an app to share curated trails
sidetrail.app
1/**
2 * Walking Journey Tests
3 *
4 * Scenarios covering the user's journey through trails:
5 * discovering, starting, progressing, completing, abandoning, and restarting.
6 */
7
8import { describe, it, expect } from "vitest";
9import * as queries from "../queries";
10import { emit, emitDelete, ALICE, BOB, CAROL, setCurrentUser, generateTid } from "./helpers";
11
12const now = () => new Date().toISOString();
13
14describe("Starting a walk", () => {
15 it("user discovers trail, starts walking, sees it in their walks list", async () => {
16 const stop1 = generateTid();
17 const stop2 = generateTid();
18
19 // Alice publishes a trail
20 const trail = await emit("app.sidetrail.trail", ALICE.did, {
21 $type: "app.sidetrail.trail",
22 title: "Learn TypeScript",
23 description: "A beginner's guide to TS",
24 stops: [
25 { tid: stop1, title: "Types Basics", content: "Let's start with types" },
26 { tid: stop2, title: "Interfaces", content: "Now interfaces" },
27 ],
28 accentColor: "#3178c6",
29 backgroundColor: "#f0f4f8",
30 createdAt: now(),
31 });
32
33 // Bob discovers it and starts walking
34 setCurrentUser(BOB);
35 await emit("app.sidetrail.walk", BOB.did, {
36 $type: "app.sidetrail.walk",
37 trail: { uri: trail.uri, cid: trail.cid },
38 visitedStops: [stop1],
39 createdAt: now(),
40 updatedAt: now(),
41 });
42
43 // Bob's walks list shows the trail
44 const walks = await queries.loadWalks();
45 expect(walks).toHaveLength(1);
46 expect(walks[0].title).toBe("Learn TypeScript");
47 expect(walks[0].visitedStops).toEqual([stop1]);
48
49 // Trail detail shows Bob's progress
50 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
51 expect(detail.yourWalk).not.toBeNull();
52 expect(detail.yourWalk?.visitedStops).toEqual([stop1]);
53
54 // Bob appears as a walker
55 const walkers = await detail.walkers;
56 expect(walkers).toHaveLength(1);
57 expect(walkers[0].user.handle).toBe(BOB.handle);
58 expect(walkers[0].isYou).toBe(true);
59
60 // Bob appears at stop 1
61 const atStop1 = await detail.stops[0].walkersHere;
62 expect(atStop1.map((u) => u.handle)).toContain(BOB.handle);
63 });
64
65 it("walking badges show max 3 most recent walks", async () => {
66 const createTrailAndWalk = async (title: string, createdAt: string) => {
67 const trail = await emit("app.sidetrail.trail", ALICE.did, {
68 $type: "app.sidetrail.trail",
69 title,
70 description: "Test",
71 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
72 accentColor: `#${title.replace(" ", "")}`,
73 backgroundColor: "#ffffff",
74 createdAt: now(),
75 });
76
77 await emit("app.sidetrail.walk", BOB.did, {
78 $type: "app.sidetrail.walk",
79 trail: { uri: trail.uri, cid: trail.cid },
80 visitedStops: [],
81 createdAt,
82 updatedAt: createdAt,
83 });
84
85 return trail;
86 };
87
88 setCurrentUser(BOB);
89
90 // Bob walks 4 trails at different times
91 await createTrailAndWalk("Oldest", "2024-01-01T10:00:00Z");
92 await createTrailAndWalk("Second", "2024-01-02T10:00:00Z");
93 await createTrailAndWalk("Third", "2024-01-03T10:00:00Z");
94 await createTrailAndWalk("Newest", "2024-01-04T10:00:00Z");
95
96 // Badges show exactly 3
97 const badges = await queries.loadWalkingBadges();
98 expect(badges).toHaveLength(3);
99 });
100});
101
102describe("Progressing through a trail", () => {
103 it("user visits stops one by one, position updates correctly", async () => {
104 const stop1 = generateTid();
105 const stop2 = generateTid();
106 const stop3 = generateTid();
107
108 const trail = await emit("app.sidetrail.trail", ALICE.did, {
109 $type: "app.sidetrail.trail",
110 title: "Three Stop Trail",
111 description: "Progress through all stops",
112 stops: [
113 { tid: stop1, title: "First", content: "Start" },
114 { tid: stop2, title: "Second", content: "Middle" },
115 { tid: stop3, title: "Third", content: "End" },
116 ],
117 accentColor: "#ff0000",
118 backgroundColor: "#fff0f0",
119 createdAt: now(),
120 });
121
122 setCurrentUser(BOB);
123
124 // Start at stop 1
125 const walk = await emit("app.sidetrail.walk", BOB.did, {
126 $type: "app.sidetrail.walk",
127 trail: { uri: trail.uri, cid: trail.cid },
128 visitedStops: [stop1],
129 createdAt: now(),
130 updatedAt: now(),
131 });
132
133 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
134 expect((await detail.stops[0].walkersHere).map((u) => u.handle)).toContain(BOB.handle);
135 expect(await detail.stops[1].walkersHere).toHaveLength(0);
136
137 // Move to stop 2
138 await emit(
139 "app.sidetrail.walk",
140 BOB.did,
141 {
142 $type: "app.sidetrail.walk",
143 trail: { uri: trail.uri, cid: trail.cid },
144 visitedStops: [stop1, stop2],
145 createdAt: now(),
146 updatedAt: now(),
147 },
148 walk.rkey,
149 );
150
151 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
152 expect(await detail.stops[0].walkersHere).toHaveLength(0);
153 expect((await detail.stops[1].walkersHere).map((u) => u.handle)).toContain(BOB.handle);
154
155 // Move to stop 3
156 await emit(
157 "app.sidetrail.walk",
158 BOB.did,
159 {
160 $type: "app.sidetrail.walk",
161 trail: { uri: trail.uri, cid: trail.cid },
162 visitedStops: [stop1, stop2, stop3],
163 createdAt: now(),
164 updatedAt: now(),
165 },
166 walk.rkey,
167 );
168
169 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
170 expect((await detail.stops[2].walkersHere).map((u) => u.handle)).toContain(BOB.handle);
171
172 // Walks list shows full progress
173 const walks = await queries.loadWalks();
174 expect(walks[0].visitedStops).toEqual([stop1, stop2, stop3]);
175 });
176
177 it("going back to a previous stop updates walker position", async () => {
178 const stop1 = generateTid();
179 const stop2 = generateTid();
180 const stop3 = generateTid();
181
182 const trail = await emit("app.sidetrail.trail", ALICE.did, {
183 $type: "app.sidetrail.trail",
184 title: "Go Back Trail",
185 description: "Test going back",
186 stops: [
187 { tid: stop1, title: "First", content: "Start" },
188 { tid: stop2, title: "Second", content: "Middle" },
189 { tid: stop3, title: "Third", content: "End" },
190 ],
191 accentColor: "#ff0000",
192 backgroundColor: "#fff0f0",
193 createdAt: now(),
194 });
195
196 setCurrentUser(BOB);
197
198 // Bob visits all 3 stops
199 const walk = await emit("app.sidetrail.walk", BOB.did, {
200 $type: "app.sidetrail.walk",
201 trail: { uri: trail.uri, cid: trail.cid },
202 visitedStops: [stop1, stop2, stop3],
203 createdAt: now(),
204 updatedAt: now(),
205 });
206
207 // Bob is at stop 3
208 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
209 expect((await detail.stops[2].walkersHere).map((u) => u.handle)).toContain(BOB.handle);
210 expect(await detail.stops[1].walkersHere).toHaveLength(0);
211
212 // Bob goes back to stop 2 (visitedStops becomes [stop1, stop3, stop2])
213 await emit(
214 "app.sidetrail.walk",
215 BOB.did,
216 {
217 $type: "app.sidetrail.walk",
218 trail: { uri: trail.uri, cid: trail.cid },
219 visitedStops: [stop1, stop3, stop2], // stop2 moved to end = current position
220 createdAt: now(),
221 updatedAt: now(),
222 },
223 walk.rkey,
224 );
225
226 // Bob is now at stop 2, not stop 3
227 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
228 expect((await detail.stops[1].walkersHere).map((u) => u.handle)).toContain(BOB.handle);
229 expect(await detail.stops[2].walkersHere).toHaveLength(0);
230
231 // Walks list shows updated order
232 const walks = await queries.loadWalks();
233 expect(walks[0].visitedStops).toEqual([stop1, stop3, stop2]);
234 });
235
236 it("walk updates affect activity ordering", async () => {
237 const stopTid = generateTid();
238 const trail = await emit("app.sidetrail.trail", ALICE.did, {
239 $type: "app.sidetrail.trail",
240 title: "Activity Order Trail",
241 description: "Test",
242 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
243 accentColor: "#00ff00",
244 backgroundColor: "#f0fff0",
245 createdAt: now(),
246 });
247
248 // Bob starts first
249 const bobWalk = await emit("app.sidetrail.walk", BOB.did, {
250 $type: "app.sidetrail.walk",
251 trail: { uri: trail.uri, cid: trail.cid },
252 visitedStops: [stopTid],
253 createdAt: "2024-01-01T10:00:00Z",
254 updatedAt: "2024-01-01T10:00:00Z",
255 });
256
257 // Carol starts later
258 await emit("app.sidetrail.walk", CAROL.did, {
259 $type: "app.sidetrail.walk",
260 trail: { uri: trail.uri, cid: trail.cid },
261 visitedStops: [stopTid],
262 createdAt: "2024-01-02T10:00:00Z",
263 updatedAt: "2024-01-02T10:00:00Z",
264 });
265
266 // Carol is first (most recent)
267 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
268 let walkers = await detail.walkers;
269 expect(walkers[0].user.handle).toBe(CAROL.handle);
270
271 // Bob updates his walk (now most recent)
272 await emit(
273 "app.sidetrail.walk",
274 BOB.did,
275 {
276 $type: "app.sidetrail.walk",
277 trail: { uri: trail.uri, cid: trail.cid },
278 visitedStops: [stopTid],
279 createdAt: "2024-01-01T10:00:00Z",
280 updatedAt: "2024-01-03T10:00:00Z",
281 },
282 bobWalk.rkey,
283 );
284
285 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
286 walkers = await detail.walkers;
287 expect(walkers[0].user.handle).toBe(BOB.handle);
288 });
289});
290
291describe("Completing a trail", () => {
292 it("completion appears on trail and in user's profile", async () => {
293 const stopTid = generateTid();
294 const trail = await emit("app.sidetrail.trail", ALICE.did, {
295 $type: "app.sidetrail.trail",
296 title: "Completable Trail",
297 description: "Finish this one",
298 stops: [{ tid: stopTid, title: "Only Stop", content: "Done!" }],
299 accentColor: "#ffd700",
300 backgroundColor: "#fffde7",
301 createdAt: now(),
302 });
303
304 // Bob completes the trail
305 await emit("app.sidetrail.completion", BOB.did, {
306 $type: "app.sidetrail.completion",
307 trail: { uri: trail.uri, cid: trail.cid },
308 createdAt: now(),
309 });
310
311 // Completion shows on trail
312 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
313 const completions = await detail.completions;
314 expect(completions).toHaveLength(1);
315 expect(completions[0].user.handle).toBe(BOB.handle);
316
317 // Completer also appears in walkers list
318 const walkers = await detail.walkers;
319 expect(walkers.map((w) => w.user.handle)).toContain(BOB.handle);
320
321 // Completion shows on Bob's profile
322 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
323 expect(completedTrails).toHaveLength(1);
324 expect(completedTrails[0].title).toBe("Completable Trail");
325 });
326
327 it("completing same trail twice shows most recent in profile (deduped)", async () => {
328 const trail = await emit("app.sidetrail.trail", ALICE.did, {
329 $type: "app.sidetrail.trail",
330 title: "Re-completable",
331 description: "Can complete multiple times",
332 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
333 accentColor: "#ff00ff",
334 backgroundColor: "#fff0ff",
335 createdAt: now(),
336 });
337
338 // Bob completes twice
339 await emit("app.sidetrail.completion", BOB.did, {
340 $type: "app.sidetrail.completion",
341 trail: { uri: trail.uri, cid: trail.cid },
342 createdAt: "2024-01-01T10:00:00Z",
343 });
344
345 await emit("app.sidetrail.completion", BOB.did, {
346 $type: "app.sidetrail.completion",
347 trail: { uri: trail.uri, cid: trail.cid },
348 createdAt: "2024-06-01T10:00:00Z",
349 });
350
351 // Profile shows only one (most recent)
352 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
353 expect(completedTrails).toHaveLength(1);
354 expect(completedTrails[0].completedAt.toISOString()).toBe("2024-06-01T10:00:00.000Z");
355
356 // But trail detail shows both completions
357 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
358 const completions = await detail.completions;
359 expect(completions).toHaveLength(2);
360 });
361
362 it("user with both active walk AND completion: most recent wins in walkers", async () => {
363 const stopTid = generateTid();
364 const trail = await emit("app.sidetrail.trail", ALICE.did, {
365 $type: "app.sidetrail.trail",
366 title: "Walk And Complete",
367 description: "Testing precedence",
368 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
369 accentColor: "#0000ff",
370 backgroundColor: "#f0f0ff",
371 createdAt: now(),
372 });
373
374 // Bob walks first (older)
375 await emit("app.sidetrail.walk", BOB.did, {
376 $type: "app.sidetrail.walk",
377 trail: { uri: trail.uri, cid: trail.cid },
378 visitedStops: [stopTid],
379 createdAt: "2024-01-01T10:00:00Z",
380 updatedAt: "2024-01-01T10:00:00Z",
381 });
382
383 // Bob completes later (newer)
384 await emit("app.sidetrail.completion", BOB.did, {
385 $type: "app.sidetrail.completion",
386 trail: { uri: trail.uri, cid: trail.cid },
387 createdAt: "2024-06-01T10:00:00Z",
388 });
389
390 // Bob appears only once in walkers (dedupe by user)
391 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
392 const walkers = await detail.walkers;
393 const bobEntries = walkers.filter((w) => w.user.handle === BOB.handle);
394 expect(bobEntries).toHaveLength(1);
395 });
396});
397
398describe("Abandoning a walk", () => {
399 it("abandoned walk disappears from user's walks and trail's walkers", async () => {
400 const stopTid = generateTid();
401 const trail = await emit("app.sidetrail.trail", ALICE.did, {
402 $type: "app.sidetrail.trail",
403 title: "Abandonable Trail",
404 description: "User might give up",
405 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
406 accentColor: "#ff6b6b",
407 backgroundColor: "#fff5f5",
408 createdAt: now(),
409 });
410
411 const walk = await emit("app.sidetrail.walk", BOB.did, {
412 $type: "app.sidetrail.walk",
413 trail: { uri: trail.uri, cid: trail.cid },
414 visitedStops: [stopTid],
415 createdAt: now(),
416 updatedAt: now(),
417 });
418
419 setCurrentUser(BOB);
420
421 // Bob is walking
422 let walks = await queries.loadWalks();
423 expect(walks).toHaveLength(1);
424
425 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
426 expect(detail.yourWalk).not.toBeNull();
427 expect(await detail.walkers).toHaveLength(1);
428
429 // Bob abandons
430 await emitDelete("app.sidetrail.walk", walk.uri);
431
432 // Walk is gone
433 walks = await queries.loadWalks();
434 expect(walks).toHaveLength(0);
435
436 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
437 expect(detail.yourWalk).toBeNull();
438 expect(await detail.walkers).toHaveLength(0);
439 });
440});
441
442describe("Restarting after abandonment", () => {
443 it("user can abandon and start fresh on the same trail", async () => {
444 const stop1 = generateTid();
445 const stop2 = generateTid();
446
447 const trail = await emit("app.sidetrail.trail", ALICE.did, {
448 $type: "app.sidetrail.trail",
449 title: "Restart Trail",
450 description: "Try again!",
451 stops: [
452 { tid: stop1, title: "First", content: "Begin" },
453 { tid: stop2, title: "Second", content: "Continue" },
454 ],
455 accentColor: "#4caf50",
456 backgroundColor: "#e8f5e9",
457 createdAt: now(),
458 });
459
460 setCurrentUser(BOB);
461
462 // First attempt: Bob progresses to stop 2 then abandons
463 const firstWalk = await emit("app.sidetrail.walk", BOB.did, {
464 $type: "app.sidetrail.walk",
465 trail: { uri: trail.uri, cid: trail.cid },
466 visitedStops: [stop1, stop2],
467 createdAt: now(),
468 updatedAt: now(),
469 });
470
471 await emitDelete("app.sidetrail.walk", firstWalk.uri);
472
473 // Verify abandoned
474 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
475 expect(detail.yourWalk).toBeNull();
476
477 // Second attempt: Bob starts fresh at stop 1
478 await emit("app.sidetrail.walk", BOB.did, {
479 $type: "app.sidetrail.walk",
480 trail: { uri: trail.uri, cid: trail.cid },
481 visitedStops: [stop1],
482 createdAt: now(),
483 updatedAt: now(),
484 });
485
486 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
487 expect(detail.yourWalk).not.toBeNull();
488 expect(detail.yourWalk?.visitedStops).toEqual([stop1]);
489
490 const walks = await queries.loadWalks();
491 expect(walks).toHaveLength(1);
492 });
493});
494
495describe("Forgetting a trail completely", () => {
496 it("user forgets trail: both walk and completion are removed", async () => {
497 const stopTid = generateTid();
498 const trail = await emit("app.sidetrail.trail", ALICE.did, {
499 $type: "app.sidetrail.trail",
500 title: "Forgettable Trail",
501 description: "Remove all traces",
502 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
503 accentColor: "#9c27b0",
504 backgroundColor: "#f3e5f5",
505 createdAt: now(),
506 });
507
508 // Bob walks AND completes (unusual but possible)
509 const walk = await emit("app.sidetrail.walk", BOB.did, {
510 $type: "app.sidetrail.walk",
511 trail: { uri: trail.uri, cid: trail.cid },
512 visitedStops: [stopTid],
513 createdAt: now(),
514 updatedAt: now(),
515 });
516
517 const completion = await emit("app.sidetrail.completion", BOB.did, {
518 $type: "app.sidetrail.completion",
519 trail: { uri: trail.uri, cid: trail.cid },
520 createdAt: now(),
521 });
522
523 setCurrentUser(BOB);
524
525 // Verify both exist
526 let walks = await queries.loadWalks();
527 let completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
528 expect(walks).toHaveLength(1);
529 expect(completedTrails).toHaveLength(1);
530
531 // Bob forgets (deletes both)
532 await emitDelete("app.sidetrail.walk", walk.uri);
533 await emitDelete("app.sidetrail.completion", completion.uri);
534
535 // Both gone
536 walks = await queries.loadWalks();
537 completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
538 expect(walks).toHaveLength(0);
539 expect(completedTrails).toHaveLength(0);
540
541 // Trail shows no Bob activity
542 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
543 expect(await detail.walkers).toHaveLength(0);
544 expect(await detail.completions).toHaveLength(0);
545 });
546});
547
548describe("Current user context", () => {
549 it("loadWalks returns empty when not logged in", async () => {
550 const trail = await emit("app.sidetrail.trail", ALICE.did, {
551 $type: "app.sidetrail.trail",
552 title: "Some Trail",
553 description: "Test",
554 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
555 accentColor: "#123",
556 backgroundColor: "#456",
557 createdAt: now(),
558 });
559
560 await emit("app.sidetrail.walk", BOB.did, {
561 $type: "app.sidetrail.walk",
562 trail: { uri: trail.uri, cid: trail.cid },
563 visitedStops: [],
564 createdAt: now(),
565 updatedAt: now(),
566 });
567
568 // Not logged in
569 const walks = await queries.loadWalks();
570 expect(walks).toHaveLength(0);
571 });
572
573 it("loadWalkingBadges returns empty when not logged in", async () => {
574 const badges = await queries.loadWalkingBadges();
575 expect(badges).toHaveLength(0);
576 });
577
578 it("yourWalk is null when not logged in", async () => {
579 const stopTid = generateTid();
580 const trail = await emit("app.sidetrail.trail", ALICE.did, {
581 $type: "app.sidetrail.trail",
582 title: "Trail",
583 description: "Test",
584 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
585 accentColor: "#abc",
586 backgroundColor: "#def",
587 createdAt: now(),
588 });
589
590 await emit("app.sidetrail.walk", BOB.did, {
591 $type: "app.sidetrail.walk",
592 trail: { uri: trail.uri, cid: trail.cid },
593 visitedStops: [stopTid],
594 createdAt: now(),
595 updatedAt: now(),
596 });
597
598 // Not logged in - but Bob's walk still shows in walkers
599 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
600 expect(detail.yourWalk).toBeNull();
601 expect(await detail.walkers).toHaveLength(1);
602 });
603
604 it("loadCurrentUser returns user when logged in", async () => {
605 setCurrentUser(BOB);
606 const user = await queries.loadCurrentUser();
607 expect(user).not.toBeNull();
608 expect(user?.did).toBe(BOB.did);
609 expect(user?.handle).toBe(BOB.handle);
610 });
611
612 it("loadCurrentUser returns null when not logged in", async () => {
613 const user = await queries.loadCurrentUser();
614 expect(user).toBeNull();
615 });
616});
617
618describe("Walks ordering", () => {
619 it("user's walks ordered by createdAt descending", async () => {
620 const createTrailAndWalk = async (title: string, createdAt: string) => {
621 const trail = await emit("app.sidetrail.trail", ALICE.did, {
622 $type: "app.sidetrail.trail",
623 title,
624 description: "Test",
625 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
626 accentColor: "#111",
627 backgroundColor: "#eee",
628 createdAt: now(),
629 });
630
631 await emit("app.sidetrail.walk", BOB.did, {
632 $type: "app.sidetrail.walk",
633 trail: { uri: trail.uri, cid: trail.cid },
634 visitedStops: [],
635 createdAt,
636 updatedAt: createdAt,
637 });
638 };
639
640 await createTrailAndWalk("Old Walk", "2024-01-01T10:00:00Z");
641 await createTrailAndWalk("New Walk", "2024-06-01T10:00:00Z");
642 await createTrailAndWalk("Middle Walk", "2024-03-01T10:00:00Z");
643
644 setCurrentUser(BOB);
645 const walks = await queries.loadWalks();
646
647 expect(walks[0].title).toBe("New Walk");
648 expect(walks[1].title).toBe("Middle Walk");
649 expect(walks[2].title).toBe("Old Walk");
650 });
651
652 it("walk without updatedAt uses createdAt as fallback", async () => {
653 const trail = await emit("app.sidetrail.trail", ALICE.did, {
654 $type: "app.sidetrail.trail",
655 title: "Fallback Test",
656 description: "Test",
657 stops: [{ tid: "s1", title: "Stop", content: "Content" }],
658 accentColor: "#000",
659 backgroundColor: "#fff",
660 createdAt: now(),
661 });
662
663 // Create walk WITHOUT updatedAt field
664 await emit("app.sidetrail.walk", BOB.did, {
665 $type: "app.sidetrail.walk",
666 trail: { uri: trail.uri, cid: trail.cid },
667 visitedStops: [],
668 createdAt: "2024-05-15T12:00:00Z",
669 // Note: no updatedAt field
670 });
671
672 setCurrentUser(BOB);
673 const walks = await queries.loadWalks();
674
675 expect(walks).toHaveLength(1);
676 // Should fall back to createdAt from the DB record (not the record field)
677 expect(walks[0].updatedAt).toBeInstanceOf(Date);
678 });
679});
680
681/**
682 * Duplicate Walk Handling (Regression Tests)
683 *
684 * ATProto is the source of truth, but users may end up with duplicate walk records
685 * for the same trail (from different clients, race conditions, etc). Our interpretation
686 * is "one walk per trail per user" - we always work with the most recently updated walk.
687 */
688describe("Duplicate walk handling", () => {
689 it("loadWalks deduplicates by trail, returning only most recent walk", async () => {
690 const stop1 = generateTid();
691
692 const trail = await emit("app.sidetrail.trail", ALICE.did, {
693 $type: "app.sidetrail.trail",
694 title: "Trail With Duplicates",
695 description: "Testing deduplication",
696 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
697 accentColor: "#ff0000",
698 backgroundColor: "#ffffff",
699 createdAt: now(),
700 });
701
702 // Bob has TWO walks for the same trail (simulating ATProto duplicates)
703 await emit("app.sidetrail.walk", BOB.did, {
704 $type: "app.sidetrail.walk",
705 trail: { uri: trail.uri, cid: trail.cid },
706 visitedStops: [], // Older walk - no progress
707 createdAt: "2024-01-01T10:00:00Z",
708 updatedAt: "2024-01-01T10:00:00Z",
709 });
710
711 await emit("app.sidetrail.walk", BOB.did, {
712 $type: "app.sidetrail.walk",
713 trail: { uri: trail.uri, cid: trail.cid },
714 visitedStops: [stop1], // Newer walk - has progress
715 createdAt: "2024-06-01T10:00:00Z",
716 updatedAt: "2024-06-01T10:00:00Z",
717 });
718
719 setCurrentUser(BOB);
720 const walks = await queries.loadWalks();
721
722 // Should only show ONE walk (the most recent)
723 expect(walks).toHaveLength(1);
724 expect(walks[0].visitedStops).toEqual([stop1]); // The newer walk's progress
725 });
726
727 it("loadUserWalk returns most recently updated walk when duplicates exist", async () => {
728 const stop1 = generateTid();
729
730 const trail = await emit("app.sidetrail.trail", ALICE.did, {
731 $type: "app.sidetrail.trail",
732 title: "Duplicate Walk Test",
733 description: "Testing yourWalk dedup",
734 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
735 accentColor: "#00ff00",
736 backgroundColor: "#ffffff",
737 createdAt: now(),
738 });
739
740 // Bob has two walks - older one has more progress but is stale
741 await emit("app.sidetrail.walk", BOB.did, {
742 $type: "app.sidetrail.walk",
743 trail: { uri: trail.uri, cid: trail.cid },
744 visitedStops: [stop1], // Has progress
745 createdAt: "2024-01-01T10:00:00Z",
746 updatedAt: "2024-01-01T10:00:00Z",
747 });
748
749 await emit("app.sidetrail.walk", BOB.did, {
750 $type: "app.sidetrail.walk",
751 trail: { uri: trail.uri, cid: trail.cid },
752 visitedStops: [], // No progress but more recent
753 createdAt: "2024-06-01T10:00:00Z",
754 updatedAt: "2024-06-01T10:00:00Z",
755 });
756
757 setCurrentUser(BOB);
758 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
759
760 // Should return the MORE RECENT walk (even though it has less progress)
761 expect(detail.yourWalk).not.toBeNull();
762 expect(detail.yourWalk?.visitedStops).toEqual([]); // Newer walk's state
763 });
764
765 it("loadTrailActiveWalkers shows each user only once even with duplicate walks", async () => {
766 const stop1 = generateTid();
767
768 const trail = await emit("app.sidetrail.trail", ALICE.did, {
769 $type: "app.sidetrail.trail",
770 title: "Active Walkers Dedup",
771 description: "Testing walker dedup",
772 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
773 accentColor: "#0000ff",
774 backgroundColor: "#ffffff",
775 createdAt: now(),
776 });
777
778 // Bob has THREE duplicate walks
779 await emit("app.sidetrail.walk", BOB.did, {
780 $type: "app.sidetrail.walk",
781 trail: { uri: trail.uri, cid: trail.cid },
782 visitedStops: [],
783 createdAt: "2024-01-01T10:00:00Z",
784 updatedAt: "2024-01-01T10:00:00Z",
785 });
786
787 await emit("app.sidetrail.walk", BOB.did, {
788 $type: "app.sidetrail.walk",
789 trail: { uri: trail.uri, cid: trail.cid },
790 visitedStops: [stop1],
791 createdAt: "2024-03-01T10:00:00Z",
792 updatedAt: "2024-03-01T10:00:00Z",
793 });
794
795 await emit("app.sidetrail.walk", BOB.did, {
796 $type: "app.sidetrail.walk",
797 trail: { uri: trail.uri, cid: trail.cid },
798 visitedStops: [],
799 createdAt: "2024-06-01T10:00:00Z",
800 updatedAt: "2024-06-01T10:00:00Z",
801 });
802
803 // Carol has a normal single walk
804 await emit("app.sidetrail.walk", CAROL.did, {
805 $type: "app.sidetrail.walk",
806 trail: { uri: trail.uri, cid: trail.cid },
807 visitedStops: [stop1],
808 createdAt: "2024-05-01T10:00:00Z",
809 updatedAt: "2024-05-01T10:00:00Z",
810 });
811
812 const activeWalkers = await queries.loadTrailActiveWalkers(trail.uri);
813
814 // Bob should appear only ONCE (not 3 times)
815 expect(activeWalkers).toHaveLength(2);
816 const handles = activeWalkers.map((u) => u.handle);
817 expect(handles).toContain(BOB.handle);
818 expect(handles).toContain(CAROL.handle);
819 });
820
821 it("walkers list shows each user only once with most recent activity", async () => {
822 const stop1 = generateTid();
823
824 const trail = await emit("app.sidetrail.trail", ALICE.did, {
825 $type: "app.sidetrail.trail",
826 title: "Walkers List Dedup",
827 description: "Testing walkers dedup",
828 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
829 accentColor: "#ff00ff",
830 backgroundColor: "#ffffff",
831 createdAt: now(),
832 });
833
834 // Bob has duplicate walks
835 await emit("app.sidetrail.walk", BOB.did, {
836 $type: "app.sidetrail.walk",
837 trail: { uri: trail.uri, cid: trail.cid },
838 visitedStops: [],
839 createdAt: "2024-01-01T10:00:00Z",
840 updatedAt: "2024-01-01T10:00:00Z",
841 });
842
843 await emit("app.sidetrail.walk", BOB.did, {
844 $type: "app.sidetrail.walk",
845 trail: { uri: trail.uri, cid: trail.cid },
846 visitedStops: [stop1],
847 createdAt: "2024-06-01T10:00:00Z",
848 updatedAt: "2024-06-01T10:00:00Z",
849 });
850
851 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
852 const walkers = await detail.walkers;
853
854 // Bob should appear only once
855 expect(walkers).toHaveLength(1);
856 expect(walkers[0].user.handle).toBe(BOB.handle);
857 });
858
859 it("walkersAtStop shows user at only their most recent walk's position", async () => {
860 const stop1 = generateTid();
861 const stop2 = generateTid();
862
863 const trail = await emit("app.sidetrail.trail", ALICE.did, {
864 $type: "app.sidetrail.trail",
865 title: "Stop Position Dedup",
866 description: "Testing stop position",
867 stops: [
868 { tid: stop1, title: "First", content: "1" },
869 { tid: stop2, title: "Second", content: "2" },
870 ],
871 accentColor: "#123456",
872 backgroundColor: "#ffffff",
873 createdAt: now(),
874 });
875
876 // Bob's older walk is at stop2
877 await emit("app.sidetrail.walk", BOB.did, {
878 $type: "app.sidetrail.walk",
879 trail: { uri: trail.uri, cid: trail.cid },
880 visitedStops: [stop1, stop2],
881 createdAt: "2024-01-01T10:00:00Z",
882 updatedAt: "2024-01-01T10:00:00Z",
883 });
884
885 // Bob's newer walk is at stop1
886 await emit("app.sidetrail.walk", BOB.did, {
887 $type: "app.sidetrail.walk",
888 trail: { uri: trail.uri, cid: trail.cid },
889 visitedStops: [stop1],
890 createdAt: "2024-06-01T10:00:00Z",
891 updatedAt: "2024-06-01T10:00:00Z",
892 });
893
894 setCurrentUser(CAROL); // Not Bob, so we can see Bob as walker
895 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
896
897 const atStop1 = await detail.stops[0].walkersHere;
898 const atStop2 = await detail.stops[1].walkersHere;
899
900 // Bob should ONLY be at stop1 (his most recent walk's position)
901 expect(atStop1.map((u) => u.handle)).toContain(BOB.handle);
902 expect(atStop2.map((u) => u.handle)).not.toContain(BOB.handle);
903 });
904
905 it("loadWalkingBadges deduplicates by trail", async () => {
906 const trail = await emit("app.sidetrail.trail", ALICE.did, {
907 $type: "app.sidetrail.trail",
908 title: "Badge Dedup Trail",
909 description: "Testing badge dedup",
910 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
911 accentColor: "#badge1",
912 backgroundColor: "#ffffff",
913 createdAt: now(),
914 });
915
916 // Bob has duplicate walks for the same trail
917 await emit("app.sidetrail.walk", BOB.did, {
918 $type: "app.sidetrail.walk",
919 trail: { uri: trail.uri, cid: trail.cid },
920 visitedStops: [],
921 createdAt: "2024-01-01T10:00:00Z",
922 updatedAt: "2024-01-01T10:00:00Z",
923 });
924
925 await emit("app.sidetrail.walk", BOB.did, {
926 $type: "app.sidetrail.walk",
927 trail: { uri: trail.uri, cid: trail.cid },
928 visitedStops: [],
929 createdAt: "2024-06-01T10:00:00Z",
930 updatedAt: "2024-06-01T10:00:00Z",
931 });
932
933 setCurrentUser(BOB);
934 const badges = await queries.loadWalkingBadges();
935
936 // Should show only ONE badge for this trail
937 expect(badges).toHaveLength(1);
938 });
939});