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

A test pins the variant deck: Like and Dislike are drawn only for someone who can vote, and each form posts to a route that accepts it #6842

Merged opened this

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

Merged

scout/forge:scout/6830-a-test-pins-the-variant-deck-like-and-di → main 2 commits fork

  • Merged. scout/6830-a-test-pins-the-variant-deck-like-and-di was merged into main as fbe6f78.

Review

Sign in to review this change.
1 file changed +182 −0
test/variant-deck.test.ts added +182
@@ -0,0 +1,182 @@
1 /** The variant deck draws Like and Dislike only for a writer, and each form posts to a route that accepts it. */
2 import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts";
8 import { createIssue, getIssueById } from "../src/core/issues.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { promote } from "./helpers/members.ts";
14
15 describe("the variant deck", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let alice: User;
20 let repo: Repo;
21 const cookies: Record<string, string> = {};
22 let n = 0;
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-deck-"));
26 app = createApp({ dataDir, port: 0 });
27 servers = await serve(app);
28 const mk = async (username: string, member: boolean, first = false) => {
29 const r = await createUser(app.db, {
30 origin: "seed",
31 trusted: true,
32 username,
33 allowReserved: true,
34 email: `${username}@f.dev`,
35 password: "a-good-passphrase",
36 adminIfFirst: first,
37 });
38 if (!r.ok) throw new Error(r.error);
39 if (member) promote(app.db, r.value);
40 cookies[username] = `forge_session=${createSession(app.db, r.value.id).token}`;
41 return r.value;
42 };
43 await mk("root", true, true);
44 alice = await mk("alice", true);
45 await mk("bob", true);
46 await mk("guest", false);
47 const r = await createRepo(app, alice, { name: "garden", visibility: "public" });
48 if (!r.ok) throw new Error(`repo: ${JSON.stringify(r)}`);
49 repo = r.value;
50 });
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dataDir, { recursive: true, force: true });
55 });
56
57 /** Sibling pulls in one group, written straight to the tables. */
58 function group(name: string, count: number): number[] {
59 const numbers: number[] = [];
60 for (let i = 0; i < count; i++) {
61 const issue = createIssue(app.db, {
62 repoId: repo.id,
63 authorId: alice.id,
64 title: `${name} variant ${++n}`,
65 kind: "pull",
66 });
67 if (!issue.ok) throw new Error(issue.error);
68 app.db
69 .query(
70 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, variant_group)
71 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
72 )
73 .run(issue.value.id, `${name}-${i}`, name);
74 numbers.push(issue.value.number);
75 }
76 return numbers;
77 }
78
79 function issueIdOf(num: number): string {
80 return app.db
81 .query<{ id: string }, [string, number]>("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2")
82 .get(repo.id, num)!.id;
83 }
84
85 const votesOn = (num: number) =>
86 app.db
87 .query<{ answer: string; by: string }, [string]>(
88 "SELECT answer, by FROM decision WHERE kind = 'lean' AND issue_id = ?1",
89 )
90 .all(issueIdOf(num));
91
92 const path = (g: string) => `/alice/garden/variants/${g}`;
93 const get = (g: string, who?: string) =>
94 fetch(`${servers.url}${path(g)}`, {
95 headers: who ? { cookie: cookies[who]! } : {},
96 redirect: "manual",
97 });
98 const post = (url: string, who: string, form: Record<string, string> = {}, origin = servers.url) =>
99 fetch(`${servers.url}${url}`, {
100 method: "POST",
101 headers: { cookie: cookies[who]!, origin },
102 body: new URLSearchParams(form),
103 redirect: "manual",
104 });
105 const cards = (body: string) => body.match(/class="variant-card"/g)?.length ?? 0;
106 const actions = (body: string, kind: "like" | "dislike") =>
107 [...body.matchAll(new RegExp(`<form class="${kind}" method="post" action="([^"]+)"`, "g"))].map((m) => m[1]!);
108
109 test("a writer sees a card per open pull, each with a Like and a Dislike form that posts to its pull", async () => {
110 const nums = group("writer-deck", 3);
111 const res = await get("writer-deck", "alice");
112 expect(res.status).toBe(200);
113 const body = await res.text();
114 expect(cards(body)).toBe(3);
115 expect(actions(body, "like")).toEqual(nums.map((x) => `${path("writer-deck")}/${x}/like`));
116 expect(actions(body, "dislike")).toEqual(nums.map((x) => `${path("writer-deck")}/${x}/dislike`));
117 // Plain same-origin posts: relative actions, no foreign target.
118 expect(body).not.toMatch(/<form class="(like|dislike)"[^>]*action="https?:/);
119 // Every drawn route accepts the post; a cross-origin one is refused.
120 const liked = await post(`${path("writer-deck")}/${nums[0]}/like`, "alice");
121 expect(liked.status).toBe(303);
122 expect(votesOn(nums[0]!)).toEqual([{ answer: "like", by: "alice" }]);
123 expect(getIssueById(app.db, issueIdOf(nums[0]!))!.state).toBe("open");
124 const foreign = await post(`${path("writer-deck")}/${nums[1]}/like`, "alice", {}, "https://evil.example");
125 expect(foreign.status).toBe(403);
126 });
127
128 test("a reader without write and a guest see the cards and no vote forms", async () => {
129 group("reader-deck", 2);
130 for (const who of ["bob", "guest", undefined]) {
131 const res = await get("reader-deck", who);
132 expect(res.status).toBe(200);
133 const body = await res.text();
134 expect(cards(body)).toBe(2);
135 expect(body).not.toContain('class="like"');
136 expect(body).not.toContain('class="dislike"');
137 expect(body).not.toMatch(/\/\d+\/like/);
138 }
139 });
140
141 test("a reader's post is refused and closes nothing", async () => {
142 const nums = group("refused-deck", 2);
143 const res = await post(`${path("refused-deck")}/${nums[0]}/dislike`, "bob");
144 expect(res.status).toBe(403);
145 expect(getIssueById(app.db, issueIdOf(nums[0]!))!.state).toBe("open");
146 });
147
148 test("disliking as the writer closes that pull and the deck lists one card fewer", async () => {
149 const nums = group("dislike-deck", 3);
150 expect(cards(await (await get("dislike-deck", "alice")).text())).toBe(3);
151 const res = await post(`${path("dislike-deck")}/${nums[1]}/dislike`, "alice", { reason: "too loud" });
152 expect(res.status).toBe(303);
153 expect(votesOn(nums[1]!)).toEqual([{ answer: "dislike", by: "alice" }]);
154 expect(getIssueById(app.db, issueIdOf(nums[1]!))!.state).toBe("closed");
155 const body = await (await get("dislike-deck", "alice")).text();
156 expect(cards(body)).toBe(2);
157 expect(actions(body, "dislike")).not.toContain(`${path("dislike-deck")}/${nums[1]}/dislike`);
158 });
159
160 test("a closed sibling is not drawn as votable, and posting to it votes nothing", async () => {
161 const nums = group("closed-deck", 2);
162 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(issueIdOf(nums[0]!));
163 const body = await (await get("closed-deck", "alice")).text();
164 expect(cards(body)).toBe(1);
165 expect(actions(body, "like")).toEqual([`${path("closed-deck")}/${nums[1]}/like`]);
166 const res = await post(`${path("closed-deck")}/${nums[0]}/like`, "alice");
167 expect(res.status).toBe(303);
168 expect(votesOn(nums[0]!)).toEqual([]);
169 });
170
171 test("a merged sibling is not drawn as votable", async () => {
172 const nums = group("merged-deck", 2);
173 app.db
174 .query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1")
175 .run(issueIdOf(nums[0]!), new Date().toISOString());
176 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(issueIdOf(nums[0]!));
177 const body = await (await get("merged-deck", "alice")).text();
178 expect(cards(body)).toBe(1);
179 expect(body).not.toContain(`/${nums[0]}/like`);
180 expect(body).not.toContain(`/${nums[0]}/dislike`);
181 });
182 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs