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
at atb-52-css-token-extraction 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});