commit c0930b906580bf14221d50f339882fe85ef3d052
parent 7d364094652626d04702eb66e6a3cdb11eab842f
Author: Florian Dold <dold@taler.net>
Date: Sun, 19 Jul 2026 22:41:28 +0200
db: expand the conformance suite to the remaining record types
Diffstat:
1 file changed, 957 insertions(+), 1 deletion(-)
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -26,6 +26,8 @@
import {
AmountString,
CoinStatus,
+ MerchantContractTokenKind,
+ RefreshReason,
DenomKeyType,
TalerPreciseTimestamp,
TransactionIdStr,
@@ -52,6 +54,28 @@ import {
PlanchetStatus,
ReserveBankInfo,
WalletPlanchet,
+ WalletProposalDownloadInfo,
+ WalletPurchase,
+ WalletTransactionMeta,
+ PurchaseStatus,
+ DepositOperationStatus,
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentCreditStatus,
+ PeerPushCreditStatus,
+ PeerPushDebitStatus,
+ RecoupOperationStatus,
+ RefreshCoinStatus,
+ RefreshOperationStatus,
+ WalletDepositGroup,
+ WalletPeerPullCredit,
+ WalletPeerPullDebit,
+ WalletPeerPushCredit,
+ WalletPeerPushDebit,
+ WalletRecoupGroup,
+ WalletRefreshGroup,
+ WalletRefreshSession,
+ WalletSlate,
+ WalletToken,
WalletWithdrawalGroup,
WgInfo,
WithdrawalGroupStatus,
@@ -282,6 +306,278 @@ function makePlanchet(
return pl;
}
+function makeDownloadInfo(
+ fulfillmentUrl: string | undefined,
+): WalletProposalDownloadInfo {
+ const dl: WalletProposalDownloadInfo = {
+ contractTermsHash: "cth-1",
+ currency: "TESTKUDOS",
+ contractTermsMerchantSig: "sig-1",
+ ...(fulfillmentUrl !== undefined ? { fulfillmentUrl } : undefined),
+ };
+ return dl;
+}
+
+function makePurchase(proposalId: string): WalletPurchase {
+ const p: WalletPurchase = {
+ proposalId,
+ orderId: `order-${proposalId}`,
+ merchantBaseUrl: "https://merchant.test/",
+ claimToken: undefined,
+ downloadSessionId: undefined,
+ repurchaseProposalId: undefined,
+ purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
+ noncePriv: `npriv-${proposalId}`,
+ noncePub: `npub-${proposalId}`,
+ secretSeed: undefined,
+ download: undefined,
+ payInfo: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ merchantPaySig: undefined,
+ posConfirmation: undefined,
+ shared: false,
+ timestamp: tsPrecise(1000),
+ timestampAccept: undefined,
+ timestampLastRefundStatus: undefined,
+ lastSessionId: undefined,
+ autoRefundDeadline: undefined,
+ refundAmountAwaiting: undefined,
+ };
+ return p;
+}
+
+/**
+ * Strip keys whose value is undefined.
+ *
+ * The two backends disagree on how an *absent optional* field comes back:
+ * IndexedDB returns the key with value undefined (its structured clone
+ * materialises every declared field), while the sqlite mapper omits the key.
+ * Both read identically -- `rec.abortReason` is undefined either way, and
+ * both serialise the same -- so the DAL contract is that callers must not
+ * distinguish them, and these round-trip cases compare accordingly.
+ *
+ * This does NOT apply to fields declared as `T | undefined` rather than `T?`:
+ * those keys are required and must be present, which the coin and exchange
+ * cases assert explicitly with `in`.
+ */
+function withoutUndefined<T>(v: T): T {
+ if (v === null || typeof v !== "object" || Array.isArray(v)) {
+ return v;
+ }
+ const out: Record<string, unknown> = {};
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
+ if (val !== undefined) {
+ out[k] = val;
+ }
+ }
+ return out as T;
+}
+
+const tokenFamilyFields = () => ({
+ slug: "fam-slug",
+ name: "Family",
+ description: "A token family",
+ extraData: {
+ class: MerchantContractTokenKind.Subscription as const,
+ trusted_domains: ["example.com"],
+ },
+ tokenIssuePub: {
+ cipher: "RSA" as const,
+ rsa_pub: "rsa-pub",
+ signature_validity_start: { t_s: 1 },
+ signature_validity_end: { t_s: 2 },
+ },
+ descriptionI18n: undefined,
+});
+
+function makeToken(tokenUsePub: string): WalletToken {
+ const tok: WalletToken = {
+ ...tokenFamilyFields(),
+ tokenUsePub,
+ tokenUsePriv: `priv-${tokenUsePub}`,
+ purchaseId: "pur-tok",
+ merchantBaseUrl: "https://merchant.test/",
+ kind: MerchantContractTokenKind.Subscription,
+ tokenIssuePubHash: "tiph-1",
+ validAfter: ts(100),
+ validBefore: ts(200),
+ tokenIssueSig: { cipher: DenomKeyType.Rsa, rsa_signature: "isig" },
+ tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" },
+ tokenEvHash: `evh-${tokenUsePub}`,
+ blindingKey: "bk-tok",
+ };
+ return tok;
+}
+
+function makeSlate(
+ tokenUsePub: string,
+ purchaseId: string,
+ choiceIndex: number,
+ outputIndex: number,
+ repeatIndex: number,
+): WalletSlate {
+ const sl: WalletSlate = {
+ ...tokenFamilyFields(),
+ tokenUsePub,
+ tokenUsePriv: `priv-${tokenUsePub}`,
+ purchaseId,
+ choiceIndex,
+ outputIndex,
+ repeatIndex,
+ merchantBaseUrl: "https://merchant.test/",
+ kind: MerchantContractTokenKind.Subscription,
+ tokenIssuePubHash: "tiph-1",
+ validAfter: ts(100),
+ validBefore: ts(200),
+ tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" },
+ tokenEvHash: `evh-${tokenUsePub}`,
+ blindingKey: "bk-slate",
+ };
+ return sl;
+}
+
+function makeDepositGroup(depositGroupId: string): WalletDepositGroup {
+ const dg: WalletDepositGroup = {
+ depositGroupId,
+ currency: "TESTKUDOS",
+ amount: amt("TESTKUDOS:5"),
+ wireTransferDeadline: ts(9999),
+ merchantPub: "mpub",
+ merchantPriv: "mpriv",
+ noncePriv: "npriv",
+ noncePub: "npub",
+ wire: { payto_uri: "payto://iban/DE1", salt: "salt-1" },
+ contractTermsHash: "cth",
+ totalPayCost: amt("TESTKUDOS:5.1"),
+ counterpartyEffectiveDepositAmount: amt("TESTKUDOS:5"),
+ timestampCreated: tsPrecise(1000),
+ timestampFinished: undefined,
+ timestampLastDepositAttempt: undefined,
+ operationStatus: DepositOperationStatus.PendingDeposit,
+ };
+ return dg;
+}
+
+function makeRefreshGroup(refreshGroupId: string): WalletRefreshGroup {
+ const rg: WalletRefreshGroup = {
+ refreshGroupId,
+ operationStatus: RefreshOperationStatus.Pending,
+ currency: "TESTKUDOS",
+ reason: RefreshReason.Manual,
+ oldCoinPubs: ["c1", "c2"],
+ inputPerCoin: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")],
+ expectedOutputPerCoin: [amt("TESTKUDOS:0.9"), amt("TESTKUDOS:1.9")],
+ statusPerCoin: [RefreshCoinStatus.Pending, RefreshCoinStatus.Pending],
+ refundRequests: {},
+ timestampCreated: tsPrecise(1000),
+ timestampFinished: undefined,
+ };
+ return rg;
+}
+
+function makeRefreshSession(
+ refreshGroupId: string,
+ coinIndex: number,
+): WalletRefreshSession {
+ const rs: WalletRefreshSession = {
+ refreshGroupId,
+ coinIndex,
+ amountRefreshOutput: amt("TESTKUDOS:1"),
+ newDenoms: [{ denomPubHash: "dph-1", count: 2 }],
+ };
+ return rs;
+}
+
+function makeRecoupGroup(
+ recoupGroupId: string,
+ exchangeBaseUrl: string,
+): WalletRecoupGroup {
+ const rc: WalletRecoupGroup = {
+ recoupGroupId,
+ exchangeBaseUrl,
+ operationStatus: RecoupOperationStatus.Pending,
+ timestampStarted: tsPrecise(1000),
+ timestampFinished: undefined,
+ coinPubs: ["c1"],
+ recoupFinishedPerCoin: [false],
+ scheduleRefreshCoins: [],
+ };
+ return rc;
+}
+
+function makePeerPushDebit(pursePub: string): WalletPeerPushDebit {
+ const rec: WalletPeerPushDebit = {
+ pursePub,
+ exchangeBaseUrl: "https://exchange.test/",
+ amount: amt("TESTKUDOS:3"),
+ totalCost: amt("TESTKUDOS:3.1"),
+ contractTermsHash: "cth",
+ pursePriv: "ppriv",
+ mergePub: "mpub",
+ mergePriv: "mpriv",
+ contractPriv: "cpriv",
+ contractPub: "cpub",
+ contractEncNonce: "nonce",
+ purseExpiration: ts(9999),
+ timestampCreated: tsPrecise(1000),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ };
+ return rec;
+}
+
+function makePeerPushCredit(peerPushCreditId: string): WalletPeerPushCredit {
+ const rec: WalletPeerPushCredit = {
+ peerPushCreditId,
+ exchangeBaseUrl: "https://exchange.test/",
+ pursePub: `purse-${peerPushCreditId}`,
+ mergePriv: "mpriv",
+ contractPriv: "cpriv",
+ timestamp: tsPrecise(1000),
+ estimatedAmountEffective: amt("TESTKUDOS:2"),
+ contractTermsHash: "cth",
+ status: PeerPushCreditStatus.PendingMerge,
+ withdrawalGroupId: undefined,
+ currency: undefined,
+ };
+ return rec;
+}
+
+function makePeerPullDebit(peerPullDebitId: string): WalletPeerPullDebit {
+ const rec: WalletPeerPullDebit = {
+ peerPullDebitId,
+ pursePub: `purse-${peerPullDebitId}`,
+ exchangeBaseUrl: "https://exchange.test/",
+ amount: amt("TESTKUDOS:4"),
+ contractTermsHash: "cth",
+ timestampCreated: tsPrecise(1000),
+ contractPriv: "cpriv",
+ status: PeerPullDebitRecordStatus.PendingDeposit,
+ totalCostEstimated: amt("TESTKUDOS:4.1"),
+ };
+ return rec;
+}
+
+function makePeerPullCredit(pursePub: string): WalletPeerPullCredit {
+ const rec: WalletPeerPullCredit = {
+ pursePub,
+ exchangeBaseUrl: "https://exchange.test/",
+ amount: amt("TESTKUDOS:6"),
+ estimatedAmountEffective: amt("TESTKUDOS:6"),
+ pursePriv: "ppriv",
+ contractTermsHash: "cth",
+ mergePub: "mpub",
+ mergePriv: "mpriv",
+ contractPub: "cpub",
+ contractPriv: "cpriv",
+ contractEncNonce: "nonce",
+ mergeTimestamp: tsPrecise(1000),
+ mergeReserveRowId: 1,
+ status: PeerPullPaymentCreditStatus.PendingCreatePurse,
+ withdrawalGroupId: undefined,
+ };
+ return rec;
+}
+
function makeReserve(reservePub: string): WalletReserve {
const r: WalletReserve = {
reservePub,
@@ -1499,7 +1795,7 @@ export const conformanceCases: ConformanceCase[] = [
const got = await runner.runTx((tx) =>
tx.getExchangeMigrationLog("https://a/", "https://b/"),
);
- t.deepEqual(got, rec);
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
t.equal(
typeof got?.reason,
"string",
@@ -1776,4 +2072,664 @@ export const conformanceCases: ConformanceCase[] = [
);
},
},
+ // -------------------------------------------------- transaction meta
+
+ {
+ name: "transaction meta: round trips and updates in place",
+ async run(t, runner) {
+ const rec: WalletTransactionMeta = {
+ transactionId: "txn:meta:1",
+ timestamp: tsPrecise(500),
+ status: WithdrawalGroupStatus.PendingRegisteringBank,
+ currency: "TESTKUDOS",
+ exchanges: ["https://e1/", "https://e2/"],
+ };
+ await runner.runTx((tx) => tx.upsertTransactionMeta(rec));
+ let got = await runner.runTx((tx) => tx.getTransactionMeta("txn:meta:1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
+ rec.status = WithdrawalGroupStatus.Done;
+ await runner.runTx((tx) => tx.upsertTransactionMeta(rec));
+ got = await runner.runTx((tx) => tx.getTransactionMeta("txn:meta:1"));
+ t.equal(got?.status, WithdrawalGroupStatus.Done);
+ const all = await runner.runTx((tx) =>
+ tx.listTransactionMetaByTimestamp({}),
+ );
+ t.equal(
+ all.filter((m) => m.transactionId === "txn:meta:1").length,
+ 1,
+ "an update must not append a second row",
+ );
+ },
+ },
+
+ {
+ name: "transaction meta: before is inclusive, after-cursor is exclusive",
+ async run(t, runner) {
+ // These bounds come from IndexedDB key ranges: upperBound(ts, false)
+ // and lowerBound(ts, false) are inclusive, while the pagination cursor
+ // uses lowerBound(ts, true) -- exclusive. Getting one of them wrong
+ // either skips a transaction or loops on it forever.
+ await runner.runTx(async (tx) => {
+ for (const [id, at] of [
+ ["txn:p:10", 10],
+ ["txn:p:20", 20],
+ ["txn:p:30", 30],
+ ] as const) {
+ await tx.upsertTransactionMeta({
+ transactionId: id,
+ timestamp: tsPrecise(at),
+ status: WithdrawalGroupStatus.Done,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ }
+ });
+ const before = await runner.runTx((tx) =>
+ tx.getTransactionMetaBefore(tsPrecise(20)),
+ );
+ t.equal(before?.transactionId, "txn:p:20", "before is inclusive");
+ const after = await runner.runTx((tx) =>
+ tx.getTransactionMetaAfter(tsPrecise(20)),
+ );
+ t.equal(after?.transactionId, "txn:p:20", "after is inclusive");
+ const at = await runner.runTx((tx) =>
+ tx.getTransactionMetaAtTimestamp(tsPrecise(30)),
+ );
+ t.equal(at?.transactionId, "txn:p:30");
+ const page = await runner.runTx((tx) =>
+ tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(20) }),
+ );
+ t.deepEqual(
+ page.map((m) => m.transactionId),
+ ["txn:p:30"],
+ "the pagination cursor is exclusive",
+ );
+ },
+ },
+
+ {
+ name: "transaction meta: ordered by timestamp, and limited",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ for (const [id, at] of [
+ ["txn:o:c", 300],
+ ["txn:o:a", 100],
+ ["txn:o:b", 200],
+ ] as const) {
+ await tx.upsertTransactionMeta({
+ transactionId: id,
+ timestamp: tsPrecise(at),
+ status: WithdrawalGroupStatus.Done,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ }
+ });
+ const all = await runner.runTx((tx) =>
+ tx.listTransactionMetaByTimestamp({}),
+ );
+ const mine = all.filter((m) => m.transactionId.startsWith("txn:o:"));
+ t.deepEqual(
+ mine.map((m) => m.transactionId),
+ ["txn:o:a", "txn:o:b", "txn:o:c"],
+ "must be ordered by timestamp ascending, not insertion order",
+ );
+ const limited = await runner.runTx((tx) =>
+ tx.listTransactionMetaByTimestamp({ limit: 1 }),
+ );
+ t.equal(limited.length, 1);
+ },
+ },
+
+ {
+ name: "transaction meta: onlyActive selects the non-final range",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertTransactionMeta({
+ transactionId: "txn:act:1",
+ timestamp: tsPrecise(1),
+ status: WithdrawalGroupStatus.PendingRegisteringBank,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ await tx.upsertTransactionMeta({
+ transactionId: "txn:act:2",
+ timestamp: tsPrecise(2),
+ status: WithdrawalGroupStatus.Done,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ });
+ const active = await runner.runTx((tx) =>
+ tx.listTransactionMetaByStatus({ onlyActive: true }),
+ );
+ const ids = active.map((m) => m.transactionId);
+ t.ok(ids.includes("txn:act:1"));
+ t.ok(!ids.includes("txn:act:2"), "a final state must not be active");
+ const all = await runner.runTx((tx) =>
+ tx.listTransactionMetaByStatus({ onlyActive: false }),
+ );
+ t.ok(all.length >= active.length);
+ },
+ },
+
+ {
+ name: "transaction meta: delete one and delete all",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertTransactionMeta({
+ transactionId: "txn:d:1",
+ timestamp: tsPrecise(1),
+ status: WithdrawalGroupStatus.Done,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ await tx.upsertTransactionMeta({
+ transactionId: "txn:d:2",
+ timestamp: tsPrecise(2),
+ status: WithdrawalGroupStatus.Done,
+ currency: "TESTKUDOS",
+ exchanges: [],
+ });
+ });
+ await runner.runTx((tx) => tx.deleteTransactionMeta("txn:d:1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getTransactionMeta("txn:d:1")),
+ undefined,
+ );
+ t.ok(await runner.runTx((tx) => tx.getTransactionMeta("txn:d:2")));
+ await runner.runTx((tx) => tx.deleteAllTransactionMeta());
+ const left = await runner.runTx((tx) =>
+ tx.listTransactionMetaByTimestamp({}),
+ );
+ t.equal(left.length, 0, "deleteAll must clear the whole view");
+ },
+ },
+
+ // ---------------------------------------------------------- purchases
+
+ {
+ name: "purchase: round trips including the exchange list",
+ async run(t, runner) {
+ const p = makePurchase("prop-rt");
+ p.exchanges = ["https://ex-a/", "https://ex-b/"];
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ const got = await runner.runTx((tx) => tx.getPurchase("prop-rt"));
+ t.deepEqual(got, p, "every field must survive, exchange order included");
+ },
+ },
+
+ {
+ name: "purchase: the exchange list has exactly one stored copy",
+ async run(t, runner) {
+ // The native schema keeps this in a junction table rather than a JSON
+ // column plus a multiEntry index. Shrinking the list must therefore
+ // remove rows, not leave a stale one that byExchange still matches.
+ const p = makePurchase("prop-ex");
+ p.exchanges = ["https://keep/", "https://drop/"];
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ t.equal(
+ (await runner.runTx((tx) => tx.getPurchasesByExchange("https://drop/")))
+ .length,
+ 1,
+ );
+ p.exchanges = ["https://keep/"];
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ const stillDropped = await runner.runTx((tx) =>
+ tx.getPurchasesByExchange("https://drop/"),
+ );
+ t.equal(stillDropped.length, 0, "the removed exchange must not match");
+ const kept = await runner.runTx((tx) =>
+ tx.getPurchasesByExchange("https://keep/"),
+ );
+ t.equal(kept.length, 1);
+ t.deepEqual(kept[0].exchanges, ["https://keep/"]);
+ },
+ },
+
+ {
+ name: "purchase: the fulfillment URL has exactly one stored copy",
+ async run(t, runner) {
+ const p = makePurchase("prop-ff");
+ p.download = makeDownloadInfo("https://shop/fulfil/first");
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ p.download = makeDownloadInfo("https://shop/fulfil/second");
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ const byOld = await runner.runTx((tx) =>
+ tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/first"),
+ );
+ t.equal(byOld.length, 0, "the old URL must no longer resolve");
+ const byNew = await runner.runTx((tx) =>
+ tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/second"),
+ );
+ t.equal(byNew.length, 1);
+ t.equal(
+ byNew[0].download?.fulfillmentUrl,
+ "https://shop/fulfil/second",
+ "record and index must agree",
+ );
+ },
+ },
+
+ {
+ name: "purchase: a download without a fulfillment URL round trips",
+ async run(t, runner) {
+ const p = makePurchase("prop-nf");
+ const dl = makeDownloadInfo(undefined);
+ p.download = dl;
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ const got = await runner.runTx((tx) => tx.getPurchase("prop-nf"));
+ t.deepEqual(got?.download, dl);
+ t.equal(got?.download?.fulfillmentUrl, undefined);
+ },
+ },
+
+ {
+ name: "purchase: lookup by merchant URL and order id",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ const a = makePurchase("prop-o1");
+ a.merchantBaseUrl = "https://m1/";
+ a.orderId = "order-1";
+ const b = makePurchase("prop-o2");
+ b.merchantBaseUrl = "https://m1/";
+ b.orderId = "order-2";
+ await tx.upsertPurchase(a);
+ await tx.upsertPurchase(b);
+ });
+ const one = await runner.runTx((tx) =>
+ tx.getPurchaseByUrlAndOrderId("https://m1/", "order-2"),
+ );
+ t.equal(one?.proposalId, "prop-o2");
+ const many = await runner.runTx((tx) =>
+ tx.getPurchasesByUrlAndOrderId("https://m1/", "order-1"),
+ );
+ t.equal(many.length, 1);
+ const none = await runner.runTx((tx) =>
+ tx.getPurchaseByUrlAndOrderId("https://m1/", "no-such-order"),
+ );
+ t.equal(none, undefined);
+ },
+ },
+
+ {
+ name: "purchase: delete removes the purchase and its exchange rows",
+ async run(t, runner) {
+ const p = makePurchase("prop-del");
+ p.exchanges = ["https://gone/"];
+ await runner.runTx((tx) => tx.upsertPurchase(p));
+ await runner.runTx((tx) => tx.deletePurchase("prop-del"));
+ t.equal(
+ await runner.runTx((tx) => tx.getPurchase("prop-del")),
+ undefined,
+ );
+ const orphaned = await runner.runTx((tx) =>
+ tx.getPurchasesByExchange("https://gone/"),
+ );
+ t.equal(orphaned.length, 0, "no orphaned exchange rows may remain");
+ },
+ },
+
+ {
+ name: "purchase: status filters and the active range",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ const pending = makePurchase("prop-s1");
+ pending.purchaseStatus = PurchaseStatus.PendingDownloadingProposal;
+ const failed = makePurchase("prop-s2");
+ failed.purchaseStatus = PurchaseStatus.Failed;
+ await tx.upsertPurchase(pending);
+ await tx.upsertPurchase(failed);
+ });
+ const byStatus = await runner.runTx((tx) =>
+ tx.getPurchasesByStatus(PurchaseStatus.Failed),
+ );
+ t.equal(byStatus.length, 1);
+ t.equal(byStatus[0].proposalId, "prop-s2");
+ const active = await runner.runTx((tx) => tx.getActivePurchases());
+ const ids = active.map((p) => p.proposalId);
+ t.ok(ids.includes("prop-s1"));
+ t.ok(!ids.includes("prop-s2"), "a failed purchase is not active");
+ const all = await runner.runTx((tx) => tx.listAllPurchases());
+ t.equal(all.length, 2);
+ },
+ },
+ // ------------------------------------- remaining domains: round trips
+ //
+ // Each of these stores a record with its optional fields unset and its
+ // nested JSON populated, reads it back whole, and checks that the
+ // active-status query agrees with the non-final range. The active queries
+ // are the ones worth pinning: in IndexedDB they are a key range over a
+ // status index, and in SQL a BETWEEN -- an off-by-one at either bound
+ // silently drops a transaction from the wallet's task list.
+
+ {
+ name: "deposit group: round trip and active range",
+ async run(t, runner) {
+ const dg = makeDepositGroup("dg-1");
+ await runner.runTx((tx) => tx.upsertDepositGroup(dg));
+ const got = await runner.runTx((tx) => tx.getDepositGroup("dg-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(dg));
+ const done = makeDepositGroup("dg-2");
+ done.operationStatus = DepositOperationStatus.Finished;
+ await runner.runTx((tx) => tx.upsertDepositGroup(done));
+ const active = await runner.runTx((tx) => tx.getActiveDepositGroups());
+ const ids = active.map((d) => d.depositGroupId);
+ t.ok(ids.includes("dg-1"));
+ t.ok(!ids.includes("dg-2"), "a finished group is not active");
+ t.equal(
+ (await runner.runTx((tx) => tx.listAllDepositGroups())).length,
+ 2,
+ );
+ await runner.runTx((tx) => tx.deleteDepositGroup("dg-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getDepositGroup("dg-1")),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "refresh group: round trip, active range and originating lookup",
+ async run(t, runner) {
+ const rg = makeRefreshGroup("rg-1");
+ rg.originatingTransactionId = "txn:orig:1";
+ await runner.runTx((tx) => tx.upsertRefreshGroup(rg));
+ const got = await runner.runTx((tx) => tx.getRefreshGroup("rg-1"));
+ t.deepEqual(
+ withoutUndefined(got),
+ withoutUndefined(rg),
+ "nested per-coin arrays must survive",
+ );
+ const done = makeRefreshGroup("rg-2");
+ done.operationStatus = RefreshOperationStatus.Finished;
+ await runner.runTx((tx) => tx.upsertRefreshGroup(done));
+ const active = await runner.runTx((tx) => tx.getActiveRefreshGroups());
+ t.ok(active.map((r) => r.refreshGroupId).includes("rg-1"));
+ t.ok(!active.map((r) => r.refreshGroupId).includes("rg-2"));
+ const byOrig = await runner.runTx((tx) =>
+ tx.getRefreshGroupsByOriginatingTransaction("txn:orig:1"),
+ );
+ t.equal(byOrig.length, 1);
+ t.equal(
+ (await runner.runTx((tx) => tx.listAllRefreshGroups())).length,
+ 2,
+ );
+ await runner.runTx((tx) => tx.deleteRefreshGroup("rg-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getRefreshGroup("rg-1")),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "refresh session: keyed by (group, coin index)",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertRefreshSession(makeRefreshSession("rs-g", 0));
+ await tx.upsertRefreshSession(makeRefreshSession("rs-g", 1));
+ await tx.upsertRefreshSession(makeRefreshSession("rs-other", 0));
+ });
+ const one = await runner.runTx((tx) => tx.getRefreshSession("rs-g", 1));
+ t.equal(one?.coinIndex, 1);
+ const byGroup = await runner.runTx((tx) =>
+ tx.getRefreshSessionsByGroup("rs-g"),
+ );
+ t.equal(byGroup.length, 2);
+ t.deepEqual(
+ byGroup.map((r) => r.coinIndex),
+ [0, 1],
+ "sessions must come back in coin-index order",
+ );
+ await runner.runTx((tx) => tx.deleteRefreshSession("rs-g", 0));
+ t.equal(
+ (await runner.runTx((tx) => tx.getRefreshSessionsByGroup("rs-g")))
+ .length,
+ 1,
+ );
+ },
+ },
+
+ {
+ name: "recoup group: round trip, by exchange and active range",
+ async run(t, runner) {
+ const rc = makeRecoupGroup("rc-1", "https://rex/");
+ await runner.runTx((tx) => tx.upsertRecoupGroup(rc));
+ const got = await runner.runTx((tx) => tx.getRecoupGroup("rc-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rc));
+ const done = makeRecoupGroup("rc-2", "https://rex/");
+ done.operationStatus = RecoupOperationStatus.Finished;
+ await runner.runTx((tx) => tx.upsertRecoupGroup(done));
+ const byEx = await runner.runTx((tx) =>
+ tx.getRecoupGroupsByExchange("https://rex/"),
+ );
+ t.equal(byEx.length, 2);
+ const active = await runner.runTx((tx) => tx.getActiveRecoupGroups());
+ t.ok(active.map((r) => r.recoupGroupId).includes("rc-1"));
+ t.ok(!active.map((r) => r.recoupGroupId).includes("rc-2"));
+ await runner.runTx((tx) => tx.deleteRecoupGroup("rc-1"));
+ t.equal(await runner.runTx((tx) => tx.getRecoupGroup("rc-1")), undefined);
+ },
+ },
+
+ {
+ name: "peer push debit: round trip and active range",
+ async run(t, runner) {
+ const rec = makePeerPushDebit("ppd-1");
+ await runner.runTx((tx) => tx.upsertPeerPushDebit(rec));
+ const got = await runner.runTx((tx) => tx.getPeerPushDebit("ppd-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
+ const done = makePeerPushDebit("ppd-2");
+ done.status = PeerPushDebitStatus.Done;
+ await runner.runTx((tx) => tx.upsertPeerPushDebit(done));
+ const active = await runner.runTx((tx) => tx.getActivePeerPushDebits());
+ t.ok(active.map((r) => r.pursePub).includes("ppd-1"));
+ t.ok(!active.map((r) => r.pursePub).includes("ppd-2"));
+ t.equal(
+ (await runner.runTx((tx) => tx.listAllPeerPushDebits())).length,
+ 2,
+ );
+ await runner.runTx((tx) => tx.deletePeerPushDebit("ppd-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getPeerPushDebit("ppd-1")),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "peer push credit: round trip, contract-priv lookup, active range",
+ async run(t, runner) {
+ const rec = makePeerPushCredit("ppc-1");
+ rec.contractPriv = "cpriv-find-me";
+ await runner.runTx((tx) => tx.upsertPeerPushCredit(rec));
+ const got = await runner.runTx((tx) => tx.getPeerPushCredit("ppc-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
+ const byPriv = await runner.runTx((tx) =>
+ tx.getPeerPushCreditByExchangeAndContractPriv(
+ rec.exchangeBaseUrl,
+ "cpriv-find-me",
+ ),
+ );
+ t.equal(byPriv?.peerPushCreditId, "ppc-1");
+ const wrongExchange = await runner.runTx((tx) =>
+ tx.getPeerPushCreditByExchangeAndContractPriv(
+ "https://other/",
+ "cpriv-find-me",
+ ),
+ );
+ t.equal(wrongExchange, undefined, "both components must match");
+ const done = makePeerPushCredit("ppc-2");
+ done.status = PeerPushCreditStatus.Done;
+ await runner.runTx((tx) => tx.upsertPeerPushCredit(done));
+ const active = await runner.runTx((tx) => tx.getActivePeerPushCredits());
+ t.ok(active.map((r) => r.peerPushCreditId).includes("ppc-1"));
+ t.ok(!active.map((r) => r.peerPushCreditId).includes("ppc-2"));
+ await runner.runTx((tx) => tx.deletePeerPushCredit("ppc-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getPeerPushCredit("ppc-1")),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "peer pull debit: round trip, contract-priv lookup, active range",
+ async run(t, runner) {
+ const rec = makePeerPullDebit("ppld-1");
+ rec.contractPriv = "cpriv-pull";
+ await runner.runTx((tx) => tx.upsertPeerPullDebit(rec));
+ const got = await runner.runTx((tx) => tx.getPeerPullDebit("ppld-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
+ const byPriv = await runner.runTx((tx) =>
+ tx.getPeerPullDebitByExchangeAndContractPriv(
+ rec.exchangeBaseUrl,
+ "cpriv-pull",
+ ),
+ );
+ t.equal(byPriv?.peerPullDebitId, "ppld-1");
+ const done = makePeerPullDebit("ppld-2");
+ done.status = PeerPullDebitRecordStatus.Done;
+ await runner.runTx((tx) => tx.upsertPeerPullDebit(done));
+ const active = await runner.runTx((tx) => tx.getActivePeerPullDebits());
+ t.ok(active.map((r) => r.peerPullDebitId).includes("ppld-1"));
+ t.ok(!active.map((r) => r.peerPullDebitId).includes("ppld-2"));
+ t.equal(
+ (await runner.runTx((tx) => tx.listAllPeerPullDebits())).length,
+ 2,
+ );
+ await runner.runTx((tx) => tx.deletePeerPullDebit("ppld-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getPeerPullDebit("ppld-1")),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "peer pull credit: round trip and active range",
+ async run(t, runner) {
+ const rec = makePeerPullCredit("pplc-1");
+ await runner.runTx((tx) => tx.upsertPeerPullCredit(rec));
+ const got = await runner.runTx((tx) => tx.getPeerPullCredit("pplc-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(rec));
+ const done = makePeerPullCredit("pplc-2");
+ done.status = PeerPullPaymentCreditStatus.Done;
+ await runner.runTx((tx) => tx.upsertPeerPullCredit(done));
+ const active = await runner.runTx((tx) => tx.getActivePeerPullCredits());
+ t.ok(active.map((r) => r.pursePub).includes("pplc-1"));
+ t.ok(!active.map((r) => r.pursePub).includes("pplc-2"));
+ t.equal(
+ (await runner.runTx((tx) => tx.listAllPeerPullCredits())).length,
+ 2,
+ );
+ await runner.runTx((tx) => tx.deletePeerPullCredit("pplc-1"));
+ t.equal(
+ await runner.runTx((tx) => tx.getPeerPullCredit("pplc-1")),
+ undefined,
+ );
+ },
+ },
+ // ------------------------------------------------------ tokens / slates
+
+ {
+ name: "token: round trip, including the inherited family fields",
+ async run(t, runner) {
+ // WalletToken extends TokenFamilyInfo, so slug/name/description/
+ // extraData/tokenIssuePub are part of the record even though they are
+ // declared in a different interface. Reading the type through only
+ // its own body once cost five silently dropped columns here.
+ const tok = makeToken("tk-1");
+ await runner.runTx((tx) => tx.upsertToken(tok));
+ const got = await runner.runTx((tx) => tx.getToken("tk-1"));
+ t.deepEqual(withoutUndefined(got), withoutUndefined(tok));
+ t.equal(got?.slug, tok.slug, "inherited fields must persist");
+ t.deepEqual(got?.tokenIssuePub, tok.tokenIssuePub);
+ t.deepEqual(got?.extraData, tok.extraData);
+ t.deepEqual(got?.tokenEv, tok.tokenEv);
+ t.equal(got?.blindingKey, tok.blindingKey);
+ },
+ },
+
+ {
+ name: "token: lookup by issue pub hash, list and delete",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ const a = makeToken("tk-a");
+ a.tokenIssuePubHash = "tiph-shared";
+ const b = makeToken("tk-b");
+ b.tokenIssuePubHash = "tiph-shared";
+ const c = makeToken("tk-c");
+ c.tokenIssuePubHash = "tiph-other";
+ await tx.upsertToken(a);
+ await tx.upsertToken(b);
+ await tx.upsertToken(c);
+ });
+ const byHash = await runner.runTx((tx) =>
+ tx.getTokensByIssuePubHash("tiph-shared"),
+ );
+ t.equal(byHash.length, 2);
+ t.equal((await runner.runTx((tx) => tx.listTokens())).length, 3);
+ await runner.runTx((tx) => tx.deleteToken("tk-a"));
+ t.equal(await runner.runTx((tx) => tx.getToken("tk-a")), undefined);
+ t.equal(
+ (await runner.runTx((tx) => tx.getTokensByIssuePubHash("tiph-shared")))
+ .length,
+ 1,
+ );
+ },
+ },
+
+ {
+ name: "slate: addressed by the full (purchase, choice, output, repeat)",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertSlate(makeSlate("sl-1", "pur-1", 0, 0, 0));
+ await tx.upsertSlate(makeSlate("sl-2", "pur-1", 0, 0, 1));
+ await tx.upsertSlate(makeSlate("sl-3", "pur-1", 0, 1, 0));
+ await tx.upsertSlate(makeSlate("sl-4", "pur-1", 1, 0, 0));
+ });
+ const one = await runner.runTx((tx) => tx.getSlate("pur-1", 0, 0, 1));
+ t.equal(
+ one?.tokenUsePub,
+ "sl-2",
+ "all four components must select the slate",
+ );
+ const byChoice = await runner.runTx((tx) =>
+ tx.getSlatesByPurchaseAndChoice("pur-1", 0),
+ );
+ t.equal(byChoice.length, 3, "choice 1 must not be included");
+ await runner.runTx((tx) => tx.deleteSlate("sl-2"));
+ t.equal(
+ await runner.runTx((tx) => tx.getSlate("pur-1", 0, 0, 1)),
+ undefined,
+ );
+ },
+ },
+
+ {
+ name: "slate: round trip with the use signature set and unset",
+ async run(t, runner) {
+ const unsigned = makeSlate("sl-u", "pur-2", 0, 0, 0);
+ await runner.runTx((tx) => tx.upsertSlate(unsigned));
+ const gotUnsigned = await runner.runTx((tx) =>
+ tx.getSlate("pur-2", 0, 0, 0),
+ );
+ t.deepEqual(withoutUndefined(gotUnsigned), withoutUndefined(unsigned));
+ t.equal(gotUnsigned?.tokenUseSig, undefined);
+ const signed = makeSlate("sl-s", "pur-3", 0, 0, 0);
+ signed.tokenUseSig = {
+ token_sig: "tsig",
+ token_pub: "tpub",
+ ub_sig: { cipher: DenomKeyType.Rsa, rsa_signature: "usig" },
+ h_issue: "hissue",
+ };
+ await runner.runTx((tx) => tx.upsertSlate(signed));
+ const gotSigned = await runner.runTx((tx) =>
+ tx.getSlate("pur-3", 0, 0, 0),
+ );
+ t.deepEqual(gotSigned?.tokenUseSig, signed.tokenUseSig);
+ },
+ },
];