mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 09:08:30 +00:00
* 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>
305 lines
14 KiB
TypeScript
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");
|
|
});
|