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