Files
bitvid/tests/settings-restore-prompt.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 4f56a2bd26 Wallet restore popup: re-offer on login when missing locally (like storage)
The wallet already rode the same login "restore from Nostr" offer as storage,
but a once-per-device "offered" flag suppressed it — so on a device without
your wallet you fell back to the manual Sync button. Now:

- encryptedSyncItem: add hasLocal(pubkey) (non-null payload => item present
  locally, e.g. a connected wallet / configured storage).
- settingsRestorePrompt: re-offer an item whenever it's MISSING locally and a
  Nostr backup exists — bypassing the "offered once" flag — so it surfaces
  exactly when you'd want it. Only respect the once-per-device flag when the
  item IS present locally (no nagging). A new per-device "dismissed" flag,
  set when you decline, keeps it quiet after an explicit "no".
- Accepting enables sync, so it auto-pulls silently thereafter (the "both"
  behavior: silent auto-pull when enabled + popup when missing & off).
- Unknown services (no hasLocal) keep the legacy behavior.
- tests: re-offer when missing despite prior offer; no re-offer when present;
  decline stays quiet.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-08 06:56:52 -04:00

282 lines
9.8 KiB
JavaScript

// Offer-to-pull on login (todo #15): after login, if an encrypted copy exists on
// the user's account and this device hasn't opted in, offer to restore — once.
// Scenarios assert observable outcomes:
// - prompts and restores only the items that have a remote copy + are not
// already enabled locally
// - declining does not restore
// - it never nags twice once it has prompted (per pubkey)
// - if nothing remote exists, it does NOT consume the one-time offer
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
import { isSyncEnabled } from "../js/services/settingsSyncFlags.js";
const PUBKEY = "c".repeat(64);
function makeSyncStub({
available = true,
enabled = false,
remote = false,
local = undefined,
} = {}) {
const stub = {
pulls: 0,
isAvailable: () => available,
isEnabled: () => enabled,
hasRemote: async () => remote,
pull: async function () {
this.pulls += 1;
return { found: true, imported: true };
},
};
// Only real services expose hasLocal; omitting it (local === undefined) keeps
// the legacy "assume present" behavior the older tests rely on.
if (local !== undefined) {
stub.hasLocal = async () => local;
}
return stub;
}
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
test("offers and restores items that have a remote copy and aren't already enabled", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const walletSync = makeSyncStub({ remote: false }); // no wallet note
const restoredCalls = [];
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY, {
onRestored: (items) => restoredCalls.push(items),
});
assert.equal(result.offered, true);
assert.equal(result.accepted, true);
assert.deepEqual(result.restored, ["storage"]);
assert.equal(storageSync.pulls, 1, "storage (has remote) must be pulled");
assert.equal(walletSync.pulls, 0, "wallet (no remote) must NOT be pulled");
assert.deepEqual(restoredCalls, [["storage"]]);
});
test("re-offers a MISSING item even after it was already offered once (restore-like-storage)", async () => {
localStorage.clear();
// Simulate a prior offer for wallet on this device (the old blocker).
localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } }));
const walletSync = makeSyncStub({ remote: true, local: false });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.accepted, true);
assert.deepEqual(result.restored, ["wallet"]);
assert.equal(walletSync.pulls, 1, "missing wallet is re-surfaced despite the prior offer");
});
test("does NOT re-offer an item that is present locally once prompted", async () => {
localStorage.clear();
localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } }));
const walletSync = makeSyncStub({ remote: true, local: true });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.offered, false, "present + already offered → no nag");
assert.equal(walletSync.pulls, 0);
});
test("dismissing a missing item stops it re-offering every login", async () => {
localStorage.clear();
const walletSync = makeSyncStub({ remote: true, local: false });
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: false, local: true }),
walletSync,
confirm: () => false, // decline
});
const first = await prompt.maybeOffer(PUBKEY);
assert.equal(first.offered, true);
assert.equal(first.accepted, false);
// Next login: still missing locally, but the decline is remembered.
const second = await prompt.maybeOffer(PUBKEY);
assert.equal(second.offered, false, "declined → stays quiet even though still missing");
assert.equal(walletSync.pulls, 0);
});
test("declining the prompt restores nothing", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync: makeSyncStub({ remote: false }),
confirm: () => false,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.equal(result.offered, true);
assert.equal(result.accepted, false);
assert.equal(storageSync.pulls, 0, "decline must not pull");
});
test("does not offer the SAME item twice once it has prompted", async () => {
localStorage.clear();
let confirmCount = 0;
const prompt = createSettingsRestorePrompt({
storageSync: makeSyncStub({ remote: true }),
walletSync: makeSyncStub({ remote: false }),
confirm: () => {
confirmCount += 1;
return false;
},
});
await prompt.maybeOffer(PUBKEY);
const second = await prompt.maybeOffer(PUBKEY);
assert.equal(confirmCount, 1, "must prompt only once for storage");
assert.equal(second.offered, false);
// Storage was offered+marked, wallet has no remote copy -> no candidates remain.
assert.equal(second.reason, "no-remote");
});
test("offers the WALLET later even after storage was already offered (per-item)", async () => {
// Regression: a wallet note published AFTER storage was already offered must
// still be surfaced. The old per-pubkey flag flagged the whole account on the
// first offer, so the wallet never restored "like storage does".
localStorage.clear();
const offeredKinds = [];
const storageSync = makeSyncStub({ remote: true });
// Wallet has no remote copy yet on the first login, then appears later.
let walletRemote = false;
const walletSync = {
pulls: 0,
isAvailable: () => true,
isEnabled: () => false,
hasRemote: async () => walletRemote,
pull: async function () {
this.pulls += 1;
return { found: true, imported: true };
},
};
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: (message) => {
offeredKinds.push(message);
return true;
},
});
const first = await prompt.maybeOffer(PUBKEY); // offers + restores storage only
assert.deepEqual(first.restored, ["storage"]);
assert.equal(walletSync.pulls, 0);
// Wallet sync gets set up on another device -> a note now exists.
walletRemote = true;
const second = await prompt.maybeOffer(PUBKEY);
assert.equal(second.offered, true, "wallet must still be offered on a later login");
assert.deepEqual(second.restored, ["wallet"]);
assert.equal(walletSync.pulls, 1, "wallet is pulled the first time it appears");
assert.equal(storageSync.pulls, 1, "storage is not pulled again");
// And it is not offered a third time.
const third = await prompt.maybeOffer(PUBKEY);
assert.equal(third.offered, false);
assert.equal(third.reason, "no-remote");
});
test("skips items already enabled on this device", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true, enabled: true }); // already opted in here
const walletSync = makeSyncStub({ remote: true, enabled: false });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
const result = await prompt.maybeOffer(PUBKEY);
assert.deepEqual(result.restored, ["wallet"], "only the not-yet-enabled item is offered");
assert.equal(storageSync.pulls, 0);
assert.equal(walletSync.pulls, 1);
});
test("accepting the offer ENABLES ongoing sync so future saves auto-push", async () => {
// Regression: accepting used to pull once but never set the sync flag, so the
// user "restored once but stopped syncing" and the toggle showed OFF. Accepting
// means "keep this device in sync".
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const walletSync = makeSyncStub({ remote: false });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => true,
});
assert.equal(isSyncEnabled(PUBKEY, "storage"), false, "not enabled before accept");
const result = await prompt.maybeOffer(PUBKEY);
assert.deepEqual(result.restored, ["storage"]);
assert.equal(
isSyncEnabled(PUBKEY, "storage"),
true,
"accepting must enable ongoing sync for the restored item",
);
assert.equal(
isSyncEnabled(PUBKEY, "wallet"),
false,
"an item that was not restored must not be enabled",
);
});
test("declining the offer does NOT enable sync", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: true });
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync: makeSyncStub({ remote: false }),
confirm: () => false,
});
await prompt.maybeOffer(PUBKEY);
assert.equal(isSyncEnabled(PUBKEY, "storage"), false, "decline must not enable sync");
});
test("nothing remote: does NOT consume the one-time offer (can prompt later)", async () => {
localStorage.clear();
const storageSync = makeSyncStub({ remote: false });
const walletSync = makeSyncStub({ remote: false });
let confirmCount = 0;
const prompt = createSettingsRestorePrompt({
storageSync,
walletSync,
confirm: () => {
confirmCount += 1;
return true;
},
});
const first = await prompt.maybeOffer(PUBKEY);
assert.equal(first.offered, false);
assert.equal(first.reason, "no-remote");
assert.equal(confirmCount, 0);
// Later a note appears; the offer is still available.
storageSync.hasRemote = async () => true;
const later = await prompt.maybeOffer(PUBKEY);
assert.equal(later.offered, true);
assert.equal(later.accepted, true);
assert.deepEqual(later.restored, ["storage"]);
});