Public: anyone can read this; the crew can change it.
A test pins variant votes: a dislike closes the pull with one comment and one ledger row, and a refused vote leaves nothing behind #6839
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6823-a-test-pins-variant-votes-a-dislike-clos → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +159
−0
test/variant-votes.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * `voteVariant` on its own: a writer's dislike closes the pull with one | |
| 3 | * comment and one ledger row; a like leaves it open; a refused vote writes | |
| 4 | * nothing. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { DEFAULT_REASON, REASON_MAX, voteVariant } from "../src/actions/variants.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { listDecisions } from "../src/core/decisions.ts"; | |
| 15 | import { listComments } from "../src/core/issues.ts"; | |
| 16 | import { getPull } from "../src/core/pulls.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("voteVariant", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let alice: User; | |
| 27 | let bob: User; | |
| 28 | let lib: Repo; | |
| 29 | let n = 0; | |
| 30 | ||
| 31 | const variant = async (variantGroup: string | null = "g") => { | |
| 32 | const branch = `b${++n}`; | |
| 33 | const c = await commitToBranch(app, alice, lib, { | |
| 34 | branch, | |
| 35 | message: branch, | |
| 36 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 37 | }); | |
| 38 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 39 | const o = await openPull(app, alice, lib, { | |
| 40 | title: `Try ${branch}`, | |
| 41 | baseBranch: "main", | |
| 42 | headBranch: branch, | |
| 43 | ...(variantGroup === null ? {} : { variantGroup }), | |
| 44 | }); | |
| 45 | if (!o.ok) throw new Error(String(o.error)); | |
| 46 | return o.value; | |
| 47 | }; | |
| 48 | const fresh = (num: number) => getPull(app.db, lib.id, num)!; | |
| 49 | const ledger = (id: string) => | |
| 50 | listDecisions(app.db, lib.id, { kind: "lean" }).filter((r) => r.issueId === id); | |
| 51 | const comments = (id: string) => listComments(app.db, id).length; | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-vvotes-")); | |
| 55 | app = createApp({ dataDir, port: 0 }); | |
| 56 | const make = async (username: string, first: boolean) => { | |
| 57 | const u = await createUser(app.db, { | |
| 58 | origin: "seed", | |
| 59 | trusted: true, | |
| 60 | username, | |
| 61 | email: `${username}@forge.local`, | |
| 62 | password: "a-good-passphrase", | |
| 63 | adminIfFirst: first, | |
| 64 | }); | |
| 65 | if (!u.ok) throw new Error(u.error); | |
| 66 | return u.value; | |
| 67 | }; | |
| 68 | alice = await make("alice", true); | |
| 69 | bob = await make("bob", false); | |
| 70 | const made = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 71 | if (!made.ok) throw new Error(String(made.error)); | |
| 72 | lib = made.value; | |
| 73 | const seed = await commitToBranch(app, alice, lib, { | |
| 74 | message: "seed", | |
| 75 | files: [{ path: "README.md", content: "# lib\n" }], | |
| 76 | }); | |
| 77 | if (!seed.ok) throw new Error(JSON.stringify(seed.error)); | |
| 78 | }, 60_000); | |
| 79 | ||
| 80 | afterAll(async () => { | |
| 81 | app.close(); | |
| 82 | await rm(dataDir, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a writer's dislike closes the pull with one comment and one vote", async () => { | |
| 86 | const pull = await variant(); | |
| 87 | const before = comments(pull.id); | |
| 88 | const r = voteVariant(app, alice, lib, pull, "dislike", "too noisy\n"); | |
| 89 | expect(r).toEqual({ ok: true, value: { closed: true } }); | |
| 90 | expect(fresh(pull.number).state).toBe("closed"); | |
| 91 | const added = listComments(app.db, pull.id).slice(before); | |
| 92 | expect(added).toHaveLength(1); | |
| 93 | expect(added[0]!.body.startsWith("Closed: not chosen among its variants.")).toBe(true); | |
| 94 | expect(added[0]!.body).toContain("too noisy"); | |
| 95 | const rows = ledger(pull.id); | |
| 96 | expect(rows).toHaveLength(1); | |
| 97 | expect(rows[0]!.answer).toBe("dislike"); | |
| 98 | expect((rows[0]!.state as any).reason).toBe("too noisy"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("an empty reason is the default; a long one is cut to REASON_MAX", async () => { | |
| 102 | const a = await variant(); | |
| 103 | voteVariant(app, alice, lib, a, "dislike", " "); | |
| 104 | expect((ledger(a.id)[0]!.state as any).reason).toBe(DEFAULT_REASON); | |
| 105 | expect(DEFAULT_REASON).toBe("Not this one."); | |
| 106 | ||
| 107 | const b = await variant(); | |
| 108 | voteVariant(app, alice, lib, b, "dislike", "x".repeat(500)); | |
| 109 | expect((ledger(b.id)[0]!.state as any).reason).toBe("x".repeat(REASON_MAX)); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a like records a vote and leaves the pull open", async () => { | |
| 113 | const pull = await variant(); | |
| 114 | const before = comments(pull.id); | |
| 115 | expect(voteVariant(app, alice, lib, pull, "like")).toEqual({ | |
| 116 | ok: true, | |
| 117 | value: { closed: false }, | |
| 118 | }); | |
| 119 | expect(fresh(pull.number).state).toBe("open"); | |
| 120 | expect(comments(pull.id)).toBe(before); | |
| 121 | const rows = ledger(pull.id); | |
| 122 | expect(rows).toHaveLength(1); | |
| 123 | expect(rows[0]!.answer).toBe("like"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a refused vote leaves no comment and no ledger row", async () => { | |
| 127 | const pull = await variant(); | |
| 128 | const plain = await variant(null); | |
| 129 | const before = comments(pull.id); | |
| 130 | const plainBefore = comments(plain.id); | |
| 131 | ||
| 132 | const guest = voteVariant(app, undefined, lib, pull, "dislike", "no"); | |
| 133 | expect(guest).toEqual({ ok: false, error: "unauthorized" }); | |
| 134 | const reader = voteVariant(app, bob, lib, pull, "dislike", "no"); | |
| 135 | expect(reader).toEqual({ ok: false, error: "forbidden" }); | |
| 136 | const solo = voteVariant(app, alice, lib, plain, "dislike", "no"); | |
| 137 | expect(solo).toEqual({ ok: false, error: "not_variant" }); | |
| 138 | ||
| 139 | expect(comments(pull.id)).toBe(before); | |
| 140 | expect(comments(plain.id)).toBe(plainBefore); | |
| 141 | expect(ledger(pull.id)).toHaveLength(0); | |
| 142 | expect(ledger(plain.id)).toHaveLength(0); | |
| 143 | expect(fresh(pull.number).state).toBe("open"); | |
| 144 | expect(fresh(plain.number).state).toBe("open"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a second dislike on the closed pull is not_open and adds nothing", async () => { | |
| 148 | const pull = await variant(); | |
| 149 | expect(voteVariant(app, alice, lib, pull, "dislike", "first").ok).toBe(true); | |
| 150 | const closed = fresh(pull.number); | |
| 151 | const before = comments(pull.id); | |
| 152 | expect(voteVariant(app, alice, lib, closed, "dislike", "again")).toEqual({ | |
| 153 | ok: false, | |
| 154 | error: "not_open", | |
| 155 | }); | |
| 156 | expect(comments(pull.id)).toBe(before); | |
| 157 | expect(ledger(pull.id)).toHaveLength(1); | |
| 158 | }); | |
| 159 | }); |
new above
offline
Nice: the refusals check the comment count, ledger and pull state for both pulls, not just the error code.