import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createMembershipForUser } from "../membership.js"; import { createTestContext, type TestContext } from "./test-context.js"; import { memberships, users, roles, rolePermissions } from "@atbb/db"; import { eq, and } from "drizzle-orm"; describe("createMembershipForUser", () => { let ctx: TestContext; beforeEach(async () => { ctx = await createTestContext(); }); afterEach(async () => { await ctx.cleanup(); }); it("returns early when membership already exists", async () => { const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:test-user/space.atbb.membership/test", cid: "bafytest123", }, }), }, }, }, } as any; // Insert user first (FK constraint) await ctx.db.insert(users).values({ did: "did:plc:test-user", handle: "test.user", indexedAt: new Date(), }); // Insert existing membership into test database const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; await ctx.db.insert(memberships).values({ did: "did:plc:test-user", rkey: "existing", cid: "bafytest", forumUri, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }); const result = await createMembershipForUser( ctx, mockAgent, "did:plc:test-user" ); expect(result.created).toBe(false); expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); }); it("throws 'Forum not found' when only a different forum DID exists (multi-tenant isolation)", async () => { // Regression test for ATB-29 fix: membership.ts must scope the forum lookup // to ctx.config.forumDid. Without eq(forums.did, forumDid), this would find // the wrong forum and create a membership pointing to the wrong forum. // // The existing ctx has did:plc:test-forum in the DB. We create an isolationCtx // that points to a different forumDid — if the code is broken (no forumDid filter), // it would find did:plc:test-forum instead of throwing "Forum not found". // // Using ctx spread (not createTestContext) avoids calling cleanDatabase(), which // would race with concurrently-running tests that also depend on did:plc:test-forum. const isolationCtx = { ...ctx, config: { ...ctx.config, forumDid: `did:plc:isolation-${Date.now()}` }, }; const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn() } } }, } as any; await expect( createMembershipForUser(isolationCtx, mockAgent, "did:plc:test-user") ).rejects.toThrow("Forum not found"); expect(mockAgent.com.atproto.repo.putRecord).not.toHaveBeenCalled(); }); it("throws when forum metadata not found", async () => { // emptyDb: true skips forum insertion; cleanDatabase() removes any stale // test forum. membership.ts queries by forumDid so stale real-forum rows // with different DIDs won't interfere. const emptyCtx = await createTestContext({ emptyDb: true }); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn(), }, }, }, } as any; await expect( createMembershipForUser(emptyCtx, mockAgent, "did:plc:test123") ).rejects.toThrow("Forum not found"); // Clean up the empty context await emptyCtx.cleanup(); }); it("creates membership record when none exists", async () => { const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:create-test/space.atbb.membership/tid123", cid: "bafynew123", }, }), }, }, }, } as any; const result = await createMembershipForUser( ctx, mockAgent, "did:plc:create-test" ); expect(result.created).toBe(true); expect(result.uri).toBe("at://did:plc:create-test/space.atbb.membership/tid123"); expect(result.cid).toBe("bafynew123"); // Verify putRecord was called with correct lexicon structure expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: "did:plc:create-test", collection: "space.atbb.membership", rkey: expect.stringMatching(/^[a-z0-9]+$/), // TID format record: expect.objectContaining({ $type: "space.atbb.membership", forum: { forum: { uri: expect.stringContaining("space.atbb.forum.forum/self"), cid: expect.any(String), }, }, createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), // ISO timestamp joinedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), }), }) ); }); it("throws when PDS write fails", async () => { const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockRejectedValue(new Error("Network timeout")), }, }, }, } as any; await expect( createMembershipForUser(ctx, mockAgent, "did:plc:pds-fail-test") ).rejects.toThrow("Network timeout"); }); it("checks for duplicates using DID + forumUri", async () => { const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:duptest/space.atbb.membership/test", cid: "bafydup123", }, }), }, }, }, } as any; const testDid = `did:plc:duptest-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; // Insert user first (FK constraint) await ctx.db.insert(users).values({ did: testDid, handle: "dupcheck.user", indexedAt: new Date(), }); // Insert membership for same user in this forum await ctx.db.insert(memberships).values({ did: testDid, rkey: "existing1", cid: "bafytest1", forumUri, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }); // Should return early (duplicate in same forum) const result1 = await createMembershipForUser( ctx, mockAgent, testDid ); expect(result1.created).toBe(false); // Insert membership for same user in DIFFERENT forum await ctx.db.insert(memberships).values({ did: testDid, rkey: "existing2", cid: "bafytest2", forumUri: "at://did:plc:other/space.atbb.forum.forum/self", joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }); // Should still return early (already has membership in THIS forum) const result2 = await createMembershipForUser( ctx, mockAgent, testDid ); expect(result2.created).toBe(false); }); it("includes Member role in new membership PDS record when Member role exists in DB", async () => { const memberRoleRkey = "memberrole123"; const memberRoleCid = "bafymemberrole456"; const [memberRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: memberRoleRkey, cid: memberRoleCid, name: "Member", description: "Regular forum member", priority: 30, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([ { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, { roleId: memberRole.id, permission: "space.atbb.permission.createPosts" }, ]); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:test-new-member/space.atbb.membership/tid789", cid: "bafynewmember", }, }), }, }, }, } as any; await createMembershipForUser(ctx, mockAgent, "did:plc:test-new-member"); expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ role: { role: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${memberRoleRkey}`, cid: memberRoleCid, }, }, }), }) ); }); it("logs error and creates membership without role when Member role not found in DB", async () => { // No roles seeded — Member role absent const errorSpy = vi.spyOn(ctx.logger, "error"); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:test-no-role/space.atbb.membership/tid000", cid: "bafynorole", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, "did:plc:test-no-role"); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Member role not found"), expect.objectContaining({ operation: "createMembershipForUser" }) ); }); it("creates membership without role when role lookup DB error occurs", async () => { // Simulate a transient DB error on the roles query (3rd select call). // Forum and membership queries must succeed; only the role lookup fails. const origSelect = ctx.db.select.bind(ctx.db); vi.spyOn(ctx.db, "select") .mockImplementationOnce(() => origSelect() as any) // forums lookup .mockImplementationOnce(() => origSelect() as any) // memberships check .mockReturnValueOnce({ // roles query — DB error from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), }), }), }), } as any); const warnSpy = vi.spyOn(ctx.logger, "warn"); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:test-role-err/space.atbb.membership/tid999", cid: "bafyrole-err", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, "did:plc:test-role-err"); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("role lookup"), expect.objectContaining({ operation: "createMembershipForUser" }) ); vi.restoreAllMocks(); }); it("re-throws TypeError from role lookup so programming errors are not silently swallowed", async () => { const origSelect = ctx.db.select.bind(ctx.db); vi.spyOn(ctx.db, "select") .mockImplementationOnce(() => origSelect() as any) // forums lookup .mockImplementationOnce(() => origSelect() as any) // memberships check .mockReturnValueOnce({ // roles query — TypeError from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue( new TypeError("Cannot read properties of undefined") ), }), }), }), } as any); // putRecord returns a valid response — the only TypeError in flight is the // one from the role lookup mock. If the catch block swallows it, the // function would return { created: true } instead of rejecting. const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: "at://did:plc:test-type-err/space.atbb.membership/tid111", cid: "bafytypeerr", }, }), }, }, }, } as any; await expect( createMembershipForUser(ctx, mockAgent, "did:plc:test-type-err") ).rejects.toThrow(TypeError); vi.restoreAllMocks(); }); it("upgrades bootstrap membership to real PDS record", async () => { const testDid = `did:plc:test-bootstrap-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrkey`; const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tid456`, cid: "bafyupgraded789", }, }), }, }, }, } as any; // Insert user (FK constraint) await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.owner", indexedAt: new Date(), }); // Insert bootstrap membership (as created by `atbb init`) await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, roleUri: ownerRoleUri, role: "Owner", createdAt: new Date(), indexedAt: new Date(), }); const result = await createMembershipForUser(ctx, mockAgent, testDid); // Should create a real PDS record expect(result.created).toBe(true); expect(result.cid).toBe("bafyupgraded789"); expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: testDid, collection: "space.atbb.membership", }) ); // Verify DB row was upgraded with real values const [updated] = await ctx.db .select() .from(memberships) .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) .limit(1); expect(updated.cid).toBe("bafyupgraded789"); expect(updated.rkey).not.toBe("bootstrap"); // Role preserved through the upgrade expect(updated.roleUri).toBe(ownerRoleUri); expect(updated.role).toBe("Owner"); }); it("includes role strongRef in PDS record when upgrading bootstrap membership with a known role", async () => { // This is the ATB-37 regression test. When upgradeBootstrapMembership writes the // PDS record without a role field, the firehose re-indexes the event and sets // roleUri = null (record.role?.role.uri ?? null), stripping the Owner's role. const testDid = `did:plc:test-bootstrap-roleref-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; const ownerRoleRkey = "ownerrole789"; const ownerRoleCid = "bafyowner789"; // Insert the Owner role so upgradeBootstrapMembership can look it up await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: ownerRoleRkey, cid: ownerRoleCid, name: "Owner", description: "Forum owner", priority: 10, createdAt: new Date(), indexedAt: new Date(), }); const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${ownerRoleRkey}`; await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.roleref", indexedAt: new Date(), }); await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, roleUri: ownerRoleUri, role: "Owner", createdAt: new Date(), indexedAt: new Date(), }); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tidabc`, cid: "bafyupgradedabc", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, testDid); expect(result.created).toBe(true); // The PDS record must include the role strongRef so the firehose // preserves the roleUri when it re-indexes the upgrade event. expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ role: { role: { uri: ownerRoleUri, cid: ownerRoleCid, }, }, }), }) ); // DB row must reflect the upgrade: real rkey/cid, roleUri preserved const [updated] = await ctx.db .select() .from(memberships) .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) .limit(1); expect(updated.cid).toBe("bafyupgradedabc"); expect(updated.rkey).not.toBe("bootstrap"); expect(updated.roleUri).toBe(ownerRoleUri); }); it("omits role from PDS record when upgrading bootstrap membership without a roleUri", async () => { const testDid = `did:plc:test-bootstrap-norole-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.norole", indexedAt: new Date(), }); // Bootstrap membership with no roleUri await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, createdAt: new Date(), indexedAt: new Date(), }); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tiddef`, cid: "bafynoroledef", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, testDid); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); // DB row must reflect the upgrade: real rkey/cid, roleUri stays null const [updated] = await ctx.db .select() .from(memberships) .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) .limit(1); expect(updated.cid).toBe("bafynoroledef"); expect(updated.rkey).not.toBe("bootstrap"); expect(updated.roleUri).toBeNull(); }); it("upgrades bootstrap membership without role when roleUri references a role not in DB", async () => { const testDid = `did:plc:test-bootstrap-missingrole-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; // A roleUri that has no matching row in the roles table const danglingRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`; await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.missingrole", indexedAt: new Date(), }); await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, roleUri: danglingRoleUri, createdAt: new Date(), indexedAt: new Date(), }); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tidghi`, cid: "bafymissingghi", }, }), }, }, }, } as any; // Upgrade should still succeed even if role lookup finds nothing const result = await createMembershipForUser(ctx, mockAgent, testDid); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); // DB row must reflect the upgrade: real rkey/cid, dangling roleUri preserved const [updated] = await ctx.db .select() .from(memberships) .where(and(eq(memberships.did, testDid), eq(memberships.forumUri, forumUri))) .limit(1); expect(updated.cid).toBe("bafymissingghi"); expect(updated.rkey).not.toBe("bootstrap"); expect(updated.roleUri).toBe(danglingRoleUri); }); it("logs error and continues upgrade when role DB lookup fails during bootstrap upgrade", async () => { const testDid = `did:plc:test-bootstrap-dberr-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; const ownerRoleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/ownerrole`; await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.dberr", indexedAt: new Date(), }); await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, roleUri: ownerRoleUri, role: "Owner", createdAt: new Date(), indexedAt: new Date(), }); const origSelect = ctx.db.select.bind(ctx.db); vi.spyOn(ctx.db, "select") .mockImplementationOnce(() => origSelect() as any) // forums lookup .mockImplementationOnce(() => origSelect() as any) // memberships check (bootstrap found) .mockReturnValueOnce({ // roles query in upgradeBootstrapMembership from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), }), }), } as any); const errorSpy = vi.spyOn(ctx.logger, "error"); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tidjkl`, cid: "bafydberrjkl", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, testDid); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Role lookup failed during bootstrap upgrade"), expect.objectContaining({ operation: "upgradeBootstrapMembership" }) ); vi.restoreAllMocks(); }); it("logs error and omits role when bootstrap membership has a malformed roleUri", async () => { const testDid = `did:plc:test-bootstrap-malformed-${Date.now()}`; const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; // Syntactically invalid AT URI — parseAtUri will return null const malformedRoleUri = "not-a-valid-at-uri"; await ctx.db.insert(users).values({ did: testDid, handle: "bootstrap.malformed", indexedAt: new Date(), }); await ctx.db.insert(memberships).values({ did: testDid, rkey: "bootstrap", cid: "bootstrap", forumUri, roleUri: malformedRoleUri, createdAt: new Date(), indexedAt: new Date(), }); const errorSpy = vi.spyOn(ctx.logger, "error"); const mockAgent = { com: { atproto: { repo: { putRecord: vi.fn().mockResolvedValue({ data: { uri: `at://${testDid}/space.atbb.membership/tidmno`, cid: "bafymalformedmno", }, }), }, }, }, } as any; const result = await createMembershipForUser(ctx, mockAgent, testDid); expect(result.created).toBe(true); const callArg = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; expect(callArg.record.role).toBeUndefined(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("roleUri failed to parse"), expect.objectContaining({ operation: "upgradeBootstrapMembership", roleUri: malformedRoleUri }) ); }); });