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