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