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

A test proves watching a milestone: a person in a repository they can read may, an agent and a stranger may not, and no event is appended #7531

Merged opened this

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

Merged

torque/forge:torque/7525-a-test-proves-watching-a-milestone-a-per → main 1 commit fork

  • Merged. torque/7525-a-test-proves-watching-a-milestone-a-per was merged into main as 4c3b9c6.

Review

Sign in to review this change.
1 file changed +162 −0
test/watch-action.test.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * `watchMilestone` (src/actions/watches.ts), called directly: a person who
3 * can read the repository starts and stops watching and `isWatching`
4 * follows; a guest is `unauthorized`, an agent `forbidden`, and someone who
5 * cannot read the repository or names no milestone `not_found`. The watch is
6 * the person's own state, so neither a start nor a stop appends an event —
7 * checked on the bus and in the event table.
8 */
9 import { afterAll, beforeAll, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import { linkIssue } from "../src/actions/dependencies.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { createRepo } from "../src/actions/repos.ts";
18 import { watchMilestone } from "../src/actions/watches.ts";
19 import { getIssueById, type Issue } from "../src/core/issues.ts";
20 import { getRepoById, type Repo } from "../src/core/repos.ts";
21 import type { ForgeEvent } from "../src/core/events.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { isWatching } from "../src/core/watches.ts";
24
25 let dataDir: string;
26 let app: App;
27 let alice: User;
28 let bob: User;
29 let mason: User;
30 let engine: Repo;
31 let secret: Repo;
32 let release: Issue;
33 let plain: Issue;
34 let hidden: Issue;
35 const heard: ForgeEvent[] = [];
36 let unsubscribe: () => void;
37
38 const fresh = (repo: Repo) => getRepoById(app.db, repo.id)!;
39 const issue = (repo: Repo, title: string): Issue => {
40 const opened = openIssue(app, alice, fresh(repo), { title });
41 if (!opened.ok) throw new Error(String(opened.error));
42 return opened.value;
43 };
44 /** A milestone: an issue that waits for one part. */
45 const milestone = (repo: Repo, title: string): Issue => {
46 const top = issue(repo, title);
47 const part = issue(repo, `${title}'s part`);
48 const linked = linkIssue(app, alice, fresh(repo), getIssueById(app.db, top.id)!, part);
49 if (!linked.ok) throw new Error(String(linked.error));
50 return top;
51 };
52 const events = (): number =>
53 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event").get()!.n;
54
55 beforeAll(async () => {
56 dataDir = await mkdtemp(join(tmpdir(), "forge-watch-action-"));
57 app = createApp({ dataDir, port: 0 });
58 for (const [username, adminIfFirst] of [
59 ["alice", true],
60 ["bob", false],
61 ] as const) {
62 const made = await createUser(app.db, {
63 origin: "seed",
64 trusted: true,
65 username,
66 email: `${username}@forge.local`,
67 password: "a-good-passphrase",
68 adminIfFirst,
69 });
70 if (!made.ok) throw new Error(made.error);
71 }
72 alice = getUserByName(app.db, "alice")!;
73 bob = getUserByName(app.db, "bob")!;
74 const repoOf = async (name: string, visibility: "public" | "private") => {
75 const made = await createRepo(app, alice, { name, visibility });
76 if (!made.ok) throw new Error(String(made.error));
77 return made.value;
78 };
79 // Public: bob reads the engine; the secret is alice's alone.
80 engine = await repoOf("engine", "public");
81 secret = await repoOf("secret", "private");
82 const agent = await createAgent(app, alice, { username: "mason" });
83 if (!agent.ok) throw new Error(String(agent.error));
84 expect(addToCrew(app, alice, engine, "mason").ok).toBe(true);
85 mason = getUserByName(app.db, "mason")!;
86 release = milestone(engine, "Release one");
87 plain = issue(engine, "No milestone");
88 hidden = milestone(secret, "A hidden milestone");
89 unsubscribe = app.bus.subscribe((event) => heard.push(event));
90 }, 60_000);
91
92 afterAll(async () => {
93 unsubscribe();
94 app.close();
95 await rm(dataDir, { recursive: true, force: true });
96 });
97
98 test("a person who can read the repository starts and stops watching, and isWatching follows", () => {
99 const before = events();
100 heard.length = 0;
101 expect(isWatching(app.db, bob.id, release.id)).toBe(false);
102
103 const on = watchMilestone(app, bob, fresh(engine), release.number, true);
104 expect(on).toEqual({ ok: true, value: { watching: true } });
105 expect(isWatching(app.db, bob.id, release.id)).toBe(true);
106 // Twice is still once.
107 expect(watchMilestone(app, bob, fresh(engine), release.number, true)).toEqual({
108 ok: true,
109 value: { watching: true },
110 });
111 // Bob's watch is his own: alice does not watch because he does.
112 expect(isWatching(app.db, alice.id, release.id)).toBe(false);
113
114 const off = watchMilestone(app, bob, fresh(engine), release.number, false);
115 expect(off).toEqual({ ok: true, value: { watching: false } });
116 expect(isWatching(app.db, bob.id, release.id)).toBe(false);
117
118 // Neither a start nor a stop is an event.
119 expect(heard).toEqual([]);
120 expect(events()).toBe(before);
121 });
122
123 test("a guest is unauthorized, an agent forbidden", () => {
124 const before = events();
125 heard.length = 0;
126 expect(watchMilestone(app, undefined, fresh(engine), release.number, true)).toEqual({
127 ok: false,
128 error: "unauthorized",
129 });
130 // Mason is on the engine's crew and reads it; an agent still does not watch.
131 expect(watchMilestone(app, mason, fresh(engine), release.number, true)).toEqual({
132 ok: false,
133 error: "forbidden",
134 });
135 expect(isWatching(app.db, mason.id, release.id)).toBe(false);
136 expect(heard).toEqual([]);
137 expect(events()).toBe(before);
138 });
139
140 test("a repository that cannot be read and a number that is no milestone are not_found", () => {
141 const before = events();
142 heard.length = 0;
143 // Bob cannot read the secret: the same answer as no milestone at all.
144 expect(watchMilestone(app, bob, fresh(secret), hidden.number, true)).toEqual({
145 ok: false,
146 error: "not_found",
147 });
148 expect(isWatching(app.db, bob.id, hidden.id)).toBe(false);
149 // The owner may.
150 expect(watchMilestone(app, alice, fresh(secret), hidden.number, true).ok).toBe(true);
151 expect(watchMilestone(app, alice, fresh(secret), hidden.number, false).ok).toBe(true);
152 // An issue that waits for nothing, a number nobody holds, and no integer.
153 for (const number of [plain.number, 9999, 1.5, Number.NaN]) {
154 expect(watchMilestone(app, bob, fresh(engine), number, true), String(number)).toEqual({
155 ok: false,
156 error: "not_found",
157 });
158 }
159 expect(isWatching(app.db, bob.id, plain.id)).toBe(false);
160 expect(heard).toEqual([]);
161 expect(events()).toBe(before);
162 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs