fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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});