Public: anyone can read this; the crew can change it.

A terminal screen for /pricing: plans, order form, checkout address as text, claim link by the same ticket #11389

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11369-a-terminal-screen-for-pricing-plans-orde → main 5 commits fork

  • Merged. torque/11369-a-terminal-screen-for-pricing-plans-orde was merged into main as 12d9f79.

Review

Sign in to review this change.
5 files changed +505 −1
docs/features/pricing.md +1 −1
@@ -7,6 +7,6 @@ Open `/pricing` with no account. Each plan shows its size, price and crew credit
77 - **No provider connected:** paid plans show their size and say "Ask the operator".
88 - **Operator:** connect Stripe under Settings → Billing. A paid order whose forge could not be made shows under "Paid, not made" with Retry and a link to the provider's dashboard to refund; a flow alarm names it.
99 - **Limits:** one client address may have 3 orders waiting at once (counted by socket address, never a header); a name is held 35 minutes and the provider's checkout stops being payable at 31, so a late payer never finds the name given away; a claim link is mailed again at most once a day per order.
10 - **Terminal:** no screen yet; checkout is browser-only (the provider's hosted page). Tracked in a follow-up issue.
10 - **Terminal:** palette → "Get a forge (plans)" (`src/tui/screens/pricing.ts`): up/down picks a paid plan, Tab switches name/email, Enter places the order (the per-address cap counts the SSH client's bucket). The checkout address is printed as text to open anywhere; after paying press r, and the claim link appears once, by the ticket the order was placed with. Free is not sold there: use Start my forge. Test: `bun test test/tui-pricing.test.ts`.
1111 - **Code:** `src/actions/order.ts` (rules), `src/billing/orders.ts` (store), `src/web/pages/pricing.ts` (pages). The paid path is described in `docs/SPAWN.md`.
1212 - **Test:** `bun test test/pricing-order.test.ts`.
src/tui/forge.ts +2
@@ -20,6 +20,7 @@ import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
2121 import { getForgeCommand } from "./screens/getforge.ts";
2222 import { startForgeCommand } from "./screens/startforge.ts";
23 import { pricingCommand } from "./screens/pricing.ts";
2324 import { forgeNameCommand } from "./screens/forgename.ts";
2425 import { workCommand } from "./screens/work.ts";
2526 import { shellsScreen } from "./screens/shells.ts";
@@ -246,6 +247,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
246247 packsCommand(session, ctx),
247248 getForgeCommand(session, ctx),
248249 ...startForgeCommand(session, ctx),
250 pricingCommand(session, ctx),
249251 ...forgeNameCommand(session, ctx),
250252 ...dataCommand(session, ctx),
251253 fleetCommand(session, ctx),
src/tui/screens/pricing.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * `/pricing` in the terminal (#11369): the plans, a name and an email, then
3 * **Enter** places the order through `placeOrder` (the same rules as the web
4 * form), counted under this SSH client's address bucket. The provider's
5 * checkout is a hosted page, so it is printed as text to open anywhere; **r**
6 * asks `reconcileOrder` whether it was paid, and once the forge is made the
7 * claim link is shown by the same ticket the order was placed with (held here
8 * in memory, as the web holds it in a cookie), once, through `deliverLink`.
9 * Free is not sold here: its door is Start my forge / New forge.
10 */
11
12 import { deliverLink, mayView, NO_PROVIDER, placeOrder, reconcileOrder } from "../../actions/order.ts";
13 import { checkoutOf } from "../../billing/checkout.ts";
14 import { publicOrigin } from "../../core/public_origin.ts";
15 import { bucketOf } from "../../http/peer.ts";
16 import { takeDoorAt } from "../../http/strangers.ts";
17 import { sizeWords } from "../../settings/billing.ts";
18 import { FREE, getPlans, type Plan } from "../../settings/plans.ts";
19 import type { Ctx, Screen } from "../app/app.ts";
20 import { binding } from "../app/keys.ts";
21 import { style } from "../term/style.ts";
22 import { truncate, wrap } from "../term/width.ts";
23 import { field } from "../ui/widgets.ts";
24 import type { Session } from "../session.ts";
25
26 /** Text cut into rows of at most `w` characters, for an address that has no spaces to wrap at. */
27 const chunks = (text: string, w: number): string[] => {
28 const out: string[] = [];
29 const n = Math.max(1, w);
30 for (let i = 0; i < text.length; i += n) out.push(text.slice(i, i + n));
31 return out;
32 };
33
34 type Stage =
35 | { kind: "form" }
36 | { kind: "placed"; id: string; ticket: string; address: string; name: string; says: string }
37 | { kind: "done"; name: string; says: string; link?: string };
38
39 export function pricingScreen(f: Session, ctx: Ctx): Screen {
40 const plans = (): Plan[] => getPlans(f.app.db);
41 const paid = (): Plan[] => plans().filter((p) => p.key !== FREE && p.priceUsd > 0);
42 const bucket = bucketOf(f.identity.remoteAddress ?? "unknown");
43 let name = "";
44 let email = "";
45 let at = 0;
46 let focus: "name" | "email" = "name";
47 let error: string | undefined;
48 let busy = false;
49 let stage: Stage = { kind: "form" };
50
51 const run = (work: () => Promise<void>): void => {
52 busy = true;
53 void ctx
54 .track(
55 work().finally(() => {
56 busy = false;
57 ctx.invalidate();
58 }),
59 )
60 .catch((e: unknown) => ctx.toast(String(e instanceof Error ? e.message : e), "danger"));
61 };
62
63 const place = (): void => {
64 if (busy) return;
65 const plan = paid()[at];
66 if (plan === undefined) {
67 error = NO_PROVIDER;
68 ctx.invalidate();
69 return;
70 }
71 error = undefined;
72 run(async () => {
73 const verdict = takeDoorAt(f.app, "plan", bucket);
74 if (!verdict.allowed) {
75 error = `Too many tries from here; try again in ${verdict.retryAfter}s`;
76 return;
77 }
78 const url = new URL(publicOrigin(f.app.config.url, `http://${f.app.config.host ?? "localhost"}:${f.app.config.port ?? 3000}`));
79 const placed = await placeOrder(f.app, { name, plan: plan.key, email, address: bucket }, url);
80 if (!placed.ok) {
81 error = `${placed.error.field === "form" ? "" : `${placed.error.field}: `}${placed.error.message}`;
82 return;
83 }
84 stage = { kind: "placed", id: placed.value.id, ticket: placed.value.ticket, address: placed.value.redirect, name: name.trim(), says: "Open the address and pay; then press r." };
85 });
86 };
87
88 const check = (): void => {
89 if (busy || stage.kind !== "placed") return;
90 const s = stage;
91 run(async () => {
92 // The ticket this screen was given is the order's key, as the cookie is on the web.
93 if (!mayView(f.app, s.id, s.ticket)) {
94 stage = { kind: "form" };
95 error = "That order is not yours to see from here, so nothing is shown. Place it again.";
96 return;
97 }
98 const o = await reconcileOrder(f.app, s.id);
99 if (o === undefined) {
100 stage = { kind: "form" };
101 error = "That order is gone. Place it again.";
102 return;
103 }
104 if (o.state === "pending") s.says = "Not paid yet. Open the address, pay, then press r.";
105 else if (o.state === "paid" || o.state === "spawning") s.says = `Paid. Making ${o.name}; press r again in a moment.`;
106 else if (o.state === "failed") stage = { kind: "done", name: o.name, says: `Paid, but the forge could not be made: ${o.reason}. The operator has been told; keep the order id ${o.id}.` };
107 else {
108 const given = await deliverLink(f.app, o);
109 if (given.kind === "link") stage = { kind: "done", name: o.name, says: `${o.name} is ready. Open this link to take it over; it is shown once.${given.mailed ? ` It was also sent to ${o.email}.` : ""}`, link: given.claimUrl };
110 else if (given.kind === "claimed") stage = { kind: "done", name: o.name, says: `${o.name} is taken over; sign in at its address.` };
111 else stage = { kind: "done", name: o.name, says: "The link was shown already, once. Ask for a new one by email at /pricing/resend." };
112 }
113 });
114 };
115
116 const edit = (fn: (v: string) => string): void => {
117 if (focus === "name") name = fn(name).slice(0, 60);
118 else email = fn(email).slice(0, 254);
119 error = undefined;
120 };
121
122 return {
123 title: "Get a forge",
124 keys: [binding(["enter"], "order"), binding(["tab"], "next field"), binding(["up", "down"], "plan"), binding(["esc"], "back")],
125 render(area, s) {
126 const c = ctx.theme.c;
127 const w = Math.max(20, area.w);
128 let y = area.y;
129 const line = (text: string, color = c.fg): void => {
130 if (y < area.y + area.h) s.text(area.x, y, truncate(text, w), style(color, c.bg), area.x + area.w);
131 y++;
132 };
133 if (stage.kind !== "form") {
134 line(`Forge ${stage.name}`);
135 y++;
136 for (const row of wrap(stage.says, w)) line(row);
137 y++;
138 if (stage.kind === "placed") {
139 line("Checkout address (open it in a browser to pay):", c.mutedFg);
140 for (const row of chunks(stage.address, w)) line(row);
141 } else if (stage.link !== undefined) {
142 line("Claim link:", c.mutedFg);
143 for (const row of chunks(stage.link, w)) line(row);
144 }
145 return;
146 }
147 const selling = checkoutOf(f.app) !== undefined;
148 line(selling ? "Get your own forge: pick a plan, name it, pay. Free: use Start my forge." : "Plans. Paid plans are not on sale here yet: ask the operator.", c.mutedFg);
149 y++;
150 const list = paid();
151 for (const p of plans()) {
152 const isPaid = p.key !== FREE && p.priceUsd > 0;
153 const mark = isPaid && list[at]?.key === p.key ? "▸" : " ";
154 line(`${mark} ${p.name.padEnd(12)} ${isPaid ? `$${p.priceUsd} a month` : "free"} ${sizeWords(p.size)}`, isPaid ? c.fg : c.mutedFg);
155 }
156 y++;
157 const base = y - area.y;
158 const fw = Math.min(60, w);
159 const nameAt = field(s, { x: area.x, y: area.y + base, w: fw, h: 3 }, c, { label: "Name", value: name, focused: focus === "name", cursor: [...name].length, hint: "Lowercase letters, digits and dashes." });
160 const emailAt = field(s, { x: area.x, y: area.y + base + 3, w: fw, h: 3 }, c, { label: "Email", value: email, focused: focus === "email", cursor: [...email].length, hint: busy ? "Placing the order…" : "The claim link is shown here after payment." });
161 if (error !== undefined)
162 wrap(error, fw).forEach((row, i) => {
163 if (base + 6 + i < area.h) s.text(area.x, area.y + base + 6 + i, row, style(c.danger, c.bg), area.x + area.w);
164 });
165 ctx.setCursor(focus === "name" ? nameAt : emailAt);
166 },
167 paste(text) {
168 if (stage.kind !== "form") return false;
169 edit((v) => v + text.replace(/\s+/g, ""));
170 return true;
171 },
172 key(e) {
173 if (e.key === "esc") {
174 ctx.pop();
175 return true;
176 }
177 if (stage.kind === "placed") {
178 if (e.key === "r" || e.key === "enter") check();
179 else if (e.key === "c") {
180 ctx.copy(stage.address);
181 ctx.toast("copied through the terminal", "success");
182 } else return false;
183 return true;
184 }
185 if (stage.kind === "done") return false;
186 if (e.key === "enter") {
187 place();
188 return true;
189 }
190 if (e.key === "tab") {
191 focus = focus === "name" ? "email" : "name";
192 return true;
193 }
194 const n = paid().length;
195 if (e.key === "up" && n > 0) {
196 at = (at + n - 1) % n;
197 return true;
198 }
199 if (e.key === "down" && n > 0) {
200 at = (at + 1) % n;
201 return true;
202 }
203 if (e.key === "backspace") {
204 edit((v) => [...v].slice(0, -1).join(""));
205 return true;
206 }
207 if (!e.ctrl && !e.alt && [...e.key].length === 1 && e.key !== " ") {
208 edit((v) => v + e.key);
209 return true;
210 }
211 return false;
212 },
213 };
214 }
215
216 /** Offered to everyone: the sale is open to a guest and to a signed-in person alike, as on the web. */
217 export const pricingCommand = (session: Session, ctx: Ctx) => ({
218 id: "pricing",
219 title: "Get a forge (plans)",
220 hint: "pick a plan, name it, pay at the checkout address",
221 run: () => ctx.push(pricingScreen(session, ctx)),
222 });
test/palette-roster.test.ts +4
@@ -52,6 +52,7 @@ test("a site-admin person is offered the whole roster", () => {
5252 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5353 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
5454 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
55 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
5556 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
5657 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5758 ["profile", "Open a profile", "username or org"],
@@ -203,6 +204,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
203204 ["explore-feed", "Explore feed", "Atom address"],
204205 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
205206 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
207 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
206208 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
207209 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
208210 ["profile", "Open a profile", "username or org"],
@@ -288,6 +290,7 @@ test("a member person is offered the signed-in roster without the admin entries"
288290 ["explore-feed", "Explore feed", "Atom address"],
289291 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
290292 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
293 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
291294 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
292295 ["profile", "Open a profile", "username or org"],
293296 ["repos", "Repositories", "the repositories you may write"],
@@ -370,6 +373,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
370373 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
371374 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
372375 ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"],
376 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
373377 ["forge-name", "Is a forge name free?", "ask whether Start my forge would take a name"],
374378 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
375379 ["profile", "Open a profile", "username or org"],
test/tui-pricing.test.ts added +276
@@ -0,0 +1,276 @@
1 /** /pricing in the terminal (#11369): plans, an order through placeOrder, the checkout address as text, the claim link by the same ticket. */
2 import { afterEach, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { childDirOf } from "../src/actions/spawn.ts";
8 import { deliverLink, NO_PROVIDER, reconcileOrder } from "../src/actions/order.ts";
9 import { useCheckout, type CheckoutProvider } from "../src/billing/checkout.ts";
10 import { getOrder, MAX_PENDING_PER_ADDRESS, pendingFrom } from "../src/billing/orders.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { bucketOf } from "../src/http/peer.ts";
13 import { setStrangerLimits, strangerLimits } from "../src/http/strangers.ts";
14 import { err, ok } from "../src/kernel/result.ts";
15 import { FREE, getPlans, setPlans } from "../src/settings/plans.ts";
16 import { usePlacement, useProbe } from "../src/spawn/door.ts";
17 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
18 import { pricingScreen } from "../src/tui/screens/pricing.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 const dirs: string[] = [];
26 afterEach(async () => {
27 for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
28 });
29
30 /** A forge of its own for one test: its own data, its own fake provider, its own counts. */
31 async function fixture(opts: { sell?: boolean; placement?: "ok" | "full" } = {}) {
32 const dir = await mkdtemp(join(tmpdir(), "forge-tui-pricing-"));
33 dirs.push(dir);
34 const app: App = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
35 const paid = new Set<string>();
36 let placed = 0;
37 const provider: CheckoutProvider = {
38 name: "fake",
39 async create(req) {
40 const id = `cs_fake_${req.orderId.slice(4, 16)}`;
41 return ok({ id, url: `https://pay.fake.test/${id}` });
42 },
43 async state(id) {
44 return ok({ paid: paid.has(id), customer: "cus_1", subscription: "sub_1" });
45 },
46 };
47 useProbe(app, () => async () => true);
48 usePlacement(app, {
49 place: async (plan: { name: string }) =>
50 opts.placement === "full" ? err({ kind: "refused", reason: "capacity", message: "no room" }) : ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
51 retire: async () => ok(),
52 } as never);
53 if (opts.sell !== false) useCheckout(app, provider);
54 const identity = (addr: string) => ({ sshUsername: "git", remoteAddress: addr, fingerprint: "SHA256:test", user: undefined });
55 const open = (addr: string) => {
56 const terminal = headlessTerminal(120, 40);
57 const { app: tui, session } = createForgeApp(app, identity(addr) as never, { terminal, exitHooks: false });
58 tui.start();
59 tui.ctx.push(pricingScreen(session, tui.ctx));
60 return { tui, terminal, session };
61 };
62 /** Press keys and wait until the terminal is quiet: the placement and the provider are awaited by the app's own tracking. */
63 const press = async (h: ReturnType<typeof open>, keys: string) => {
64 h.terminal.feed(keys);
65 await settleApp(h.tui);
66 };
67 const orderRow = (name: string) => app.db.query<{ id: string; email: string; state: string }, [string]>("SELECT id, email, state FROM forge_order WHERE name = ?1").get(name);
68 return { app, paid, placed: () => placed, open, press, orderRow, identity };
69 }
70
71 const flat = (h: { tui: { screen: { toText(): string } } }) => h.tui.screen.toText().replace(/\s+/g, " ");
72 const joined = (h: { tui: { screen: { toText(): string } } }) => h.tui.screen.toText().replace(/\s+/g, "");
73 const sessionOf = (h: { tui: { screen: { toText(): string } } }) => joined(h).match(/https:\/\/pay\.fake\.test\/(cs_fake_[0-9a-f]{12})/)?.[1];
74
75 /** Place an order from a fresh screen and return it, waiting at the checkout address. */
76 async function ordered(f: Awaited<ReturnType<typeof fixture>>, addr: string, name: string) {
77 const h = f.open(addr);
78 await settleApp(h.tui);
79 await f.press(h, `${name}\[email protected]\r`);
80 const session = sessionOf(h);
81 expect(session).toBeDefined();
82 return { h, session: session! };
83 }
84
85 test("the palette offers it to everyone", async () => {
86 const f = await fixture();
87 expect(forgeCommands(new Session(f.app, f.identity("203.0.113.9") as never))({} as never).map((c) => c.id)).toContain("pricing");
88 });
89
90 test("the plans are listed with their prices", async () => {
91 const f = await fixture();
92 const h = f.open("203.0.113.1");
93 await settleApp(h.tui);
94 const text = flat(h);
95 for (const p of getPlans(f.app.db)) if (p.key !== FREE && p.priceUsd > 0) expect(text).toContain(`$${p.priceUsd} a month`);
96 });
97
98 test("a bad email is refused in the web's words and holds nothing", async () => {
99 const f = await fixture();
100 const h = f.open("203.0.113.2");
101 await settleApp(h.tui);
102 await f.press(h, "sunny-day\tnot-an-email\r");
103 expect(flat(h)).toContain("Enter the email the link should go to");
104 expect(pendingFrom(f.app.db, bucketOf("203.0.113.2"), Date.now())).toBe(0);
105 });
106
107 test("an order prints the checkout address, and the same ticket brings the claim link once after payment", async () => {
108 const f = await fixture();
109 const { h, session } = await ordered(f, "203.0.113.3", "sunny-day");
110 expect(pendingFrom(f.app.db, bucketOf("203.0.113.3"), Date.now())).toBe(1);
111 await f.press(h, "r");
112 expect(flat(h)).toContain("Not paid yet");
113 f.paid.add(session);
114 await f.press(h, "r"); // the reconcile is awaited by the app: the forge is made before it settles
115 expect(joined(h)).toMatch(/https:\/\/sunny-day--forge\.test\/claim#/);
116 expect(f.placed()).toBe(1);
117 });
118
119 test("the claim link is shown once: a screen whose order's link was already taken is told so", async () => {
120 const f = await fixture();
121 const { h, session } = await ordered(f, "203.0.113.5", "once-only");
122 f.paid.add(session);
123 // The link is taken elsewhere first (the web's return page, say); this screen asks after.
124 const id = f.orderRow("once-only")!.id;
125 await reconcileOrder(f.app, id);
126 expect((await deliverLink(f.app, getOrder(f.app.db, id)!)).kind).toBe("link");
127 await f.press(h, "r");
128 expect(flat(h)).toContain("The link was shown already");
129 expect(joined(h)).not.toContain("/claim#");
130 });
131
132 test("a paid order whose forge cannot be made says so and keeps the order id", async () => {
133 const f = await fixture({ placement: "full" });
134 const { h, session } = await ordered(f, "203.0.113.7", "no-room");
135 f.paid.add(session);
136 await f.press(h, "r");
137 expect(flat(h)).toContain("Paid, but the forge could not be made");
138 expect(flat(h)).toContain(f.orderRow("no-room")!.id);
139 expect(joined(h)).not.toContain("/claim#");
140 });
141
142 test("a forge already taken over is told so, and no link is shown", async () => {
143 const f = await fixture();
144 const { h, session } = await ordered(f, "203.0.113.8", "taken-over");
145 f.paid.add(session);
146 // Paid and made by the webhook's route, before this screen asks.
147 await reconcileOrder(f.app, f.orderRow("taken-over")!.id);
148 // Someone claims the child: it has an admin.
149 const child = createApp({ dataDir: childDirOf(f.app, "fake-1") });
150 try {
151 const made = await createUser(child.db, { origin: "seed", trusted: true, username: "keeper", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
152 if (!made.ok) throw new Error(JSON.stringify(made.error));
153 child.db.query("UPDATE user SET is_admin = 1 WHERE username = 'keeper'").run();
154 } finally {
155 child.close();
156 }
157 await f.press(h, "r");
158 expect(flat(h)).toContain("is taken over");
159 expect(joined(h)).not.toContain("/claim#");
160 });
161
162 test("a ticket that does not fit the order shows no link, and the screen says why", async () => {
163 const f = await fixture();
164 const { h, session } = await ordered(f, "203.0.113.10", "not-yours");
165 f.paid.add(session);
166 f.app.db.query("UPDATE forge_order SET ticket_hash = 'someone-elses' WHERE name = 'not-yours'").run();
167 await f.press(h, "r");
168 expect(flat(h)).toContain("not yours to see");
169 expect(joined(h)).not.toContain("/claim#");
170 expect(f.placed()).toBe(0);
171 expect(f.orderRow("not-yours")!.state).toBe("pending");
172 });
173
174 test("the per-address cap counts the terminal's bucket", async () => {
175 const f = await fixture();
176 const addr = "203.0.113.4";
177 for (let i = 0; i < MAX_PENDING_PER_ADDRESS; i++) await ordered(f, addr, `cap-${i}`);
178 expect(pendingFrom(f.app.db, bucketOf(addr), Date.now())).toBe(MAX_PENDING_PER_ADDRESS);
179 const h = f.open(addr);
180 await settleApp(h.tui);
181 await f.press(h, "one-more\[email protected]\r");
182 expect(flat(h)).toContain("You have orders waiting already");
183 });
184
185 test("a visitor who tries too often is told when to come back, and nothing is held", async () => {
186 const f = await fixture();
187 setStrangerLimits(f.app, { perAddress: { ...strangerLimits(f.app).perAddress, plan: 2 } });
188 const addr = "203.0.113.11";
189 const h = f.open(addr);
190 await settleApp(h.tui);
191 for (let i = 0; i < 2; i++) await f.press(h, "\r"); // two tries (refused for the empty name, but counted)
192 expect(flat(h)).not.toContain("Too many tries");
193 await f.press(h, "sunny-day\[email protected]\r");
194 expect(flat(h)).toMatch(/Too many tries from here; try again in \d+s/);
195 expect(pendingFrom(f.app.db, bucketOf(addr), Date.now())).toBe(0);
196 });
197
198 test("up and down choose the paid plan and wrap; the order is made on the chosen one", async () => {
199 const f = await fixture();
200 const paidPlans = getPlans(f.app.db).filter((p) => p.key !== FREE && p.priceUsd > 0);
201 const h = f.open("203.0.113.12");
202 await settleApp(h.tui);
203 const marked = () => h.tui.screen.toText().split("\n").find((l) => l.includes("▸"))!;
204 expect(marked()).toContain(paidPlans[0]!.name);
205 await f.press(h, "\x1b[A"); // up from the first wraps to the last
206 expect(marked()).toContain(paidPlans[paidPlans.length - 1]!.name);
207 await f.press(h, "\x1b[B"); // down from the last wraps to the first
208 expect(marked()).toContain(paidPlans[0]!.name);
209 await f.press(h, "\x1b[B");
210 expect(marked()).toContain(paidPlans[1]!.name);
211 await f.press(h, "picked-two\[email protected]\r");
212 expect(f.app.db.query<{ plan: string }, []>("SELECT plan FROM forge_order WHERE name = 'picked-two'").get()!.plan).toBe(paidPlans[1]!.key);
213 });
214
215 test("with no paid plan there is nothing to order, and the screen says so at once", async () => {
216 const f = await fixture();
217 setPlans(f.app.db, getPlans(f.app.db).filter((p) => p.key === FREE), "test");
218 const h = f.open("203.0.113.13");
219 await settleApp(h.tui);
220 await f.press(h, "sunny-day\[email protected]\r");
221 expect(flat(h)).toContain(NO_PROVIDER);
222 expect(pendingFrom(f.app.db, bucketOf("203.0.113.13"), Date.now())).toBe(0);
223 });
224
225 test("with no provider the form says paid plans are not on sale and refuses the order", async () => {
226 const f = await fixture({ sell: false });
227 const h = f.open("203.0.113.14");
228 await settleApp(h.tui);
229 expect(flat(h)).toContain("not on sale here yet");
230 await f.press(h, "sunny-day\[email protected]\r");
231 expect(flat(h)).toContain(NO_PROVIDER);
232 });
233
234 test("a paste has its whitespace stripped and is capped at 254 for the email", async () => {
235 const f = await fixture();
236 const h = f.open("203.0.113.15");
237 await settleApp(h.tui);
238 await f.press(h, "\x1b[200~sunny\n day \x1b[201~");
239 await f.press(h, "\t");
240 const long = `${"a".repeat(64)}@${"b".repeat(235)}`; // 300 characters
241 await f.press(h, `\x1b[200~ ${long}\r\x1b[201~`);
242 await f.press(h, "\r");
243 const row = f.orderRow("sunnyday");
244 expect(row).not.toBeNull();
245 expect(row!.email).toBe(long.slice(0, 254));
246 expect(row!.email.length).toBe(254);
247 });
248
249 test("a pasted name is capped at 60 characters", async () => {
250 const f = await fixture();
251 const h = f.open("203.0.113.16");
252 await settleApp(h.tui);
253 await f.press(h, `\x1b[200~${"a".repeat(30)} ${"b".repeat(50)}\x1b[201~`);
254 // Over 38 it is refused by the name's own rule; the field holds 60 and no more.
255 const text = h.tui.screen.toText();
256 expect(text).toContain("b".repeat(30));
257 expect(text).not.toContain("b".repeat(31));
258 });
259
260 test("c copies the checkout address through the terminal", async () => {
261 const f = await fixture();
262 const { h, session } = await ordered(f, "203.0.113.17", "copy-me");
263 h.terminal.takeOutput();
264 await f.press(h, "c");
265 const wanted = Buffer.from(`https://pay.fake.test/${session}`, "utf8").toString("base64");
266 expect(h.terminal.takeOutput()).toContain(`\x1b]52;c;${wanted}\x07`);
267 });
268
269 test("an address longer than the screen is cut into rows that read back as it", async () => {
270 const f = await fixture();
271 const h = f.open("203.0.113.18");
272 h.terminal.resize(40, 30);
273 await settleApp(h.tui);
274 await f.press(h, "wrapped-address\[email protected]\r");
275 expect(sessionOf(h)).toMatch(/^cs_fake_/);
276 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs