an app to share curated trails
sidetrail.app
1/**
2 * Action Tests: Walking
3 *
4 * Tests the core walk actions (startWalk, visitStop, completeTrail) and
5 * automatically verifies eventual consistency between optimistic writes
6 * and ingester behavior.
7 *
8 * These tests use a special setup (actions-setup.ts) that:
9 * 1. Allows actions to run (not mocked out)
10 * 2. Captures PDS operations scheduled via after()
11 * 3. Verifies optimistic writes match what ingester would produce
12 */
13
14import { describe, it, expect } from "vitest";
15import { emit, ALICE, BOB, setCurrentUser, generateTid, captureInitialState } from "./helpers";
16import {
17 startWalk,
18 visitStop,
19 completeTrail,
20 abandonWalk,
21 deleteCompletion,
22 forgetTrail,
23 publishDraft,
24 deleteTrail,
25} from "../actions";
26import * as queries from "../queries";
27
28const now = () => new Date().toISOString();
29
30describe("Action: startWalk", () => {
31 it("creates walk record with first stop visited", async () => {
32 const stop1 = generateTid();
33 const stop2 = generateTid();
34
35 // Setup: create trail via ingester
36 const trail = await emit("app.sidetrail.trail", ALICE.did, {
37 $type: "app.sidetrail.trail",
38 title: "Test Trail",
39 description: "For testing startWalk",
40 stops: [
41 { tid: stop1, title: "First Stop", content: "Content 1" },
42 { tid: stop2, title: "Second Stop", content: "Content 2" },
43 ],
44 accentColor: "#ff0000",
45 backgroundColor: "#ffffff",
46 createdAt: now(),
47 });
48
49 // Capture state before action
50 await captureInitialState();
51
52 // Act: Bob starts walking
53 setCurrentUser(BOB);
54 await startWalk(trail.uri, trail.cid);
55
56 // Assert: Bob's walks list shows the trail
57 const walks = await queries.loadWalks();
58 expect(walks).toHaveLength(1);
59 expect(walks[0].title).toBe("Test Trail");
60 expect(walks[0].visitedStops).toEqual([stop1]);
61
62 // Eventual consistency is automatically verified in afterEach
63 });
64
65 it("fails if trail has no stops", async () => {
66 const trail = await emit("app.sidetrail.trail", ALICE.did, {
67 $type: "app.sidetrail.trail",
68 title: "Empty Trail",
69 description: "No stops",
70 stops: [],
71 accentColor: "#ff0000",
72 backgroundColor: "#ffffff",
73 createdAt: now(),
74 });
75
76 await captureInitialState();
77 setCurrentUser(BOB);
78
79 await expect(startWalk(trail.uri, trail.cid)).rejects.toThrow("Trail has no stops");
80 });
81});
82
83describe("Action: visitStop", () => {
84 it("adds stop to visited list", async () => {
85 const stop1 = generateTid();
86 const stop2 = generateTid();
87 const stop3 = generateTid();
88
89 const trail = await emit("app.sidetrail.trail", ALICE.did, {
90 $type: "app.sidetrail.trail",
91 title: "Multi-stop Trail",
92 description: "Progress through stops",
93 stops: [
94 { tid: stop1, title: "First", content: "1" },
95 { tid: stop2, title: "Second", content: "2" },
96 { tid: stop3, title: "Third", content: "3" },
97 ],
98 accentColor: "#00ff00",
99 backgroundColor: "#ffffff",
100 createdAt: now(),
101 });
102
103 // Bob starts walking (creates walk with stop1 visited)
104 const walk = await emit("app.sidetrail.walk", BOB.did, {
105 $type: "app.sidetrail.walk",
106 trail: { uri: trail.uri, cid: trail.cid },
107 visitedStops: [stop1],
108 createdAt: now(),
109 updatedAt: now(),
110 });
111
112 await captureInitialState();
113 setCurrentUser(BOB);
114
115 // Act: visit stop2
116 await visitStop(walk.uri, stop2);
117
118 // Assert: both stops are now visited
119 const walks = await queries.loadWalks();
120 expect(walks[0].visitedStops).toEqual([stop1, stop2]);
121
122 // Eventual consistency is automatically verified in afterEach
123 });
124
125 it("revisiting a stop moves it to the end of visitedStops", async () => {
126 const stop1 = generateTid();
127 const stop2 = generateTid();
128 const stop3 = generateTid();
129
130 const trail = await emit("app.sidetrail.trail", ALICE.did, {
131 $type: "app.sidetrail.trail",
132 title: "Multi-stop Trail",
133 description: "Test going back",
134 stops: [
135 { tid: stop1, title: "First", content: "1" },
136 { tid: stop2, title: "Second", content: "2" },
137 { tid: stop3, title: "Third", content: "3" },
138 ],
139 accentColor: "#0000ff",
140 backgroundColor: "#ffffff",
141 createdAt: now(),
142 });
143
144 // Bob has visited all 3 stops
145 const walk = await emit("app.sidetrail.walk", BOB.did, {
146 $type: "app.sidetrail.walk",
147 trail: { uri: trail.uri, cid: trail.cid },
148 visitedStops: [stop1, stop2, stop3],
149 createdAt: now(),
150 updatedAt: now(),
151 });
152
153 await captureInitialState();
154 setCurrentUser(BOB);
155
156 // Go back to stop2 - should move it to the end
157 await visitStop(walk.uri, stop2);
158
159 const walks = await queries.loadWalks();
160 expect(walks[0].visitedStops).toEqual([stop1, stop3, stop2]);
161
162 // Eventual consistency is automatically verified in afterEach
163 });
164});
165
166describe("Action: completeTrail", () => {
167 it("creates completion and removes walk", async () => {
168 const stop1 = generateTid();
169
170 const trail = await emit("app.sidetrail.trail", ALICE.did, {
171 $type: "app.sidetrail.trail",
172 title: "Completable Trail",
173 description: "Can be finished",
174 stops: [{ tid: stop1, title: "Final Stop", content: "Done!" }],
175 accentColor: "#ffd700",
176 backgroundColor: "#ffffff",
177 createdAt: now(),
178 });
179
180 const walk = await emit("app.sidetrail.walk", BOB.did, {
181 $type: "app.sidetrail.walk",
182 trail: { uri: trail.uri, cid: trail.cid },
183 visitedStops: [stop1],
184 createdAt: now(),
185 updatedAt: now(),
186 });
187
188 await captureInitialState();
189 setCurrentUser(BOB);
190
191 // Act: complete the trail
192 await completeTrail(walk.uri);
193
194 // Assert: walk is gone, completion exists
195 const walks = await queries.loadWalks();
196 expect(walks).toHaveLength(0);
197
198 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
199 expect(completedTrails).toHaveLength(1);
200 expect(completedTrails[0].title).toBe("Completable Trail");
201
202 // Eventual consistency is automatically verified in afterEach
203 });
204});
205
206describe("Action: abandonWalk", () => {
207 it("removes walk from user's list", async () => {
208 const stop1 = generateTid();
209
210 const trail = await emit("app.sidetrail.trail", ALICE.did, {
211 $type: "app.sidetrail.trail",
212 title: "Abandonable Trail",
213 description: "User might give up",
214 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
215 accentColor: "#ff6b6b",
216 backgroundColor: "#ffffff",
217 createdAt: now(),
218 });
219
220 const walk = await emit("app.sidetrail.walk", BOB.did, {
221 $type: "app.sidetrail.walk",
222 trail: { uri: trail.uri, cid: trail.cid },
223 visitedStops: [stop1],
224 createdAt: now(),
225 updatedAt: now(),
226 });
227
228 await captureInitialState();
229 setCurrentUser(BOB);
230
231 // Verify walk exists
232 let walks = await queries.loadWalks();
233 expect(walks).toHaveLength(1);
234
235 // Act: abandon
236 await abandonWalk(walk.uri);
237
238 // Assert: walk is gone
239 walks = await queries.loadWalks();
240 expect(walks).toHaveLength(0);
241
242 // Eventual consistency is automatically verified in afterEach
243 });
244});
245
246describe("Action: deleteCompletion", () => {
247 it("removes completion from user's list", async () => {
248 const stop1 = generateTid();
249
250 const trail = await emit("app.sidetrail.trail", ALICE.did, {
251 $type: "app.sidetrail.trail",
252 title: "Completed Trail",
253 description: "Already finished",
254 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
255 accentColor: "#00ff00",
256 backgroundColor: "#ffffff",
257 createdAt: now(),
258 });
259
260 const completion = await emit("app.sidetrail.completion", BOB.did, {
261 $type: "app.sidetrail.completion",
262 trail: { uri: trail.uri, cid: trail.cid },
263 createdAt: now(),
264 });
265
266 await captureInitialState();
267 setCurrentUser(BOB);
268
269 // Verify completion exists
270 let completions = await queries.loadUserCompletedTrails(BOB.handle);
271 expect(completions).toHaveLength(1);
272
273 // Act: delete completion
274 await deleteCompletion(completion.uri);
275
276 // Assert: completion is gone
277 completions = await queries.loadUserCompletedTrails(BOB.handle);
278 expect(completions).toHaveLength(0);
279
280 // Eventual consistency is automatically verified in afterEach
281 });
282});
283
284describe("Action: forgetTrail", () => {
285 it("removes all walks and completions for a trail", async () => {
286 const stop1 = generateTid();
287
288 const trail = await emit("app.sidetrail.trail", ALICE.did, {
289 $type: "app.sidetrail.trail",
290 title: "Forgettable Trail",
291 description: "User wants to forget",
292 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
293 accentColor: "#purple",
294 backgroundColor: "#ffffff",
295 createdAt: now(),
296 });
297
298 // Bob has a walk on this trail
299 await emit("app.sidetrail.walk", BOB.did, {
300 $type: "app.sidetrail.walk",
301 trail: { uri: trail.uri, cid: trail.cid },
302 visitedStops: [stop1],
303 createdAt: now(),
304 updatedAt: now(),
305 });
306
307 // Bob also completed this trail before
308 await emit("app.sidetrail.completion", BOB.did, {
309 $type: "app.sidetrail.completion",
310 trail: { uri: trail.uri, cid: trail.cid },
311 createdAt: now(),
312 });
313
314 await captureInitialState();
315 setCurrentUser(BOB);
316
317 // Verify data exists
318 let walks = await queries.loadWalks();
319 let completions = await queries.loadUserCompletedTrails(BOB.handle);
320 expect(walks).toHaveLength(1);
321 expect(completions).toHaveLength(1);
322
323 // Act: forget the trail
324 await forgetTrail(trail.uri);
325
326 // Assert: all data for this trail is gone
327 walks = await queries.loadWalks();
328 completions = await queries.loadUserCompletedTrails(BOB.handle);
329 expect(walks).toHaveLength(0);
330 expect(completions).toHaveLength(0);
331
332 // Eventual consistency is automatically verified in afterEach
333 });
334});
335
336describe("Action: publishDraft", () => {
337 it("creates a new trail from draft data", async () => {
338 await captureInitialState();
339 setCurrentUser(BOB);
340
341 const stop1Tid = generateTid();
342 const stop2Tid = generateTid();
343
344 // Act: publish a draft
345 const result = await publishDraft({
346 title: "My New Trail",
347 description: "A trail I created",
348 stops: [
349 { tid: stop1Tid, title: "First Stop", content: "Welcome!" },
350 { tid: stop2Tid, title: "Second Stop", content: "Keep going!" },
351 ],
352 accentColor: "#ff0000",
353 backgroundColor: "#ffffff",
354 });
355
356 // Assert: trail was created
357 expect(result.success).toBe(true);
358 if (result.success) {
359 expect(result.uri).toContain("app.sidetrail.trail");
360 expect(result.handle).toBe(BOB.handle);
361 }
362
363 // Eventual consistency is automatically verified in afterEach
364 });
365
366 it("validates draft data", async () => {
367 await captureInitialState();
368 setCurrentUser(BOB);
369
370 // Act: try to publish invalid draft
371 const result = await publishDraft({
372 title: "",
373 description: "",
374 stops: [{ tid: generateTid(), title: "Only One", content: "Need two" }],
375 accentColor: "#ff0000",
376 backgroundColor: "#ffffff",
377 });
378
379 // Assert: validation failed
380 expect(result.success).toBe(false);
381 if (!result.success) {
382 expect(result.errors).toContain("trail needs a title");
383 expect(result.errors).toContain("trail needs a description");
384 expect(result.errors).toContain("trails need at least 2 stops to be published");
385 }
386
387 // Eventual consistency is automatically verified in afterEach
388 });
389});
390
391describe("Action: deleteTrail", () => {
392 it("removes trail from database", async () => {
393 const stop1 = generateTid();
394
395 // Bob creates a trail
396 const trail = await emit("app.sidetrail.trail", BOB.did, {
397 $type: "app.sidetrail.trail",
398 title: "Deletable Trail",
399 description: "Will be deleted",
400 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
401 accentColor: "#ff0000",
402 backgroundColor: "#ffffff",
403 createdAt: now(),
404 });
405
406 await captureInitialState();
407 setCurrentUser(BOB);
408
409 // Act: delete the trail
410 await deleteTrail(trail.uri);
411
412 // Assert: trail is gone
413 await expect(queries.loadTrailDetail(BOB.handle, trail.rkey)).rejects.toThrow(
414 "Trail not found",
415 );
416
417 // Eventual consistency is automatically verified in afterEach
418 });
419});
420
421/**
422 * "Walked Here" List (Trail Walkers)
423 *
424 * Tests that the walkers list at the bottom of trail overview correctly
425 * includes both walks AND completions, ensuring users don't disappear
426 * after completing/abandoning a second attempt.
427 */
428describe("Walked here list (walkers)", () => {
429 it("includes user with only a completion (no walk)", async () => {
430 const stop1 = generateTid();
431
432 const trail = await emit("app.sidetrail.trail", ALICE.did, {
433 $type: "app.sidetrail.trail",
434 title: "Completed Trail",
435 description: "Bob completed this",
436 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
437 accentColor: "#00ff00",
438 backgroundColor: "#ffffff",
439 createdAt: now(),
440 });
441
442 // Bob has a completion but NO walk (walked and completed previously)
443 await emit("app.sidetrail.completion", BOB.did, {
444 $type: "app.sidetrail.completion",
445 trail: { uri: trail.uri, cid: trail.cid },
446 createdAt: now(),
447 });
448
449 await captureInitialState();
450 setCurrentUser(ALICE);
451
452 // Assert: Bob should appear in walkers list
453 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
454 const walkers = await detail.walkers;
455
456 expect(walkers.map((w) => w.user.handle)).toContain(BOB.handle);
457 });
458
459 it("shows walk when user has both walk and completion", async () => {
460 const stop1 = generateTid();
461
462 const trail = await emit("app.sidetrail.trail", ALICE.did, {
463 $type: "app.sidetrail.trail",
464 title: "Trail with Both",
465 description: "Bob has walk and completion",
466 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
467 accentColor: "#0000ff",
468 backgroundColor: "#ffffff",
469 createdAt: now(),
470 });
471
472 // Bob completed the trail a while ago
473 await emit("app.sidetrail.completion", BOB.did, {
474 $type: "app.sidetrail.completion",
475 trail: { uri: trail.uri, cid: trail.cid },
476 createdAt: "2024-01-01T10:00:00Z",
477 });
478
479 // Bob started walking again (more recent)
480 await emit("app.sidetrail.walk", BOB.did, {
481 $type: "app.sidetrail.walk",
482 trail: { uri: trail.uri, cid: trail.cid },
483 visitedStops: [stop1],
484 createdAt: "2024-06-01T10:00:00Z",
485 updatedAt: "2024-06-01T10:00:00Z",
486 });
487
488 await captureInitialState();
489 setCurrentUser(ALICE);
490
491 // Assert: Bob should appear exactly once in walkers list
492 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
493 const walkers = await detail.walkers;
494
495 const bobEntries = walkers.filter((w) => w.user.handle === BOB.handle);
496 expect(bobEntries).toHaveLength(1);
497 });
498
499 it("preserves user in walkers after abandoning second walk attempt (has completion)", async () => {
500 const stop1 = generateTid();
501
502 const trail = await emit("app.sidetrail.trail", ALICE.did, {
503 $type: "app.sidetrail.trail",
504 title: "Second Attempt Trail",
505 description: "Bob will abandon second attempt",
506 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
507 accentColor: "#ff6b6b",
508 backgroundColor: "#ffffff",
509 createdAt: now(),
510 });
511
512 // Bob completed the trail previously
513 await emit("app.sidetrail.completion", BOB.did, {
514 $type: "app.sidetrail.completion",
515 trail: { uri: trail.uri, cid: trail.cid },
516 createdAt: "2024-01-01T10:00:00Z",
517 });
518
519 // Bob started a second walk attempt
520 const walk = await emit("app.sidetrail.walk", BOB.did, {
521 $type: "app.sidetrail.walk",
522 trail: { uri: trail.uri, cid: trail.cid },
523 visitedStops: [stop1],
524 createdAt: "2024-06-01T10:00:00Z",
525 updatedAt: "2024-06-01T10:00:00Z",
526 });
527
528 await captureInitialState();
529 setCurrentUser(BOB);
530
531 // Bob abandons the second walk
532 await abandonWalk(walk.uri);
533
534 // Assert: Bob should STILL appear in walkers list (via completion)
535 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
536 const walkers = await detail.walkers;
537
538 expect(walkers.map((w) => w.user.handle)).toContain(BOB.handle);
539 });
540
541 it("preserves user in walkers after completing second walk attempt", async () => {
542 const stop1 = generateTid();
543
544 const trail = await emit("app.sidetrail.trail", ALICE.did, {
545 $type: "app.sidetrail.trail",
546 title: "Re-complete Trail",
547 description: "Bob will complete again",
548 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
549 accentColor: "#ffd700",
550 backgroundColor: "#ffffff",
551 createdAt: now(),
552 });
553
554 // Bob completed the trail previously
555 await emit("app.sidetrail.completion", BOB.did, {
556 $type: "app.sidetrail.completion",
557 trail: { uri: trail.uri, cid: trail.cid },
558 createdAt: "2024-01-01T10:00:00Z",
559 });
560
561 // Bob started a second walk attempt
562 const walk = await emit("app.sidetrail.walk", BOB.did, {
563 $type: "app.sidetrail.walk",
564 trail: { uri: trail.uri, cid: trail.cid },
565 visitedStops: [stop1],
566 createdAt: "2024-06-01T10:00:00Z",
567 updatedAt: "2024-06-01T10:00:00Z",
568 });
569
570 await captureInitialState();
571 setCurrentUser(BOB);
572
573 // Bob completes the trail again
574 await completeTrail(walk.uri);
575
576 // Assert: Bob should still appear (now with 2 completions)
577 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
578 const walkers = await detail.walkers;
579
580 expect(walkers.map((w) => w.user.handle)).toContain(BOB.handle);
581 });
582
583 it("user disappears only after forgetTrail (removes both walks and completions)", async () => {
584 const stop1 = generateTid();
585
586 const trail = await emit("app.sidetrail.trail", ALICE.did, {
587 $type: "app.sidetrail.trail",
588 title: "Forgettable Trail",
589 description: "Bob will forget everything",
590 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
591 accentColor: "#purple",
592 backgroundColor: "#ffffff",
593 createdAt: now(),
594 });
595
596 // Bob has a completion
597 await emit("app.sidetrail.completion", BOB.did, {
598 $type: "app.sidetrail.completion",
599 trail: { uri: trail.uri, cid: trail.cid },
600 createdAt: now(),
601 });
602
603 await captureInitialState();
604 setCurrentUser(BOB);
605
606 // Verify Bob is in walkers
607 let detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
608 let walkers = await detail.walkers;
609 expect(walkers.map((w) => w.user.handle)).toContain(BOB.handle);
610
611 // Bob forgets the trail entirely
612 await forgetTrail(trail.uri);
613
614 // Assert: Bob should no longer appear
615 detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
616 walkers = await detail.walkers;
617 expect(walkers.map((w) => w.user.handle)).not.toContain(BOB.handle);
618 });
619});
620
621/**
622 * Duplicate Walk Handling (Regression Tests)
623 *
624 * These tests verify that actions correctly handle duplicate walks
625 * that may exist due to ATProto sync issues, race conditions, etc.
626 * Our interpretation is "one walk per trail per user".
627 */
628describe("Duplicate walk handling in actions", () => {
629 it("startWalk does not create duplicate if walk already exists", async () => {
630 const stop1 = generateTid();
631
632 const trail = await emit("app.sidetrail.trail", ALICE.did, {
633 $type: "app.sidetrail.trail",
634 title: "No Duplicate Test",
635 description: "Testing startWalk idempotency",
636 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
637 accentColor: "#ff0000",
638 backgroundColor: "#ffffff",
639 createdAt: now(),
640 });
641
642 // Bob already has a walk (created via ingester)
643 await emit("app.sidetrail.walk", BOB.did, {
644 $type: "app.sidetrail.walk",
645 trail: { uri: trail.uri, cid: trail.cid },
646 visitedStops: [stop1],
647 createdAt: now(),
648 updatedAt: now(),
649 });
650
651 await captureInitialState();
652 setCurrentUser(BOB);
653
654 // Verify walk exists
655 let walks = await queries.loadWalks();
656 expect(walks).toHaveLength(1);
657
658 // Act: try to start walk again
659 await startWalk(trail.uri, trail.cid);
660
661 // Assert: still only one walk (no duplicate created)
662 walks = await queries.loadWalks();
663 expect(walks).toHaveLength(1);
664 });
665
666 it("completeTrail deletes ALL duplicate walks", async () => {
667 const stop1 = generateTid();
668
669 const trail = await emit("app.sidetrail.trail", ALICE.did, {
670 $type: "app.sidetrail.trail",
671 title: "Complete With Duplicates",
672 description: "Testing complete cleans up duplicates",
673 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
674 accentColor: "#00ff00",
675 backgroundColor: "#ffffff",
676 createdAt: now(),
677 });
678
679 // Bob has THREE duplicate walks (simulating ATProto sync issues)
680 await emit("app.sidetrail.walk", BOB.did, {
681 $type: "app.sidetrail.walk",
682 trail: { uri: trail.uri, cid: trail.cid },
683 visitedStops: [],
684 createdAt: "2024-01-01T10:00:00Z",
685 updatedAt: "2024-01-01T10:00:00Z",
686 });
687
688 await emit("app.sidetrail.walk", BOB.did, {
689 $type: "app.sidetrail.walk",
690 trail: { uri: trail.uri, cid: trail.cid },
691 visitedStops: [stop1],
692 createdAt: "2024-03-01T10:00:00Z",
693 updatedAt: "2024-03-01T10:00:00Z",
694 });
695
696 const walk3 = await emit("app.sidetrail.walk", BOB.did, {
697 $type: "app.sidetrail.walk",
698 trail: { uri: trail.uri, cid: trail.cid },
699 visitedStops: [stop1],
700 createdAt: "2024-06-01T10:00:00Z",
701 updatedAt: "2024-06-01T10:00:00Z",
702 });
703
704 await captureInitialState();
705 setCurrentUser(BOB);
706
707 // loadWalks shows 1 (deduped), but there are 3 in DB
708 const walks = await queries.loadWalks();
709 expect(walks).toHaveLength(1);
710
711 // Act: complete the trail (using most recent walk)
712 await completeTrail(walk3.uri);
713
714 // Assert: ALL walks are gone, completion exists
715 const walksAfter = await queries.loadWalks();
716 expect(walksAfter).toHaveLength(0);
717
718 const completions = await queries.loadUserCompletedTrails(BOB.handle);
719 expect(completions).toHaveLength(1);
720
721 // Trail detail should show no walkers (all duplicates cleaned up)
722 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
723 const walkers = await detail.walkers;
724 // Only the completion should show, not any walks
725 const walkingWalkers = walkers.filter((w) => !w.isYou);
726 expect(walkingWalkers.every((w) => w.user.handle !== BOB.handle)).toBe(true);
727 });
728
729 it("abandonWalk deletes ALL duplicate walks", async () => {
730 const stop1 = generateTid();
731
732 const trail = await emit("app.sidetrail.trail", ALICE.did, {
733 $type: "app.sidetrail.trail",
734 title: "Abandon With Duplicates",
735 description: "Testing abandon cleans up duplicates",
736 stops: [{ tid: stop1, title: "Stop", content: "Content" }],
737 accentColor: "#0000ff",
738 backgroundColor: "#ffffff",
739 createdAt: now(),
740 });
741
742 // Bob has TWO duplicate walks
743 await emit("app.sidetrail.walk", BOB.did, {
744 $type: "app.sidetrail.walk",
745 trail: { uri: trail.uri, cid: trail.cid },
746 visitedStops: [],
747 createdAt: "2024-01-01T10:00:00Z",
748 updatedAt: "2024-01-01T10:00:00Z",
749 });
750
751 const walk2 = await emit("app.sidetrail.walk", BOB.did, {
752 $type: "app.sidetrail.walk",
753 trail: { uri: trail.uri, cid: trail.cid },
754 visitedStops: [stop1],
755 createdAt: "2024-06-01T10:00:00Z",
756 updatedAt: "2024-06-01T10:00:00Z",
757 });
758
759 await captureInitialState();
760 setCurrentUser(BOB);
761
762 // Verify walks exist (shows 1 due to dedup)
763 let walks = await queries.loadWalks();
764 expect(walks).toHaveLength(1);
765
766 // Act: abandon the walk (using most recent)
767 await abandonWalk(walk2.uri);
768
769 // Assert: ALL walks are gone
770 walks = await queries.loadWalks();
771 expect(walks).toHaveLength(0);
772
773 // Trail should show no Bob
774 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey);
775 const walkers = await detail.walkers;
776 expect(walkers.map((w) => w.user.handle)).not.toContain(BOB.handle);
777 });
778});