Files
simplex-chat/apps/simplex-badge-service/web/test/store.test.ts
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

305 lines
14 KiB
TypeScript

import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { type OrderRecord } from "../src/domain.js";
import { Store } from "../src/store.js";
const storeTest = timedTest(2000);
class MemoryStorage {
map = new Map<string, string>();
failWrites = false;
getItem(k: string) { return this.map.get(k) ?? null; }
setItem(k: string, v: string) { if (this.failWrites) throw new Error("QuotaExceeded"); this.map.set(k, v); }
removeItem(k: string) { this.map.delete(k); }
}
const order = (id: string, over: Partial<OrderRecord> = {}): OrderRecord => ({
orderId: id, badgeType: "legend", months: 12,
createdAt: new Date(Date.parse("2026-08-24T11:02:19Z") + Number(id)).toISOString(),
status: "open", ...over,
});
storeTest("store: session round-trips the step and the draft", () => {
const s = new Store(new MemoryStorage());
assert.equal(s.session().step, "tier");
s.saveSession({ step: "months", priceId: "price_legend" });
assert.equal(s.session().step, "months");
assert.equal(s.session().priceId, "price_legend");
s.clearSession();
assert.equal(s.session().priceId, undefined);
});
storeTest("store: orders upsert by id and keep a stored code", () => {
const s = new Store(new MemoryStorage());
s.saveOrder(order("1", { code: "SB-CODE" }));
s.saveOrder(order("1", { status: "paid" }));
const got = s.orders()[0]!;
assert.equal(got.status, "paid");
assert.equal(got.code, "SB-CODE", "a later record must not clear the code");
});
storeTest("store: the cap drops the oldest entry holding no code", () => {
const s = new Store(new MemoryStorage());
for (let i = 0; i < 50; i++) s.saveOrder(order(String(i), i < 10 ? {} : { code: "C" + i }));
s.saveOrder(order("999", { code: "NEW" }));
const ids = s.orders().map((o) => o.orderId);
assert.equal(ids.length, 50);
assert.ok(!ids.includes("0"), "the oldest codeless entry is dropped");
assert.ok(ids.includes("999"));
});
storeTest("store: with every entry holding a code the new one is not stored", () => {
const s = new Store(new MemoryStorage());
for (let i = 0; i < 50; i++) s.saveOrder(order(String(i), { code: "C" + i }));
assert.equal(s.saveOrder(order("999", { code: "NEW" })), false);
assert.equal(s.orders().length, 50);
});
storeTest("store: a failing storage degrades without throwing", () => {
const mem = new MemoryStorage();
const s = new Store(mem);
mem.failWrites = true;
assert.equal(s.saveOrder(order("1", { code: "C" })), false);
assert.equal(s.saveSession({ step: "months" }), false);
assert.deepEqual(s.orders(), []);
assert.equal(s.session().step, "tier");
});
storeTest("store: corruption is replaced, not parsed", () => {
const mem = new MemoryStorage();
mem.map.set("sb.orders.v1", "{not json");
const s = new Store(mem);
assert.deepEqual(s.orders(), []);
assert.equal(s.saveOrder(order("1")), true);
assert.equal(s.orders().length, 1);
});
storeTest("store: newestOpen finds the resumable order", () => {
const s = new Store(new MemoryStorage());
s.saveOrder(order("1", { status: "expired" }));
s.saveOrder(order("2", { status: "open" }));
s.saveOrder(order("3", { status: "paid", code: "C" }));
assert.equal(s.newestOpen()?.orderId, "2");
});
storeTest("store: shape-corrupted but parseable entries are dropped, not thrown", () => {
const mem = new MemoryStorage();
mem.map.set("sb.orders.v1", JSON.stringify([{ foo: 1 }, { bar: 2 }]));
const s = new Store(mem);
assert.deepEqual(s.orders(), [], "malformed elements are dropped rather than surfaced");
assert.equal(s.saveOrder(order("1")), true, "the write path must not throw on corrupted shape");
assert.equal(s.orders().length, 1);
});
storeTest("store: orders sort chronologically across second- and millisecond-precision timestamps", () => {
const s = new Store(new MemoryStorage());
const secOrder: OrderRecord = {
orderId: "sec", badgeType: "legend", months: 12,
createdAt: "2026-08-24T11:02:19Z", status: "open",
};
const msOrder: OrderRecord = {
orderId: "ms", badgeType: "legend", months: 12,
createdAt: "2026-08-24T11:02:19.900Z", status: "open",
};
s.saveOrder(secOrder);
s.saveOrder(msOrder);
assert.deepEqual(
s.orders().map((o) => o.orderId),
["ms", "sec"],
"the .900Z record is chronologically newer despite sorting lexicographically before the second-precision string",
);
});
storeTest("store: an unparseable createdAt sorts oldest instead of poisoning the comparator", () => {
// Date.parse answers NaN for an unreadable createdAt, and NaN makes every comparison false, leaving the sort order undefined.
const mem = new MemoryStorage();
mem.map.set("sb.orders.v1", JSON.stringify([
{ orderId: "undated", badgeType: "legend", months: 12, createdAt: "whenever", status: "open" },
{ orderId: "older", badgeType: "legend", months: 12, createdAt: "2026-08-20T00:00:00Z", status: "open" },
]));
const s = new Store(mem);
s.saveOrder(order("1", { status: "open", createdAt: "2026-08-26T00:00:00Z" }));
assert.deepEqual(s.orders().map((o) => o.orderId), ["1", "older", "undated"]);
assert.equal(s.newestOpen()?.orderId, "1", "the newest DATED open order is the one resumed");
});
storeTest("store: a stored record keeps the fields that read and drops the ones that do not", () => {
const mem = new MemoryStorage();
mem.map.set("sb.orders.v1", JSON.stringify([{
orderId: "corrupt", createdAt: "2026-08-24T11:00:00Z", status: "open",
code: "SB-KEEP-ME", badgeType: null, months: "12",
currency: 42, paidInFull: "yes", cryptoCurrency: "eth", amount: 42000,
}]));
const stored = new Store(mem).orders();
assert.equal(stored.length, 1, "a record with a code is never dropped over a bad field");
const o = stored[0]!;
assert.equal(o.code, "SB-KEEP-ME");
assert.equal(o.amount, 42000, "and what does read is kept");
for (const key of ["currency", "paidInFull", "cryptoCurrency"] as const) {
assert.equal(o[key], undefined, `${key} does not read, so it is not there to be believed`);
}
assert.equal(o.badgeType, "");
assert.equal(o.months, 0);
});
storeTest("store: a record with nothing to point at is dropped whole", () => {
const mem = new MemoryStorage();
mem.map.set("sb.orders.v1", JSON.stringify([
{ orderId: "no-status", createdAt: "2026-08-24T11:00:00Z" },
{ orderId: "bad-status", createdAt: "2026-08-24T11:00:00Z", status: "refunded" },
{ orderId: "", createdAt: "2026-08-24T11:00:00Z", status: "open" },
{ createdAt: "2026-08-24T11:00:00Z", status: "open" },
"not-an-object",
null,
]));
assert.deepEqual(new Store(mem).orders(), [], "an order with no id or no state has no row to draw");
mem.map.set("sb.orders.v1", JSON.stringify({ orderId: "not-a-list" }));
assert.deepEqual(new Store(mem).orders(), []);
});
storeTest("store: a session or theme outside the known set falls back to the default", () => {
const mem = new MemoryStorage();
mem.map.set("sb.session.v1", JSON.stringify({ step: "elsewhere", priceId: 12, method: "paypal" }));
mem.map.set("sb.theme.v1", JSON.stringify("neon"));
assert.deepEqual(new Store(mem).session(), { step: "tier" });
assert.equal(new Store(mem).theme(), "system");
mem.map.set("sb.session.v1", JSON.stringify({ step: "months", priceId: "price_legend", method: "paypal" }));
assert.deepEqual(new Store(mem).session(), { step: "months", priceId: "price_legend" },
"a good step keeps what reads beside it, and only that");
});
storeTest("store: saveOrder replaces the stored entry rather than merging into it", () => {
const s = new Store(new MemoryStorage());
s.saveOrder(order("1", { address: "48HqK2Xm", cryptoAmountPaid: "1.482", paidInFull: true, amount: 42000 }));
s.saveOrder(order("1", { status: "expired" }));
const stored = s.order("1")!;
for (const key of ["address", "cryptoAmountPaid", "paidInFull", "amount"] as const) {
assert.equal(stored[key], undefined, `${key} must not survive a write that leaves it out`);
}
assert.equal(stored.status, "expired");
});
storeTest("store: the two facts only this browser holds survive a write that omits them", () => {
const s = new Store(new MemoryStorage());
s.saveOrder(order("1", { code: "SB-CODE" }));
assert.equal(s.markSubmitted("1"), true);
s.saveOrder(order("1", { status: "paid" }));
assert.equal(s.order("1")!.code, "SB-CODE");
assert.equal(s.order("1")!.submitted, true);
});
storeTest("store: markSubmitted is per order, sticky, and survives everything but Forget", () => {
const mem = new MemoryStorage();
const s = new Store(mem);
s.saveOrder(order("1", { code: "SB-CODE" }));
s.saveOrder(order("2"));
assert.equal(s.markSubmitted("1"), true);
assert.equal(s.markSubmitted("absent"), false, "there is no order to mark");
s.clearSession();
s.saveOrder(order("1", { status: "open" })); // a later 200 for the same order
assert.equal(s.order("1")!.submitted, true, "a plain upsert must not take it back off");
assert.equal(s.order("1")!.code, "SB-CODE");
assert.equal(s.order("2")!.submitted, undefined, "and it is not a page-wide flag");
// A reload is a fresh Store over the same storage.
assert.equal(new Store(mem).order("1")!.submitted, true);
});
storeTest("store: markCanceled is per order, sticky, and survives a later server answer", () => {
const mem = new MemoryStorage();
const s = new Store(mem);
s.saveOrder(order("1"));
s.saveOrder(order("2"));
assert.equal(s.markCanceled("1"), true);
assert.equal(s.markCanceled("absent"), false, "there is no order to mark");
assert.equal(s.order("1")!.canceled, true);
s.saveOrder(order("1", { status: "expired" })); // the cancel's own 200, applied to the record
assert.equal(s.order("1")!.canceled, true, "a plain upsert must not take it back off");
assert.equal(s.order("2")!.canceled, undefined, "and it is not a page-wide flag");
assert.equal(new Store(mem).order("1")!.canceled, true, "and it survives a reload");
});
storeTest("store: the cap evicts the oldest codeless entry even when an older entry holds a code", () => {
const s = new Store(new MemoryStorage());
for (let i = 0; i < 50; i++) s.saveOrder(order(String(i), i % 2 === 0 ? { code: "C" + i } : {}));
s.saveOrder(order("999", { code: "NEW" }));
const ids = s.orders().map((o) => o.orderId);
assert.equal(ids.length, 50);
assert.ok(ids.includes("0"), "the oldest entry overall holds a code and must survive");
assert.ok(!ids.includes("1"), "the oldest codeless entry is evicted, not the oldest overall");
assert.ok(ids.includes("999"));
});
storeTest("store: a store that loses its writes never claims to hold the code", () => {
const durable = new Store(new MemoryStorage());
durable.saveOrder(order("1", { code: "SB-CODE" }));
assert.equal(durable.holdsCode("1", "SB-CODE"), true);
const losing = new Store(new MemoryStorage(), false);
losing.saveOrder(order("1", { code: "SB-CODE" }));
assert.equal(losing.order("1")?.code, "SB-CODE", "it answers with what it was handed");
assert.equal(losing.holdsCode("1", "SB-CODE"), false, "and still does not claim to hold it");
assert.equal(durable.holdsCode("1", undefined), false, "no code is not a held code");
assert.equal(durable.holdsCode("2", "SB-CODE"), false);
});
storeTest("store: a full list cannot hold another code, and says so before the money", () => {
const s = new Store(new MemoryStorage());
assert.equal(s.canHoldACode(), true, "an empty store has room");
for (let i = 0; i < 50; i++) s.saveOrder(order(String(i), { code: "C" + i }));
assert.equal(s.saveOrder(order("999", { code: "NEW" })), false, "the cap really is reached");
assert.equal(s.canHoldACode(), false, "and the buyer can be told before paying");
const roomy = new Store(new MemoryStorage());
for (let i = 0; i < 50; i++) roomy.saveOrder(order(String(i), i === 7 ? {} : { code: "C" + i }));
assert.equal(roomy.canHoldACode(), true, "one codeless entry is room enough");
assert.equal(new Store(new MemoryStorage(), false).canHoldACode(), false, "and a losing store never has room");
});
storeTest("store: forgetting one key does not depend on the other succeeding", () => {
class RefusesOrders extends MemoryStorage {
override removeItem(k: string): void {
if (k === "sb.orders.v1") throw new Error("SecurityError");
super.removeItem(k);
}
}
const storage = new RefusesOrders();
const s = new Store(storage);
s.saveOrder(order("1", { code: "SB-CODE" }));
s.saveSession({ step: "checkout" });
s.forgetEverything();
assert.equal(storage.getItem("sb.session.v1"), null, "the session goes even though the orders key threw");
});
storeTest("store: forgetting everything is countable, so a write awaited across it can be dropped", () => {
const s = new Store(new MemoryStorage());
const before = s.wipeCount;
s.saveOrder(order("1"));
assert.equal(s.wipeCount, before, "an ordinary write is not a wipe");
s.forgetEverything();
assert.equal(s.wipeCount, before + 1, "and the wipe is");
s.forgetEverything();
assert.equal(s.wipeCount, before + 2, "each one counts: two answers may be in flight");
});
storeTest("store: a card-return is returned within the window, cleared on read, and dropped when stale", () => {
const s = new Store(new MemoryStorage());
const NOW = 1_000_000;
const WINDOW = 60_000;
assert.equal(s.takeCardReturn(WINDOW, NOW), undefined, "nothing remembered yet");
s.rememberCardReturn("inv_1", NOW);
assert.equal(s.takeCardReturn(WINDOW, NOW + WINDOW), "inv_1", "at the edge of the window it is the return");
assert.equal(s.takeCardReturn(WINDOW, NOW + WINDOW), undefined, "and it was cleared on the read");
s.rememberCardReturn("inv_2", NOW);
assert.equal(s.takeCardReturn(WINDOW, NOW + WINDOW + 1), undefined, "past the window it is a stale attempt");
s.rememberCardReturn("inv_3", NOW);
s.clearCardReturn();
assert.equal(s.takeCardReturn(WINDOW, NOW), undefined, "an explicit clear forgets it");
});