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