an app to share curated trails
sidetrail.app
1/**
2 * Drafts Tests
3 *
4 * Scenarios covering draft storage with access control,
5 * CRUD operations, and conflict detection.
6 */
7
8import { describe, it, expect, beforeEach, vi } from "vitest";
9import { ALICE, BOB, setCurrentUser, generateTid } from "./helpers";
10
11// Import after mocks are set up (via setup.ts)
12import * as queries from "../drafts/queries";
13import * as actions from "../drafts/actions";
14
15const makeDraft = (overrides: Partial<Parameters<typeof actions.saveDraft>[1]> = {}) => ({
16 title: "Test Draft",
17 description: "A test draft",
18 stops: [
19 { tid: generateTid(), title: "Stop 1", content: "Content 1" },
20 { tid: generateTid(), title: "Stop 2", content: "Content 2" },
21 ],
22 accentColor: "#ff5500",
23 backgroundColor: "#fff8f0",
24 ...overrides,
25});
26
27describe("Server Drafts - Access Control", () => {
28 it("requires authentication to list drafts", async () => {
29 setCurrentUser(null);
30 await expect(queries.loadDrafts()).rejects.toThrow("Authentication required");
31 });
32
33 it("requires authentication to get draft detail", async () => {
34 setCurrentUser(null);
35 await expect(queries.loadDraftDetail("some-rkey")).rejects.toThrow("Authentication required");
36 });
37
38 it("requires authentication to save draft", async () => {
39 setCurrentUser(null);
40 await expect(actions.saveDraft("some-rkey", makeDraft())).rejects.toThrow(
41 "Authentication required",
42 );
43 });
44
45 it("requires authentication to delete draft", async () => {
46 setCurrentUser(null);
47 await expect(actions.deleteDraft("some-rkey")).rejects.toThrow("Authentication required");
48 });
49
50 it("user can only see their own drafts", async () => {
51 // Alice creates a draft
52 setCurrentUser(ALICE);
53 await actions.saveDraft("alice-draft", makeDraft({ title: "Alice's Draft" }));
54
55 // Bob creates a draft
56 setCurrentUser(BOB);
57 await actions.saveDraft("bob-draft", makeDraft({ title: "Bob's Draft" }));
58
59 // Alice only sees her draft
60 setCurrentUser(ALICE);
61 const aliceDrafts = await queries.loadDrafts();
62 expect(aliceDrafts).toHaveLength(1);
63 expect(aliceDrafts[0].title).toBe("Alice's Draft");
64
65 // Bob only sees his draft
66 setCurrentUser(BOB);
67 const bobDrafts = await queries.loadDrafts();
68 expect(bobDrafts).toHaveLength(1);
69 expect(bobDrafts[0].title).toBe("Bob's Draft");
70 });
71
72 it("user cannot access another user's draft by rkey", async () => {
73 setCurrentUser(ALICE);
74 await actions.saveDraft("secret", makeDraft({ title: "Alice's Secret" }));
75
76 setCurrentUser(BOB);
77 const draft = await queries.loadDraftDetail("secret");
78 expect(draft).toBeNull(); // Bob can't see Alice's draft
79 });
80
81 it("user cannot delete another user's draft", async () => {
82 setCurrentUser(ALICE);
83 await actions.saveDraft("protected", makeDraft({ title: "Protected" }));
84
85 setCurrentUser(BOB);
86 await actions.deleteDraft("protected"); // Silently does nothing
87
88 setCurrentUser(ALICE);
89 const draft = await queries.loadDraftDetail("protected");
90 expect(draft).not.toBeNull(); // Still exists
91 });
92});
93
94describe("Server Drafts - Creating", () => {
95 beforeEach(() => {
96 setCurrentUser(ALICE);
97 });
98
99 it("creates a new draft with createDraft action", async () => {
100 const rkey = await actions.createDraft();
101
102 expect(rkey).toBeTruthy();
103
104 const draft = await queries.loadDraftDetail(rkey);
105 expect(draft).not.toBeNull();
106 expect(draft!.title).toBe(""); // Empty title for new draft
107 expect(draft!.stops).toHaveLength(2); // Default 2 stops
108 });
109
110 it("creates a draft with saveDraft", async () => {
111 const result = await actions.saveDraft("new-draft", makeDraft({ title: "My Draft" }));
112
113 expect(result.success).toBe(true);
114 expect(result.version).toBe(1);
115
116 const draft = await queries.loadDraftDetail("new-draft");
117 expect(draft?.title).toBe("My Draft");
118 expect(draft?.version).toBe(1);
119 });
120
121 it("new draft gets timestamps set", async () => {
122 const before = Date.now();
123 await actions.saveDraft("timestamped", makeDraft());
124 const after = Date.now();
125
126 const draft = await queries.loadDraftDetail("timestamped");
127
128 expect(draft!.createdAt.getTime()).toBeGreaterThanOrEqual(before);
129 expect(draft!.createdAt.getTime()).toBeLessThanOrEqual(after);
130 expect(draft!.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
131 expect(draft!.updatedAt.getTime()).toBeLessThanOrEqual(after);
132 });
133});
134
135describe("Server Drafts - Updating", () => {
136 beforeEach(() => {
137 setCurrentUser(ALICE);
138 });
139
140 it("updates existing draft and increments version", async () => {
141 await actions.saveDraft("updating", makeDraft({ title: "V1" }));
142
143 const result = await actions.saveDraft("updating", makeDraft({ title: "V2" }));
144
145 expect(result.version).toBe(2);
146
147 const draft = await queries.loadDraftDetail("updating");
148 expect(draft?.title).toBe("V2");
149 expect(draft?.version).toBe(2);
150 });
151
152 it("updating updates the updatedAt timestamp", async () => {
153 vi.useFakeTimers();
154
155 await actions.saveDraft("timing", makeDraft({ title: "Original" }));
156 const before = await queries.loadDraftDetail("timing");
157
158 vi.advanceTimersByTime(5000);
159
160 await actions.saveDraft("timing", makeDraft({ title: "Updated" }));
161 const after = await queries.loadDraftDetail("timing");
162
163 expect(after!.updatedAt.getTime()).toBeGreaterThan(before!.updatedAt.getTime());
164 // createdAt should stay the same (or close - depends on implementation)
165
166 vi.useRealTimers();
167 });
168
169 it("preserves all fields when updating", async () => {
170 const original = makeDraft({
171 title: "Original Title",
172 description: "Original Description",
173 accentColor: "#111111",
174 backgroundColor: "#eeeeee",
175 });
176 await actions.saveDraft("preserve", original);
177
178 const updated = makeDraft({
179 title: "Updated Title",
180 description: "Updated Description",
181 accentColor: "#222222",
182 backgroundColor: "#dddddd",
183 });
184 await actions.saveDraft("preserve", updated);
185
186 const draft = await queries.loadDraftDetail("preserve");
187 expect(draft?.title).toBe("Updated Title");
188 expect(draft?.description).toBe("Updated Description");
189 expect(draft?.accentColor).toBe("#222222");
190 expect(draft?.backgroundColor).toBe("#dddddd");
191 });
192});
193
194describe("Server Drafts - Deleting", () => {
195 beforeEach(() => {
196 setCurrentUser(ALICE);
197 });
198
199 it("deleted draft disappears from list", async () => {
200 await actions.saveDraft("to-delete", makeDraft({ title: "To Delete" }));
201
202 const beforeList = await queries.loadDrafts();
203 expect(beforeList).toHaveLength(1);
204
205 await actions.deleteDraft("to-delete");
206
207 const afterList = await queries.loadDrafts();
208 expect(afterList).toHaveLength(0);
209 });
210
211 it("deleted draft detail returns null", async () => {
212 await actions.saveDraft("temporary", makeDraft({ title: "Temporary" }));
213
214 await actions.deleteDraft("temporary");
215
216 const draft = await queries.loadDraftDetail("temporary");
217 expect(draft).toBeNull();
218 });
219
220 it("deleting one draft does not affect others", async () => {
221 await actions.saveDraft("draft1", makeDraft({ title: "Draft 1" }));
222 await actions.saveDraft("draft2", makeDraft({ title: "Draft 2" }));
223
224 await actions.deleteDraft("draft1");
225
226 const remaining = await queries.loadDrafts();
227 expect(remaining).toHaveLength(1);
228 expect(remaining[0].rkey).toBe("draft2");
229 expect(remaining[0].title).toBe("Draft 2");
230 });
231
232 it("deleting non-existent draft is a no-op", async () => {
233 // Should not throw
234 await actions.deleteDraft("nonexistent");
235 });
236});
237
238describe("Server Drafts - Listing and Ordering", () => {
239 beforeEach(() => {
240 setCurrentUser(ALICE);
241 });
242
243 it("drafts are ordered by updatedAt descending (newest first)", async () => {
244 vi.useFakeTimers();
245
246 await actions.saveDraft("old", makeDraft({ title: "Old Draft" }));
247
248 vi.advanceTimersByTime(1000);
249
250 await actions.saveDraft("new", makeDraft({ title: "New Draft" }));
251
252 const drafts = await queries.loadDrafts();
253
254 expect(drafts).toHaveLength(2);
255 expect(drafts[0].title).toBe("New Draft");
256 expect(drafts[1].title).toBe("Old Draft");
257
258 vi.useRealTimers();
259 });
260
261 it("editing a draft moves it to top of list", async () => {
262 vi.useFakeTimers();
263
264 await actions.saveDraft("first", makeDraft({ title: "First Draft" }));
265
266 vi.advanceTimersByTime(1000);
267
268 await actions.saveDraft("second", makeDraft({ title: "Second Draft" }));
269
270 // Second draft is on top
271 let drafts = await queries.loadDrafts();
272 expect(drafts[0].title).toBe("Second Draft");
273
274 vi.advanceTimersByTime(1000);
275
276 // Edit the first draft
277 await actions.saveDraft("first", makeDraft({ title: "First Draft (Edited)" }));
278
279 // Now first draft is on top
280 drafts = await queries.loadDrafts();
281 expect(drafts[0].title).toBe("First Draft (Edited)");
282
283 vi.useRealTimers();
284 });
285
286 it("list shows correct stops count", async () => {
287 await actions.saveDraft(
288 "three-stops",
289 makeDraft({
290 title: "Three Stops",
291 stops: [
292 { tid: generateTid(), title: "S1", content: "C1" },
293 { tid: generateTid(), title: "S2", content: "C2" },
294 { tid: generateTid(), title: "S3", content: "C3" },
295 ],
296 }),
297 );
298
299 const drafts = await queries.loadDrafts();
300 expect(drafts[0].stopsCount).toBe(3);
301 });
302});
303
304describe("Server Drafts - Conflict Detection", () => {
305 beforeEach(() => {
306 setCurrentUser(ALICE);
307 });
308
309 it("detects when saving over a newer version", async () => {
310 // Initial save
311 const r1 = await actions.saveDraft("conflict", makeDraft({ title: "V1" }));
312 expect(r1.version).toBe(1);
313
314 // Simulate another tab/device saving (version becomes 2)
315 await actions.saveDraft("conflict", makeDraft({ title: "V2 from other tab" }));
316
317 // Original tab tries to save with stale version
318 const r3 = await actions.saveDraft(
319 "conflict",
320 makeDraft({ title: "V3 from original tab" }),
321 1, // Expected version 1, but server has 2
322 );
323
324 expect(r3.success).toBe(true);
325 expect(r3.warning).toBe("overwrote_newer");
326 expect(r3.version).toBe(3);
327 });
328
329 it("no warning when saving with current version", async () => {
330 const r1 = await actions.saveDraft("no-conflict", makeDraft({ title: "V1" }));
331
332 const r2 = await actions.saveDraft(
333 "no-conflict",
334 makeDraft({ title: "V2" }),
335 r1.version, // Expected version matches
336 );
337
338 expect(r2.success).toBe(true);
339 expect(r2.warning).toBeUndefined();
340 });
341
342 it("no warning when expectedVersion not provided", async () => {
343 await actions.saveDraft("no-check", makeDraft({ title: "V1" }));
344 await actions.saveDraft("no-check", makeDraft({ title: "V2" }));
345
346 // Third save without version check
347 const r3 = await actions.saveDraft("no-check", makeDraft({ title: "V3" }));
348
349 expect(r3.success).toBe(true);
350 expect(r3.warning).toBeUndefined();
351 });
352});
353
354describe("Server Drafts - Draft Badges", () => {
355 beforeEach(() => {
356 setCurrentUser(ALICE);
357 });
358
359 it("loadDraftsBadges returns badges for drafts", async () => {
360 await actions.saveDraft("badge1", makeDraft({ accentColor: "#ff0000" }));
361 await actions.saveDraft("badge2", makeDraft({ accentColor: "#00ff00" }));
362
363 const badges = await queries.loadDraftsBadges();
364 expect(badges).toHaveLength(2);
365 expect(badges.map((b) => b.accentColor)).toContain("#ff0000");
366 expect(badges.map((b) => b.accentColor)).toContain("#00ff00");
367 });
368
369 it("badges show max 3 drafts", async () => {
370 for (let i = 0; i < 5; i++) {
371 await actions.saveDraft(`badge${i}`, makeDraft({ accentColor: `#${i}${i}${i}${i}${i}${i}` }));
372 }
373
374 const badges = await queries.loadDraftsBadges();
375 expect(badges).toHaveLength(3);
376 });
377
378 it("badges require authentication", async () => {
379 setCurrentUser(null);
380 await expect(queries.loadDraftsBadges()).rejects.toThrow("Authentication required");
381 });
382});
383
384describe("Server Drafts - Empty States", () => {
385 beforeEach(() => {
386 setCurrentUser(ALICE);
387 });
388
389 it("loadDrafts returns empty array when no drafts", async () => {
390 const drafts = await queries.loadDrafts();
391 expect(drafts).toEqual([]);
392 });
393
394 it("loadDraftDetail returns null for non-existent rkey", async () => {
395 const detail = await queries.loadDraftDetail("nonexistent");
396 expect(detail).toBeNull();
397 });
398});