Files
bitvid/tests/sync-conflict.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 dec5cf8fe1 feat(sync): warn before overwriting a newer remote copy (conflict guard)
Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.

- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
  (getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
  (list-only, no decrypt), compare to last push, call confirmOverwrite only on a
  real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
  confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
  surface a "kept the newer copy" status when the user declines.

tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 21:21:54 -04:00

116 lines
3.9 KiB
JavaScript

// Conflict-aware push (todo #15 follow-up): a save pushes silently UNLESS the
// relay copy is newer than the one we last pushed (another device changed it) —
// then it must ask confirmOverwrite before clobbering it. On confirm it forces a
// strictly-newer created_at so the replace wins; on decline it does not push.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { pushWithConflictCheck } from "../js/services/syncConflict.js";
import { getSyncPushedAt } from "../js/services/settingsSyncFlags.js";
const PUBKEY = "a".repeat(64);
const DTAG = "bitvid:nwc";
const KIND = "wallet";
function makeEncryptedSync(remoteCreatedAt) {
return {
pushes: [],
async exists() {
return remoteCreatedAt
? { exists: true, createdAt: remoteCreatedAt }
: { exists: false };
},
async push(dTag, payload, options) {
const createdAt = Math.max(2_000_000_000, (options?.afterCreatedAt || 0) + 1);
this.pushes.push({ dTag, payload, options, createdAt });
return { ok: true, accepted: 1, total: 1, createdAt };
},
};
}
test("no remote copy → pushes silently, records the push timestamp", async () => {
localStorage.clear();
const es = makeEncryptedSync(0);
let confirmCalls = 0;
const result = await pushWithConflictCheck({
encryptedSync: es,
dTag: DTAG,
kind: KIND,
pubkey: PUBKEY,
payload: { nwcUri: "x" },
confirmOverwrite: () => {
confirmCalls += 1;
return false;
},
});
assert.equal(result.ok, true);
assert.equal(confirmCalls, 0, "no conflict → never prompt");
assert.equal(es.pushes.length, 1);
assert.equal(getSyncPushedAt(PUBKEY, KIND), es.pushes[0].createdAt, "records push time");
});
test("remote NEWER than our last push → prompts; decline does NOT push", async () => {
localStorage.clear();
const es = makeEncryptedSync(9_999_999_999); // far-future remote = newer
let prompted = 0;
const result = await pushWithConflictCheck({
encryptedSync: es,
dTag: DTAG,
kind: KIND,
pubkey: PUBKEY,
payload: { nwcUri: "x" },
confirmOverwrite: () => {
prompted += 1;
return false; // user declines
},
});
assert.equal(prompted, 1, "newer remote must prompt");
assert.equal(result.ok, false);
assert.equal(result.conflict, true);
assert.equal(es.pushes.length, 0, "declining must NOT overwrite the newer remote");
});
test("remote NEWER but user confirms → pushes strictly-newer than remote", async () => {
localStorage.clear();
const remote = 9_999_999_999;
const es = makeEncryptedSync(remote);
const result = await pushWithConflictCheck({
encryptedSync: es,
dTag: DTAG,
kind: KIND,
pubkey: PUBKEY,
payload: { nwcUri: "x" },
confirmOverwrite: () => true,
});
assert.equal(result.ok, true);
assert.equal(es.pushes.length, 1);
assert.ok(
es.pushes[0].createdAt > remote,
"the overwrite must be strictly newer than the remote so the replace wins",
);
});
test("remote OLDER than our last push → no prompt (we already own the latest)", async () => {
localStorage.clear();
// First push establishes our last-pushed timestamp.
const es = makeEncryptedSync(0);
await pushWithConflictCheck({
encryptedSync: es, dTag: DTAG, kind: KIND, pubkey: PUBKEY, payload: { nwcUri: "x" },
});
const ours = getSyncPushedAt(PUBKEY, KIND);
// Now remote reports an OLDER created_at than what we last pushed.
const es2 = makeEncryptedSync(ours - 100);
es2.pushes = [];
let prompted = 0;
const result = await pushWithConflictCheck({
encryptedSync: es2, dTag: DTAG, kind: KIND, pubkey: PUBKEY,
payload: { nwcUri: "y" },
confirmOverwrite: () => { prompted += 1; return false; },
});
assert.equal(prompted, 0, "remote not newer than our last push → no prompt");
assert.equal(result.ok, true);
assert.equal(es2.pushes.length, 1);
});