taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 958++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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); + }, + }, ];