an app to share curated trails sidetrail.app
1

Configure Feed

Select the types of activity you want to include in your feed.

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});