1
10import { afterAll, beforeAll, describe, expect, it } from "vitest";
11import { createTestApp, type TestAppHandle } from "./helpers/api";
12import { addMemberViaSeam, authHeaders, createWorkspace } from "./helpers/seed";
13
14let app: TestAppHandle;
15
16async function mkUser(name: string) {
17
18 const email = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.local`;
19 const password = `Str0ng-Pass-${Math.random().toString(36).slice(2, 10)}`;
20 const reg = await app.request("POST", "/api/v1/auth/register", { body: { email, password, name } });
21 if (reg.status !== 201 && reg.status !== 200) throw new Error(`register ${name}: ${reg.status} ${await reg.text()}`);
22 const login = await app.request("POST", "/api/v1/auth/login", { body: { email, password } });
23 if (login.status !== 200 && login.status !== 201) throw new Error(`login ${name}: ${login.status} ${await login.text()}`);
24 const body = (await login.json()) as { user?: { id?: string }; accessToken?: string };
25 return { userId: body.user?.id ?? "", email, accessToken: body.accessToken ?? "" };
26}
27
28describe("Templates: directory + variables + versions (T-141/142/145)", () => {
29 beforeAll(async () => {
30 app = await createTestApp();
31 }, 120_000);
32 afterAll(async () => {
33 await app?.close();
34 });
35
36 it("lists built-in templates with category + search filters (T-141)", async () => {
37 const owner = await mkUser("Tpl Owner");
38 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Template Space");
39 const h = authHeaders(owner.accessToken);
40
41
42 const all = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: h });
43 expect(all.status).toBe(200);
44 const builtins = (await all.json()).data.filter((t) => t.isBuiltin);
45 expect(builtins.length).toBe(15);
46 expect(new Set(builtins.map((t) => t.name)).size).toBe(15);
47
48
49 const meetings = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?category=Meetings`, { headers: h });
50 const meetingsBody = await meetings.json();
51 expect(meetingsBody.data.length).toBe(2);
52 expect(meetingsBody.data.every((t) => t.category === "Meetings")).toBe(true);
53
54
55 const search = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?search=incident`, { headers: h });
56 expect((await search.json()).data.map((t) => t.name)).toEqual(["Incident Report"]);
57
58
59 const none = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?category=DoesNotExist`, { headers: h });
60 expect((await none.json()).data).toHaveLength(0);
61
62
63 const outsider = await mkUser("Tpl Outsider");
64 const nomem = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: authHeaders(outsider.accessToken) });
65 expect(nomem.status).toBe(200);
66 for (const t of (await nomem.json()).data) {
67 expect(t.isBuiltin).toBe(true);
68 }
69 });
70
71 it("create-with-variables + apply substitutes values (T-142)", async () => {
72 const owner = await mkUser("Var Owner");
73 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Var Space");
74 const h = authHeaders(owner.accessToken);
75 h["x-factory-workspace"] = workspaceId;
76
77
78 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Draft Plan" } });
79 expect(pageRes.status).toBe(201);
80 const page = (await pageRes.json()) as { id: string };
81 const blockRes = await app.request("POST", "/api/v1/blocks/batch", {
82 headers: h,
83 body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "Project {{project}} owned by {{owner}}; milestone on {{date}}" } } }] }
84 });
85 expect(blockRes.status).toBe(201);
86
87
88 const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, {
89 headers: h,
90 body: {
91 name: "My Plan Template",
92 variables: [
93 { name: "project", label: "Project", type: "text", required: true },
94 { name: "owner", label: "Owner", type: "text" },
95 { name: "date", label: "Date", type: "date", defaultValue: "2026-12-31" }
96 ]
97 }
98 });
99 expect(tplRes.status).toBe(201);
100 const tpl = (await tplRes.json()) as { id: string; variables: unknown[] };
101 expect(tpl.variables).toHaveLength(3);
102
103
104 const badTpl = await app.request("POST", `/api/v1/pages/${page.id}/templates`, {
105 headers: h,
106 body: { name: "Bad", variables: [{ name: "no spaces!", label: "L", type: "text" }] }
107 });
108 expect(badTpl.status).toBe(400);
109
110
111 const missReq = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, { headers: h, body: {} });
112 expect(missReq.status).toBe(400);
113
114
115 const apply = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, {
116 headers: h,
117 body: { values: { project: "Apollo", owner: "Ada" } }
118 });
119 expect(apply.status).toBe(201);
120 const newPage = (await apply.json()) as { id: string };
121
122
123 const blocks = await app.prisma.block.findMany({ where: { pageId: newPage.id, deletedAt: null } });
124 const block = blocks.find((b) => ((b.content as { text?: string }).text ?? "").includes("owned by"));
125 expect(block).toBeTruthy();
126 const text = (block?.content as { text: string }).text;
127 expect(text).toContain("Project Apollo owned by Ada");
128 expect(text).toContain("milestone on 2026-12-31");
129 expect(text).not.toContain("{{");
130
131
132 const badValues = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, {
133 headers: h,
134 body: { values: { project: "X", date: "tomorrow" } }
135 });
136 expect(badValues.status).toBe(400);
137 });
138
139 it("preview: member gets the current snapshot (read-only); non-member 404 (no leak) (T-141)", async () => {
140 const owner = await mkUser("Preview Owner");
141 const outsider = await mkUser("Preview Outsider");
142 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Preview Space");
143 const h = authHeaders(owner.accessToken);
144 h["x-factory-workspace"] = workspaceId;
145
146 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Preview Source" } });
147 const page = (await pageRes.json()) as { id: string };
148 const blockRes = await app.request("POST", "/api/v1/blocks/batch", {
149 headers: h,
150 body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "preview body text" } } }] }
151 });
152 expect(blockRes.status).toBe(201);
153 const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "Preview Tpl" } });
154 expect(tplRes.status).toBe(201);
155 const tpl = (await tplRes.json()) as { id: string };
156
157
158
159 const ok = await app.request("GET", `/api/v1/templates/${tpl.id}/preview`, { headers: authHeaders(owner.accessToken) });
160 expect(ok.status).toBe(200);
161 const snap = (await ok.json()).data.snapshot as { page: { title: string }; blocks: { type: string; content: { text?: string } }[] };
162 expect(snap.page.title).toBe("Preview Source");
163 const bodyBlock = snap.blocks.find((b) => b.content?.text === "preview body text");
164 expect(bodyBlock).toBeTruthy();
165
166
167
168 const denied = await app.request("GET", `/api/v1/templates/${tpl.id}/preview`, { headers: authHeaders(outsider.accessToken) });
169 expect(denied.status).toBe(404);
170
171
172 const missing = await app.request("GET", `/api/v1/templates/does-not-exist/preview`, { headers: authHeaders(owner.accessToken) });
173 expect(missing.status).toBe(404);
174 });
175
176 it("version history: snapshot, list, restore (history never overwritten) (T-145)", async () => {
177 const owner = await mkUser("Ver Owner");
178 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Ver Space");
179 const h = authHeaders(owner.accessToken);
180 h["x-factory-workspace"] = workspaceId;
181
182 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Versioned Source" } });
183 const page = (await pageRes.json()) as { id: string };
184 const blockRes = await app.request("POST", "/api/v1/blocks/batch", {
185 headers: h,
186 body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "v1 content" } } }] }
187 });
188 expect(blockRes.status).toBe(201);
189 const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "Versioned" } });
190 const tpl = (await tplRes.json()) as { id: string };
191
192
193 let list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h });
194 expect(list.status).toBe(200);
195 expect((await list.json()).data.map((v) => v.version)).toEqual([1]);
196
197
198 const v2 = await app.request("POST", `/api/v1/templates/${tpl.id}/versions`, { headers: h, body: { note: "checkpoint" } });
199 expect(v2.status).toBe(201);
200 expect((await v2.json()).version).toBe(2);
201
202 list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h });
203 const versions = (await list.json()).data;
204 expect(versions.map((v) => v.version).sort((a, b) => b - a)).toEqual([2, 1]);
205 expect(versions.find((v) => v.version === 2).note).toBe("checkpoint");
206
207
208
209 const v1 = versions.find((v) => v.version === 1);
210 const restore = await app.request("POST", `/api/v1/templates/${tpl.id}/versions/${v1.id}/restore`, { headers: h });
211 expect(restore.status).toBe(201);
212 expect(await restore.json()).toMatchObject({ restoredFromVersion: 1, newVersion: 3 });
213
214 list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h });
215 expect((await list.json()).data).toHaveLength(3);
216 });
217
218 it("built-in templates are read-only for members (only FULL_ACCESS can version them)", async () => {
219 const owner = await mkUser("Builtin Owner");
220 const member = await mkUser("Builtin Member");
221 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Builtin Space");
222 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER");
223
224 const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: authHeaders(owner.accessToken) });
225 const builtin = (await list.json()).data.find((t) => t.isBuiltin && t.name === "Meeting Notes");
226 expect(builtin).toBeTruthy();
227
228 const mh = authHeaders(member.accessToken);
229 mh["x-factory-workspace"] = workspaceId;
230
231
232 const v = await app.request("POST", `/api/v1/templates/${builtin.id}/versions`, { headers: mh, body: {} });
233 expect(v.status).toBe(403);
234
235
236 const oh = authHeaders(owner.accessToken);
237 oh["x-factory-workspace"] = workspaceId;
238 const ov = await app.request("POST", `/api/v1/templates/${builtin.id}/versions`, { headers: oh, body: { note: "admin audit" } });
239 expect(ov.status).toBe(201);
240 expect((await ov.json()).version).toBe(1);
241 });
242});
243