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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11369-a-terminal-screen-for-pricing-plans-orde → main 5 commits fork
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 | ||
| 7 | 7 | - **No provider connected:** paid plans show their size and say "Ask the operator". |
| 8 | 8 | - **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. |
| 9 | 9 | - **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`. | |
| 11 | 11 | - **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`. |
| 12 | 12 | - **Test:** `bun test test/pricing-order.test.ts`. |
src/tui/forge.ts +2
| @@ -20,6 +20,7 @@ import { dataCommand } from "./screens/data.ts"; | ||
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | 21 | import { getForgeCommand } from "./screens/getforge.ts"; |
| 22 | 22 | import { startForgeCommand } from "./screens/startforge.ts"; |
| 23 | import { pricingCommand } from "./screens/pricing.ts"; | |
| 23 | 24 | import { forgeNameCommand } from "./screens/forgename.ts"; |
| 24 | 25 | import { workCommand } from "./screens/work.ts"; |
| 25 | 26 | import { shellsScreen } from "./screens/shells.ts"; |
| @@ -246,6 +247,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 246 | 247 | packsCommand(session, ctx), |
| 247 | 248 | getForgeCommand(session, ctx), |
| 248 | 249 | ...startForgeCommand(session, ctx), |
| 250 | pricingCommand(session, ctx), | |
| 249 | 251 | ...forgeNameCommand(session, ctx), |
| 250 | 252 | ...dataCommand(session, ctx), |
| 251 | 253 | 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", () => { | ||
| 52 | 52 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 53 | 53 | ["pack-repos", "Pack repositories", "which repositories /packs lists"], |
| 54 | 54 | ["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"], | |
| 55 | 56 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 56 | 57 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 57 | 58 | ["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", | ||
| 203 | 204 | ["explore-feed", "Explore feed", "Atom address"], |
| 204 | 205 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 205 | 206 | ["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"], | |
| 206 | 208 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 207 | 209 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 208 | 210 | ["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" | ||
| 288 | 290 | ["explore-feed", "Explore feed", "Atom address"], |
| 289 | 291 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 290 | 292 | ["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"], | |
| 291 | 294 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 292 | 295 | ["profile", "Open a profile", "username or org"], |
| 293 | 296 | ["repos", "Repositories", "the repositories you may write"], |
| @@ -370,6 +373,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 370 | 373 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 371 | 374 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 372 | 375 | ["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"], | |
| 373 | 377 | ["forge-name", "Is a forge name free?", "ask whether Start my forge would take a name"], |
| 374 | 378 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 375 | 379 | ["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 | }); |
new above
offline