|
| 1 | +// Paying for an ending, and keeping it. |
| 2 | +// |
| 3 | +// Against a real throwaway libSQL database: the interesting behaviour is in |
| 4 | +// conditional UPDATEs and a UNIQUE constraint, and a stub has neither. |
| 5 | +import assert from "node:assert/strict"; |
| 6 | +import { mkdtempSync } from "node:fs"; |
| 7 | +import { tmpdir } from "node:os"; |
| 8 | +import path from "node:path"; |
| 9 | +import { createRequire } from "node:module"; |
| 10 | +import { randomBytes } from "node:crypto"; |
| 11 | +import test from "node:test"; |
| 12 | + |
| 13 | +const require = createRequire(import.meta.url); |
| 14 | +let installed = true; |
| 15 | +try { require("@libsql/client"); } catch { installed = false; } |
| 16 | + |
| 17 | +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-terms-")); |
| 18 | +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; |
| 19 | +process.env.SESSION_SECRET = "test-secret"; |
| 20 | + |
| 21 | +const ALICE = "user-alice"; |
| 22 | +const BOB = "user-bob"; |
| 23 | + |
| 24 | +test("ending terms", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => { |
| 25 | + const { migrate } = await import("../src/migrate.mjs"); |
| 26 | + await migrate(); |
| 27 | + const { run } = await import("../src/db.mjs"); |
| 28 | + for (const [id, email] of [[ALICE, "a@e.com"], [BOB, "b@e.com"]]) { |
| 29 | + await run(`INSERT OR IGNORE INTO users (id,email,created_at) VALUES (?,?,?)`, [id, email, Date.now()]); |
| 30 | + } |
| 31 | + const m = await import("../src/moshpit.mjs"); |
| 32 | + const uniq = () => `e${randomBytes(4).toString("hex")}`; |
| 33 | + const pay = () => `pay-${randomBytes(6).toString("hex")}`; |
| 34 | + |
| 35 | + await t.test("an unclaimed ending quotes at the ending price", async () => { |
| 36 | + const q = await m.quoteTld({ tld: uniq(), buyerId: ALICE }); |
| 37 | + assert.equal(q.ok, true); |
| 38 | + assert.equal(q.priceUsd, 5, "PRD 0005 §10.1, rounded to whole dollars"); |
| 39 | + assert.equal(q.years, 1); |
| 40 | + }); |
| 41 | + |
| 42 | + await t.test("multiple years multiply, up to the cap", async () => { |
| 43 | + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 15); |
| 44 | + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).ok, false); |
| 45 | + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 0 })).ok, false); |
| 46 | + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 1.5 })).ok, false); |
| 47 | + }); |
| 48 | + |
| 49 | + await t.test("every refusal names itself", async () => { |
| 50 | + const held = uniq(); |
| 51 | + await m.registerTld({ tld: held, userId: BOB }); |
| 52 | + |
| 53 | + assert.match((await m.quoteTld({ tld: held, buyerId: ALICE })).error, /already registered/); |
| 54 | + assert.match((await m.quoteTld({ tld: held, buyerId: BOB })).error, /already yours/); |
| 55 | + assert.match((await m.quoteTld({ tld: "bank", buyerId: ALICE })).error, /reserved/); |
| 56 | + assert.match((await m.quoteTld({ tld: "a.b", buyerId: ALICE })).error, /not a valid TLD/); |
| 57 | + }); |
| 58 | + |
| 59 | + await t.test("an open checkout holds the ending against other buyers", async () => { |
| 60 | + const tld = uniq(); |
| 61 | + await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5 }); |
| 62 | + |
| 63 | + const q = await m.quoteTld({ tld, buyerId: BOB }); |
| 64 | + assert.equal(q.ok, false); |
| 65 | + assert.equal(q.taken, true); |
| 66 | + assert.match(q.error, /in someone's checkout/); |
| 67 | + }); |
| 68 | + |
| 69 | + await t.test("an expired reservation releases it", async () => { |
| 70 | + const tld = uniq(); |
| 71 | + await m.openTldPurchase({ paymentId: pay(), tld, userId: ALICE, amountUsd: 5, now: Date.now() - m.RESERVATION_MS - 1000 }); |
| 72 | + assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true); |
| 73 | + }); |
| 74 | + |
| 75 | + await t.test("settling hands it over with a term", async () => { |
| 76 | + const tld = uniq(); |
| 77 | + const id = pay(); |
| 78 | + const now = Date.now(); |
| 79 | + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, now }); |
| 80 | + |
| 81 | + const result = await m.settleTldPurchase(id, now); |
| 82 | + assert.equal(result.ok, true); |
| 83 | + |
| 84 | + const row = await m.getTldWithTerm(tld); |
| 85 | + assert.equal(row.user_id, ALICE); |
| 86 | + assert.equal(row.term_started_at, now); |
| 87 | + assert.equal(row.expires_at, now + m.TERM_MS, "one year"); |
| 88 | + }); |
| 89 | + |
| 90 | + await t.test("a redelivered webhook does not settle twice", async () => { |
| 91 | + const tld = uniq(); |
| 92 | + const id = pay(); |
| 93 | + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 }); |
| 94 | + |
| 95 | + assert.equal((await m.settleTldPurchase(id)).ok, true); |
| 96 | + // CoinPay retries anything it never got an ack for. |
| 97 | + const again = await m.settleTldPurchase(id); |
| 98 | + assert.equal(again.ok, false); |
| 99 | + assert.match(again.error, /no pending purchase|already settled/); |
| 100 | + }); |
| 101 | + |
| 102 | + await t.test("money against an ending taken first is a refund, not a shrug", async () => { |
| 103 | + const tld = uniq(); |
| 104 | + const id = pay(); |
| 105 | + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 }); |
| 106 | + await m.registerTld({ tld, userId: BOB }); // Bob claims it in the gap |
| 107 | + |
| 108 | + const result = await m.settleTldPurchase(id); |
| 109 | + assert.equal(result.ok, false); |
| 110 | + assert.equal(result.refundDue, true); |
| 111 | + const [row] = await m.listTldPurchases(ALICE, 50); |
| 112 | + assert.equal((await m.getTldWithTerm(tld)).user_id, BOB, "not taken from Bob"); |
| 113 | + assert.ok(row, "the purchase is still on Alice's record"); |
| 114 | + }); |
| 115 | + |
| 116 | + await t.test("renewing extends, and never shortens", async () => { |
| 117 | + const tld = uniq(); |
| 118 | + const now = Date.now(); |
| 119 | + const first = pay(); |
| 120 | + await m.openTldPurchase({ paymentId: first, tld, userId: ALICE, amountUsd: 5, now }); |
| 121 | + await m.settleTldPurchase(first, now); |
| 122 | + |
| 123 | + // Renewing early adds to what is left rather than throwing it away. |
| 124 | + const second = pay(); |
| 125 | + await m.openTldPurchase({ paymentId: second, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); |
| 126 | + await m.settleTldPurchase(second, now + 1000); |
| 127 | + |
| 128 | + assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2); |
| 129 | + }); |
| 130 | + |
| 131 | + await t.test("renewing a lapsed term runs from now, not from the past", async () => { |
| 132 | + const tld = uniq(); |
| 133 | + const past = Date.now() - m.TERM_MS * 2; |
| 134 | + await run( |
| 135 | + `INSERT INTO moshpit_tlds (tld,user_id,created_at,term_started_at,expires_at) VALUES (?,?,?,?,?)`, |
| 136 | + [tld, ALICE, past, past, past + m.TERM_MS], |
| 137 | + ); |
| 138 | + |
| 139 | + const id = pay(); |
| 140 | + const now = Date.now(); |
| 141 | + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); |
| 142 | + await m.settleTldPurchase(id, now); |
| 143 | + |
| 144 | + assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS, "not backdated"); |
| 145 | + }); |
| 146 | + |
| 147 | + await t.test("only the holder may renew", async () => { |
| 148 | + const tld = uniq(); |
| 149 | + await m.registerTld({ tld, userId: ALICE }); |
| 150 | + assert.match((await m.quoteRenewal({ tld, userId: BOB })).error, /do not own/); |
| 151 | + assert.equal((await m.quoteRenewal({ tld, userId: ALICE })).ok, true); |
| 152 | + }); |
| 153 | + |
| 154 | + await t.test("an ending with no term recorded is not expired", async () => { |
| 155 | + // Every ending claimed before terms existed has a NULL expiry. Treating |
| 156 | + // that as expired would put a few hundred namespaces on a clock nobody |
| 157 | + // agreed to. |
| 158 | + const tld = uniq(); |
| 159 | + await m.registerTld({ tld, userId: ALICE }); |
| 160 | + const row = await m.getTldWithTerm(tld); |
| 161 | + |
| 162 | + assert.equal(row.expires_at, null); |
| 163 | + assert.equal(m.isExpired(row), false); |
| 164 | + assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), true); |
| 165 | + assert.equal(m.isExpired({ expires_at: Date.now() + 1000 }), false); |
| 166 | + }); |
| 167 | +}); |
0 commit comments