an app to share curated trails sidetrail.app
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 33 kB View raw
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});