WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
fork

Configure Feed

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

at fix/backfill-theme-collections 781 lines 25 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2import { createMembershipForUser } from "../membership.js"; 3import { createTestContext, type TestContext } from "./test-context.js"; 4import { memberships, users, roles, rolePermissions } from "@atbb/db"; 5import { eq, and } from "drizzle-orm"; 6 7describe("createMembershipForUser", () => { 8 let ctx: TestContext; 9 10 beforeEach(async () => { 11 ctx = await createTestContext(); 12 }); 13 14 afterEach(async () => { 15 await ctx.cleanup(); 16 }); 17 18 it("returns early when membership already exists", async () => { 19 const mockAgent = { 20 com: { 21 atproto: { 22 repo: { 23 putRecord: vi.fn().mockResolvedValue({ 24 data: { 25 uri: "at://did:plc:test-user/space.atbb.membership/test", 26 cid: "bafytest123", 27 }, 28 }), 29 }, 30 }, 31 }, 32 } as any; 33 34 // Insert user first (FK constraint) 35 await ctx.db.insert(users).values({ 36 did: "did:plc:test-user", 37 handle: "test.user", 38 indexedAt: new Date(), 39 }); 40 41 // Insert existing membership into test database 42 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 43 await ctx.db.insert(memberships).values({ 44 did: "did:plc:test-user", 45 rkey: "existing", 46 cid: "bafytest", 47 forumUri, 48 joinedAt: new Date(), 49 createdAt: new Date(), 50 indexedAt: new Date(), 51 }); 52 53 const result = await createMembershipForUser( 54 ctx, 55 mockAgent, 56 "did:plc:test-user" 57 ); 58 59 expect(result.created).toBe(false); 60 expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); 61 }); 62 63 it("throws 'Forum not found' when only a different forum DID exists (multi-tenant isolation)", async () => { 64 // Regression test for ATB-29 fix: membership.ts must scope the forum lookup 65 // to ctx.config.forumDid. Without eq(forums.did, forumDid), this would find 66 // the wrong forum and create a membership pointing to the wrong forum. 67 // 68 // The existing ctx has did:plc:test-forum in the DB. We create an isolationCtx 69 // that points to a different forumDid — if the code is broken (no forumDid filter), 70 // it would find did:plc:test-forum instead of throwing "Forum not found". 71 // 72 // Using ctx spread (not createTestContext) avoids calling cleanDatabase(), which 73 // would race with concurrently-running tests that also depend on did:plc:test-forum. 74 const isolationCtx = { 75 ...ctx, 76 config: { ...ctx.config, forumDid: `did:plc:isolation-${Date.now()}` }, 77 }; 78 79 const mockAgent = { 80 com: { atproto: { repo: { putRecord: vi.fn() } } }, 81 } as any; 82 83 await expect( 84 createMembershipForUser(isolationCtx, mockAgent, "did:plc:test-user") 85 ).rejects.toThrow("Forum not found"); 86 87 expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); 88 }); 89 90 it("throws when forum metadata not found", async () => { 91 // emptyDb: true skips forum insertion; cleanDatabase() removes any stale 92 // test forum. membership.ts queries by forumDid so stale real-forum rows 93 // with different DIDs won't interfere. 94 const emptyCtx = await createTestContext({ emptyDb: true }); 95 96 const mockAgent = { 97 com: { 98 atproto: { 99 repo: { 100 putRecord: vi.fn(), 101 }, 102 }, 103 }, 104 } as any; 105 106 await expect( 107 createMembershipForUser(emptyCtx, mockAgent, "did:plc:test123") 108 ).rejects.toThrow("Forum not found"); 109 110 // Clean up the empty context 111 await emptyCtx.cleanup(); 112 }); 113 114 it("creates membership record when none exists", async () => { 115 const mockAgent = { 116 com: { 117 atproto: { 118 repo: { 119 putRecord: vi.fn().mockResolvedValue({ 120 data: { 121 uri: "at://did:plc:create-test/space.atbb.membership/tid123", 122 cid: "bafynew123", 123 }, 124 }), 125 }, 126 }, 127 }, 128 } as any; 129 130 const result = await createMembershipForUser( 131 ctx, 132 mockAgent, 133 "did:plc:create-test" 134 ); 135 136 expect(result.created).toBe(true); 137 expect(result.uri).toBe("at://did:plc:create-test/space.atbb.membership/tid123"); 138 expect(result.cid).toBe("bafynew123"); 139 140 // Verify putRecord was called with correct lexicon structure 141 expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 142 expect.objectContaining({ 143 repo: "did:plc:create-test", 144 collection: "space.atbb.membership", 145 rkey: expect.stringMatching(/^[a-z0-9]+$/), // TID format 146 record: expect.objectContaining({ 147 $type: "space.atbb.membership", 148 forum: { 149 forum: { 150 uri: expect.stringContaining("space.atbb.forum.forum/self"), 151 cid: expect.any(String), 152 }, 153 }, 154 createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), // ISO timestamp 155 joinedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), 156 }), 157 }) 158 ); 159 }); 160 161 it("throws when PDS write fails", async () => { 162 const mockAgent = { 163 com: { 164 atproto: { 165 repo: { 166 putRecord: vi.fn().mockRejectedValue(new Error("Network timeout")), 167 }, 168 }, 169 }, 170 } as any; 171 172 await expect( 173 createMembershipForUser(ctx, mockAgent, "did:plc:pds-fail-test") 174 ).rejects.toThrow("Network timeout"); 175 }); 176 177 it("checks for duplicates using DID + forumUri", async () => { 178 const mockAgent = { 179 com: { 180 atproto: { 181 repo: { 182 putRecord: vi.fn().mockResolvedValue({ 183 data: { 184 uri: "at://did:plc:duptest/space.atbb.membership/test", 185 cid: "bafydup123", 186 }, 187 }), 188 }, 189 }, 190 }, 191 } as any; 192 193 const testDid = `did:plc:duptest-${Date.now()}`; 194 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 195 196 // Insert user first (FK constraint) 197 await ctx.db.insert(users).values({ 198 did: testDid, 199 handle: "dupcheck.user", 200 indexedAt: new Date(), 201 }); 202 203 // Insert membership for same user in this forum 204 await ctx.db.insert(memberships).values({ 205 did: testDid, 206 rkey: "existing1", 207 cid: "bafytest1", 208 forumUri, 209 joinedAt: new Date(), 210 createdAt: new Date(), 211 indexedAt: new Date(), 212 }); 213 214 // Should return early (duplicate in same forum) 215 const result1 = await createMembershipForUser( 216 ctx, 217 mockAgent, 218 testDid 219 ); 220 expect(result1.created).toBe(false); 221 222 // Insert membership for same user in DIFFERENT forum 223 await ctx.db.insert(memberships).values({ 224 did: testDid, 225 rkey: "existing2", 226 cid: "bafytest2", 227 forumUri: "at://did:plc:other/space.atbb.forum.forum/self", 228 joinedAt: new Date(), 229 createdAt: new Date(), 230 indexedAt: new Date(), 231 }); 232 233 // Should still return early (already has membership in THIS forum) 234 const result2 = await createMembershipForUser( 235 ctx, 236 mockAgent, 237 testDid 238 ); 239 expect(result2.created).toBe(false); 240 }); 241 242 it("includes Member role in new membership PDS record when Member role exists in DB", async () => { 243 const memberRoleRkey = "memberrole123"; 244 const memberRoleCid = "bafymemberrole456"; 245 246 const [memberRole] = await ctx.db.insert(roles).values({ 247 did: ctx.config.forumDid, 248 rkey: memberRoleRkey, 249 cid: memberRoleCid, 250 name: "Member", 251 description: "Regular forum member", 252 priority: 30, 253 createdAt: new Date(), 254 indexedAt: new Date(), 255 }).returning({ id: roles.id }); 256 await ctx.db.insert(rolePermissions).values([ 257 { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 258 { roleId: memberRole.id, permission: "space.atbb.permission.createPosts" }, 259 ]); 260 261 const mockAgent = { 262 com: { 263 atproto: { 264 repo: { 265 putRecord: vi.fn().mockResolvedValue({ 266 data: { 267 uri: "at://did:plc:test-new-member/space.atbb.membership/tid789", 268 cid: "bafynewmember", 269 }, 270 }), 271 }, 272 }, 273 }, 274 } as any; 275 276 await createMembershipForUser(ctx, mockAgent, "did:plc:test-new-member"); 277 278 expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 279 expect.objectContaining({ 280 record: expect.objectContaining({ 281 role: { 282 role: { 283 uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${memberRoleRkey}`, 284 cid: memberRoleCid, 285 }, 286 }, 287 }), 288 }) 289 ); 290 }); 291 292 it("logs error and creates membership without role when Member role not found in DB", async () => { 293 // No roles seeded — Member role absent 294 const errorSpy = vi.spyOn(ctx.logger, "error"); 295 296 const mockAgent = { 297 com: { 298 atproto: { 299 repo: { 300 putRecord: vi.fn().mockResolvedValue({ 301 data: { 302 uri: "at://did:plc:test-no-role/space.atbb.membership/tid000", 303 cid: "bafynorole", 304 }, 305 }), 306 }, 307 }, 308 }, 309 } as any; 310 311 const result = await createMembershipForUser(ctx, mockAgent, "did:plc:test-no-role"); 312 313 expect(result.created).toBe(true); 314 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 315 expect(callArg.record.role).toBeUndefined(); 316 expect(errorSpy).toHaveBeenCalledWith( 317 expect.stringContaining("Member role not found"), 318 expect.objectContaining({ operation: "createMembershipForUser" }) 319 ); 320 }); 321 322 it("creates membership without role when role lookup DB error occurs", async () => { 323 // Simulate a transient DB error on the roles query (3rd select call). 324 // Forum and membership queries must succeed; only the role lookup fails. 325 const origSelect = ctx.db.select.bind(ctx.db); 326 vi.spyOn(ctx.db, "select") 327 .mockImplementationOnce(() => origSelect() as any) // forums lookup 328 .mockImplementationOnce(() => origSelect() as any) // memberships check 329 .mockReturnValueOnce({ // roles query — DB error 330 from: vi.fn().mockReturnValue({ 331 where: vi.fn().mockReturnValue({ 332 orderBy: vi.fn().mockReturnValue({ 333 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 334 }), 335 }), 336 }), 337 } as any); 338 339 const warnSpy = vi.spyOn(ctx.logger, "warn"); 340 341 const mockAgent = { 342 com: { 343 atproto: { 344 repo: { 345 putRecord: vi.fn().mockResolvedValue({ 346 data: { 347 uri: "at://did:plc:test-role-err/space.atbb.membership/tid999", 348 cid: "bafyrole-err", 349 }, 350 }), 351 }, 352 }, 353 }, 354 } as any; 355 356 const result = await createMembershipForUser(ctx, mockAgent, "did:plc:test-role-err"); 357 358 expect(result.created).toBe(true); 359 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 360 expect(callArg.record.role).toBeUndefined(); 361 expect(warnSpy).toHaveBeenCalledWith( 362 expect.stringContaining("role lookup"), 363 expect.objectContaining({ operation: "createMembershipForUser" }) 364 ); 365 366 vi.restoreAllMocks(); 367 }); 368 369 it("re-throws TypeError from role lookup so programming errors are not silently swallowed", async () => { 370 const origSelect = ctx.db.select.bind(ctx.db); 371 vi.spyOn(ctx.db, "select") 372 .mockImplementationOnce(() => origSelect() as any) // forums lookup 373 .mockImplementationOnce(() => origSelect() as any) // memberships check 374 .mockReturnValueOnce({ // roles query — TypeError 375 from: vi.fn().mockReturnValue({ 376 where: vi.fn().mockReturnValue({ 377 orderBy: vi.fn().mockReturnValue({ 378 limit: vi.fn().mockRejectedValue( 379 new TypeError("Cannot read properties of undefined") 380 ), 381 }), 382 }), 383 }), 384 } as any); 385 386 // putRecord returns a valid response — the only TypeError in flight is the 387 // one from the role lookup mock. If the catch block swallows it, the 388 // function would return { created: true } instead of rejecting. 389 const mockAgent = { 390 com: { 391 atproto: { 392 repo: { 393 putRecord: vi.fn().mockResolvedValue({ 394 data: { 395 uri: "at://did:plc:test-type-err/space.atbb.membership/tid111", 396 cid: "bafytypeerr", 397 }, 398 }), 399 }, 400 }, 401 }, 402 } as any; 403 404 await expect( 405 createMembershipForUser(ctx, mockAgent, "did:plc:test-type-err") 406 ).rejects.toThrow(TypeError); 407 408 vi.restoreAllMocks(); 409 }); 410 411 it("upgrades bootstrap membership to real PDS record", async () => { 412 const testDid = `did:plc:test-bootstrap-${Date.now()}`; 413 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 414 const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrkey`; 415 416 const mockAgent = { 417 com: { 418 atproto: { 419 repo: { 420 putRecord: vi.fn().mockResolvedValue({ 421 data: { 422 uri: `at://${testDid}/space.atbb.membership/tid456`, 423 cid: "bafyupgraded789", 424 }, 425 }), 426 }, 427 }, 428 }, 429 } as any; 430 431 // Insert user (FK constraint) 432 await ctx.db.insert(users).values({ 433 did: testDid, 434 handle: "bootstrap.owner", 435 indexedAt: new Date(), 436 }); 437 438 // Insert bootstrap membership (as created by `atbb init`) 439 await ctx.db.insert(memberships).values({ 440 did: testDid, 441 rkey: "bootstrap", 442 cid: "bootstrap", 443 forumUri, 444 roleUri: ownerRoleUri, 445 role: "Owner", 446 createdAt: new Date(), 447 indexedAt: new Date(), 448 }); 449 450 const result = await createMembershipForUser(ctx, mockAgent, testDid); 451 452 // Should create a real PDS record 453 expect(result.created).toBe(true); 454 expect(result.cid).toBe("bafyupgraded789"); 455 expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 456 expect.objectContaining({ 457 repo: testDid, 458 collection: "space.atbb.membership", 459 }) 460 ); 461 462 // Verify DB row was upgraded with real values 463 const [updated] = await ctx.db 464 .select() 465 .from(memberships) 466 .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 467 .limit(1); 468 469 expect(updated.cid).toBe("bafyupgraded789"); 470 expect(updated.rkey).not.toBe("bootstrap"); 471 // Role preserved through the upgrade 472 expect(updated.roleUri).toBe(ownerRoleUri); 473 expect(updated.role).toBe("Owner"); 474 }); 475 476 it("includes role strongRef in PDS record when upgrading bootstrap membership with a known role", async () => { 477 // This is the ATB-37 regression test. When upgradeBootstrapMembership writes the 478 // PDS record without a role field, the firehose re-indexes the event and sets 479 // roleUri = null (record.role?.role.uri ?? null), stripping the Owner's role. 480 const testDid = `did:plc:test-bootstrap-roleref-${Date.now()}`; 481 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 482 const ownerRoleRkey = "ownerrole789"; 483 const ownerRoleCid = "bafyowner789"; 484 485 // Insert the Owner role so upgradeBootstrapMembership can look it up 486 await ctx.db.insert(roles).values({ 487 did: ctx.config.forumDid, 488 rkey: ownerRoleRkey, 489 cid: ownerRoleCid, 490 name: "Owner", 491 description: "Forum owner", 492 priority: 10, 493 createdAt: new Date(), 494 indexedAt: new Date(), 495 }); 496 497 const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${ownerRoleRkey}`; 498 499 await ctx.db.insert(users).values({ 500 did: testDid, 501 handle: "bootstrap.roleref", 502 indexedAt: new Date(), 503 }); 504 505 await ctx.db.insert(memberships).values({ 506 did: testDid, 507 rkey: "bootstrap", 508 cid: "bootstrap", 509 forumUri, 510 roleUri: ownerRoleUri, 511 role: "Owner", 512 createdAt: new Date(), 513 indexedAt: new Date(), 514 }); 515 516 const mockAgent = { 517 com: { 518 atproto: { 519 repo: { 520 putRecord: vi.fn().mockResolvedValue({ 521 data: { 522 uri: `at://${testDid}/space.atbb.membership/tidabc`, 523 cid: "bafyupgradedabc", 524 }, 525 }), 526 }, 527 }, 528 }, 529 } as any; 530 531 const result = await createMembershipForUser(ctx, mockAgent, testDid); 532 533 expect(result.created).toBe(true); 534 535 // The PDS record must include the role strongRef so the firehose 536 // preserves the roleUri when it re-indexes the upgrade event. 537 expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( 538 expect.objectContaining({ 539 record: expect.objectContaining({ 540 role: { 541 role: { 542 uri: ownerRoleUri, 543 cid: ownerRoleCid, 544 }, 545 }, 546 }), 547 }) 548 ); 549 550 // DB row must reflect the upgrade: real rkey/cid, roleUri preserved 551 const [updated] = await ctx.db 552 .select() 553 .from(memberships) 554 .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 555 .limit(1); 556 expect(updated.cid).toBe("bafyupgradedabc"); 557 expect(updated.rkey).not.toBe("bootstrap"); 558 expect(updated.roleUri).toBe(ownerRoleUri); 559 }); 560 561 it("omits role from PDS record when upgrading bootstrap membership without a roleUri", async () => { 562 const testDid = `did:plc:test-bootstrap-norole-${Date.now()}`; 563 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 564 565 await ctx.db.insert(users).values({ 566 did: testDid, 567 handle: "bootstrap.norole", 568 indexedAt: new Date(), 569 }); 570 571 // Bootstrap membership with no roleUri 572 await ctx.db.insert(memberships).values({ 573 did: testDid, 574 rkey: "bootstrap", 575 cid: "bootstrap", 576 forumUri, 577 createdAt: new Date(), 578 indexedAt: new Date(), 579 }); 580 581 const mockAgent = { 582 com: { 583 atproto: { 584 repo: { 585 putRecord: vi.fn().mockResolvedValue({ 586 data: { 587 uri: `at://${testDid}/space.atbb.membership/tiddef`, 588 cid: "bafynoroledef", 589 }, 590 }), 591 }, 592 }, 593 }, 594 } as any; 595 596 const result = await createMembershipForUser(ctx, mockAgent, testDid); 597 598 expect(result.created).toBe(true); 599 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 600 expect(callArg.record.role).toBeUndefined(); 601 602 // DB row must reflect the upgrade: real rkey/cid, roleUri stays null 603 const [updated] = await ctx.db 604 .select() 605 .from(memberships) 606 .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 607 .limit(1); 608 expect(updated.cid).toBe("bafynoroledef"); 609 expect(updated.rkey).not.toBe("bootstrap"); 610 expect(updated.roleUri).toBeNull(); 611 }); 612 613 it("upgrades bootstrap membership without role when roleUri references a role not in DB", async () => { 614 const testDid = `did:plc:test-bootstrap-missingrole-${Date.now()}`; 615 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 616 // A roleUri that has no matching row in the roles table 617 const danglingRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`; 618 619 await ctx.db.insert(users).values({ 620 did: testDid, 621 handle: "bootstrap.missingrole", 622 indexedAt: new Date(), 623 }); 624 625 await ctx.db.insert(memberships).values({ 626 did: testDid, 627 rkey: "bootstrap", 628 cid: "bootstrap", 629 forumUri, 630 roleUri: danglingRoleUri, 631 createdAt: new Date(), 632 indexedAt: new Date(), 633 }); 634 635 const mockAgent = { 636 com: { 637 atproto: { 638 repo: { 639 putRecord: vi.fn().mockResolvedValue({ 640 data: { 641 uri: `at://${testDid}/space.atbb.membership/tidghi`, 642 cid: "bafymissingghi", 643 }, 644 }), 645 }, 646 }, 647 }, 648 } as any; 649 650 // Upgrade should still succeed even if role lookup finds nothing 651 const result = await createMembershipForUser(ctx, mockAgent, testDid); 652 expect(result.created).toBe(true); 653 654 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 655 expect(callArg.record.role).toBeUndefined(); 656 657 // DB row must reflect the upgrade: real rkey/cid, dangling roleUri preserved 658 const [updated] = await ctx.db 659 .select() 660 .from(memberships) 661 .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) 662 .limit(1); 663 expect(updated.cid).toBe("bafymissingghi"); 664 expect(updated.rkey).not.toBe("bootstrap"); 665 expect(updated.roleUri).toBe(danglingRoleUri); 666 }); 667 668 it("logs error and continues upgrade when role DB lookup fails during bootstrap upgrade", async () => { 669 const testDid = `did:plc:test-bootstrap-dberr-${Date.now()}`; 670 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 671 const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrole`; 672 673 await ctx.db.insert(users).values({ 674 did: testDid, 675 handle: "bootstrap.dberr", 676 indexedAt: new Date(), 677 }); 678 679 await ctx.db.insert(memberships).values({ 680 did: testDid, 681 rkey: "bootstrap", 682 cid: "bootstrap", 683 forumUri, 684 roleUri: ownerRoleUri, 685 role: "Owner", 686 createdAt: new Date(), 687 indexedAt: new Date(), 688 }); 689 690 const origSelect = ctx.db.select.bind(ctx.db); 691 vi.spyOn(ctx.db, "select") 692 .mockImplementationOnce(() => origSelect() as any) // forums lookup 693 .mockImplementationOnce(() => origSelect() as any) // memberships check (bootstrap found) 694 .mockReturnValueOnce({ // roles query in upgradeBootstrapMembership 695 from: vi.fn().mockReturnValue({ 696 where: vi.fn().mockReturnValue({ 697 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 698 }), 699 }), 700 } as any); 701 702 const errorSpy = vi.spyOn(ctx.logger, "error"); 703 704 const mockAgent = { 705 com: { 706 atproto: { 707 repo: { 708 putRecord: vi.fn().mockResolvedValue({ 709 data: { 710 uri: `at://${testDid}/space.atbb.membership/tidjkl`, 711 cid: "bafydberrjkl", 712 }, 713 }), 714 }, 715 }, 716 }, 717 } as any; 718 719 const result = await createMembershipForUser(ctx, mockAgent, testDid); 720 721 expect(result.created).toBe(true); 722 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 723 expect(callArg.record.role).toBeUndefined(); 724 expect(errorSpy).toHaveBeenCalledWith( 725 expect.stringContaining("Role lookup failed during bootstrap upgrade"), 726 expect.objectContaining({ operation: "upgradeBootstrapMembership" }) 727 ); 728 729 vi.restoreAllMocks(); 730 }); 731 732 it("logs error and omits role when bootstrap membership has a malformed roleUri", async () => { 733 const testDid = `did:plc:test-bootstrap-malformed-${Date.now()}`; 734 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 735 // Syntactically invalid AT URI — parseAtUri will return null 736 const malformedRoleUri = "not-a-valid-at-uri"; 737 738 await ctx.db.insert(users).values({ 739 did: testDid, 740 handle: "bootstrap.malformed", 741 indexedAt: new Date(), 742 }); 743 744 await ctx.db.insert(memberships).values({ 745 did: testDid, 746 rkey: "bootstrap", 747 cid: "bootstrap", 748 forumUri, 749 roleUri: malformedRoleUri, 750 createdAt: new Date(), 751 indexedAt: new Date(), 752 }); 753 754 const errorSpy = vi.spyOn(ctx.logger, "error"); 755 756 const mockAgent = { 757 com: { 758 atproto: { 759 repo: { 760 putRecord: vi.fn().mockResolvedValue({ 761 data: { 762 uri: `at://${testDid}/space.atbb.membership/tidmno`, 763 cid: "bafymalformedmno", 764 }, 765 }), 766 }, 767 }, 768 }, 769 } as any; 770 771 const result = await createMembershipForUser(ctx, mockAgent, testDid); 772 773 expect(result.created).toBe(true); 774 const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; 775 expect(callArg.record.role).toBeUndefined(); 776 expect(errorSpy).toHaveBeenCalledWith( 777 expect.stringContaining("roleUri failed to parse"), 778 expect.objectContaining({ operation: "upgradeBootstrapMembership", roleUri: malformedRoleUri }) 779 ); 780 }); 781});