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, beforeEach, afterEach, vi } from "vitest";
2import { Hono } from "hono";
3import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
4import type { Variables } from "../../types.js";
5import { posts, users, modActions } from "@atbb/db";
6import { TID } from "@atproto/common-web";
7import { SpaceAtbbPost as Post } from "@atbb/lexicon";
8
9// Mock auth and permission middleware at the module level
10let mockPutRecord: ReturnType<typeof vi.fn>;
11let mockUser: any;
12
13vi.mock("../../middleware/auth.js", () => ({
14 requireAuth: vi.fn(() => async (c: any, next: any) => {
15 c.set("user", mockUser);
16 await next();
17 }),
18}));
19
20vi.mock("../../middleware/permissions.js", async (importOriginal) => {
21 const actual = await importOriginal<typeof import("../../middleware/permissions.js")>();
22 return {
23 ...actual, // Keep requireNotBanned real so ban enforcement tests work
24 requirePermission: vi.fn(() => async (c: any, next: any) => {
25 await next();
26 }),
27 };
28});
29
30// Import after mocking
31const { createPostsRoutes } = await import("../posts.js");
32
33describe("POST /api/posts", () => {
34 let ctx: TestContext;
35 let app: Hono<{ Variables: Variables }>;
36 let topicId: string;
37 let replyId: string;
38
39 beforeEach(async () => {
40 ctx = await createTestContext();
41
42 // Insert test user
43 await ctx.db.insert(users).values({
44 did: "did:plc:test-user",
45 handle: "testuser.test",
46 indexedAt: new Date(),
47 });
48
49 // Insert topic (root post) and get its ID
50 const [topicPost] = await ctx.db
51 .insert(posts)
52 .values({
53 did: "did:plc:test-user",
54 rkey: "3lbk7topic",
55 cid: "bafytopic",
56 text: "Topic post",
57 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
58 createdAt: new Date(),
59 indexedAt: new Date(),
60 })
61 .returning({ id: posts.id });
62
63 // Store topic ID for tests
64 topicId = topicPost.id.toString();
65
66 // Insert reply and get its ID
67 const [replyPost] = await ctx.db
68 .insert(posts)
69 .values({
70 did: "did:plc:test-user",
71 rkey: "3lbk8reply",
72 cid: "bafyreply",
73 text: "Reply post",
74 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
75 rootPostId: topicPost.id,
76 parentPostId: topicPost.id,
77 rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
78 parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic",
79 createdAt: new Date(),
80 indexedAt: new Date(),
81 })
82 .returning({ id: posts.id });
83
84 // Store reply ID for tests
85 replyId = replyPost.id.toString();
86
87 // Mock putRecord to track calls
88 mockPutRecord = vi.fn(async () => ({
89 data: {
90 uri: "at://did:plc:test-user/space.atbb.post/3lbk9test",
91 cid: "bafytest",
92 },
93 }));
94
95 // Set up mock user for auth middleware
96 mockUser = {
97 did: "did:plc:test-user",
98 handle: "testuser.test",
99 pdsUrl: "https://test.pds",
100 agent: {
101 com: {
102 atproto: {
103 repo: {
104 putRecord: mockPutRecord,
105 },
106 },
107 },
108 },
109 };
110
111 app = new Hono<{ Variables: Variables }>();
112 app.route("/api/posts", createPostsRoutes(ctx));
113 });
114
115 afterEach(async () => {
116 await ctx.cleanup();
117 });
118
119 it("creates reply to topic", async () => {
120 const res = await app.request("/api/posts", {
121 method: "POST",
122 headers: { "Content-Type": "application/json" },
123 body: JSON.stringify({
124 text: "My reply",
125 rootPostId: topicId,
126 parentPostId: topicId,
127 }),
128 });
129
130 expect(res.status).toBe(201);
131 const data = await res.json();
132 expect(data.uri).toBeTruthy();
133 expect(data.cid).toBeTruthy();
134 expect(data.rkey).toBeTruthy();
135
136 // Verify the reply ref written to PDS passes Post.isReplyRef() — the actual
137 // contract the indexer uses. A string literal check on $type would pass even
138 // with a typo that still breaks the indexer's runtime guard.
139 const putCall = mockPutRecord.mock.calls[0][0];
140 expect(Post.isReplyRef(putCall.record.reply)).toBe(true);
141 expect(putCall.record.reply.root.uri).toMatch(/^at:\/\/did:plc:.*\/space\.atbb\.post\//);
142 expect(putCall.record.reply.parent.uri).toMatch(/^at:\/\/did:plc:.*\/space\.atbb\.post\//);
143
144 // Verify the forum ref also has its $type discriminator, consistent with
145 // replyRef and boardRef — all three use the same lexicon ref pattern.
146 expect(Post.isForumRef(putCall.record.forum)).toBe(true);
147 });
148
149 it("creates reply to reply", async () => {
150 const res = await app.request("/api/posts", {
151 method: "POST",
152 headers: { "Content-Type": "application/json" },
153 body: JSON.stringify({
154 text: "Nested reply",
155 rootPostId: topicId,
156 parentPostId: replyId, // Reply to the reply
157 }),
158 });
159
160 expect(res.status).toBe(201);
161
162 // Same isReplyRef contract check as the direct-reply case — nested replies
163 // use the same construction path and must also include $type.
164 const putCall = mockPutRecord.mock.calls[0][0];
165 expect(Post.isReplyRef(putCall.record.reply)).toBe(true);
166 });
167
168 it("returns 400 for invalid parent ID format", async () => {
169 const res = await app.request("/api/posts", {
170 method: "POST",
171 headers: { "Content-Type": "application/json" },
172 body: JSON.stringify({
173 text: "Test",
174 rootPostId: "not-a-number",
175 parentPostId: "1",
176 }),
177 });
178
179 expect(res.status).toBe(400);
180 const data = await res.json();
181 expect(data.error).toContain("Invalid");
182 });
183
184 it("returns 404 when root post does not exist", async () => {
185 const res = await app.request("/api/posts", {
186 method: "POST",
187 headers: { "Content-Type": "application/json" },
188 body: JSON.stringify({
189 text: "Test",
190 rootPostId: "999",
191 parentPostId: "999",
192 }),
193 });
194
195 expect(res.status).toBe(404);
196 const data = await res.json();
197 expect(data.error).toContain("not found");
198 });
199
200 it("returns 404 when parent post does not exist", async () => {
201 const res = await app.request("/api/posts", {
202 method: "POST",
203 headers: { "Content-Type": "application/json" },
204 body: JSON.stringify({
205 text: "Test",
206 rootPostId: topicId,
207 parentPostId: "999",
208 }),
209 });
210
211 expect(res.status).toBe(404);
212 const data = await res.json();
213 expect(data.error).toContain("not found");
214 });
215
216 it("returns 400 when parent belongs to different thread", async () => {
217 // Insert a different topic and get its ID
218 const [otherTopic] = await ctx.db
219 .insert(posts)
220 .values({
221 did: "did:plc:test-user",
222 rkey: "3lbkaother",
223 cid: "bafyother",
224 text: "Other topic",
225 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
226 createdAt: new Date(),
227 indexedAt: new Date(),
228 })
229 .returning({ id: posts.id });
230
231 const res = await app.request("/api/posts", {
232 method: "POST",
233 headers: { "Content-Type": "application/json" },
234 body: JSON.stringify({
235 text: "Test",
236 rootPostId: topicId,
237 parentPostId: otherTopic.id.toString(), // Different thread
238 }),
239 });
240
241 expect(res.status).toBe(400);
242 const data = await res.json();
243 expect(data.error).toContain("thread");
244 });
245
246 // Critical Issue #1: Test type guard for validatePostText
247 it("returns 400 when text is missing", async () => {
248 const res = await app.request("/api/posts", {
249 method: "POST",
250 headers: { "Content-Type": "application/json" },
251 body: JSON.stringify({
252 rootPostId: topicId,
253 parentPostId: topicId,
254 }), // No text field
255 });
256
257 expect(res.status).toBe(400);
258 const data = await res.json();
259 expect(data.error).toContain("Text is required");
260 });
261
262 it("returns 400 for non-string text (array)", async () => {
263 const res = await app.request("/api/posts", {
264 method: "POST",
265 headers: { "Content-Type": "application/json" },
266 body: JSON.stringify({
267 text: ["not", "a", "string"],
268 rootPostId: topicId,
269 parentPostId: topicId,
270 }),
271 });
272
273 expect(res.status).toBe(400);
274 const data = await res.json();
275 expect(data.error).toContain("must be a string");
276 });
277
278 // Critical Issue #2: Test malformed JSON handling
279 it("returns 400 for malformed JSON", async () => {
280 const res = await app.request("/api/posts", {
281 method: "POST",
282 headers: { "Content-Type": "application/json" },
283 body: '{"text": "incomplete',
284 });
285
286 expect(res.status).toBe(400);
287 const data = await res.json();
288 expect(data.error).toContain("Invalid JSON");
289 });
290
291 // Critical test coverage: PDS network errors (503)
292 it("returns 503 when PDS connection fails (network error)", async () => {
293 mockPutRecord.mockRejectedValueOnce(new Error("Network request failed"));
294
295 const res = await app.request("/api/posts", {
296 method: "POST",
297 headers: { "Content-Type": "application/json" },
298 body: JSON.stringify({
299 text: "Test reply",
300 rootPostId: topicId,
301 parentPostId: topicId,
302 }),
303 });
304
305 expect(res.status).toBe(503);
306 const data = await res.json();
307 expect(data.error).toContain("Unable to reach external service");
308 });
309
310 it("returns 503 when DNS resolution fails (ENOTFOUND)", async () => {
311 mockPutRecord.mockRejectedValueOnce(new Error("getaddrinfo ENOTFOUND"));
312
313 const res = await app.request("/api/posts", {
314 method: "POST",
315 headers: { "Content-Type": "application/json" },
316 body: JSON.stringify({
317 text: "Test reply",
318 rootPostId: topicId,
319 parentPostId: topicId,
320 }),
321 });
322
323 expect(res.status).toBe(503);
324 const data = await res.json();
325 expect(data.error).toContain("Unable to reach external service");
326 });
327
328 it("returns 503 when request times out", async () => {
329 mockPutRecord.mockRejectedValueOnce(new Error("timeout of 5000ms exceeded"));
330
331 const res = await app.request("/api/posts", {
332 method: "POST",
333 headers: { "Content-Type": "application/json" },
334 body: JSON.stringify({
335 text: "Test reply",
336 rootPostId: topicId,
337 parentPostId: topicId,
338 }),
339 });
340
341 expect(res.status).toBe(503);
342 const data = await res.json();
343 expect(data.error).toContain("Unable to reach external service");
344 });
345
346 // Critical test coverage: PDS server errors (500)
347 it("returns 500 when PDS returns server error", async () => {
348 mockPutRecord.mockRejectedValueOnce(new Error("PDS internal error"));
349
350 const res = await app.request("/api/posts", {
351 method: "POST",
352 headers: { "Content-Type": "application/json" },
353 body: JSON.stringify({
354 text: "Test reply",
355 rootPostId: topicId,
356 parentPostId: topicId,
357 }),
358 });
359
360 expect(res.status).toBe(500);
361 const data = await res.json();
362 expect(data.error).toContain("Failed to create post");
363 });
364
365 it("returns 500 for unexpected errors", async () => {
366 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected error occurred"));
367
368 const res = await app.request("/api/posts", {
369 method: "POST",
370 headers: { "Content-Type": "application/json" },
371 body: JSON.stringify({
372 text: "Test reply",
373 rootPostId: topicId,
374 parentPostId: topicId,
375 }),
376 });
377
378 expect(res.status).toBe(500);
379 const data = await res.json();
380 expect(data.error).toContain("Failed to create post");
381 });
382
383 it("returns 503 when database is unavailable during post creation", async () => {
384 const helpers = await import("../helpers.js");
385 const getPostsByIdsSpy = vi.spyOn(helpers, "getPostsByIds");
386 getPostsByIdsSpy.mockRejectedValueOnce(new Error("Database connection lost"));
387
388 const res = await app.request("/api/posts", {
389 method: "POST",
390 headers: { "Content-Type": "application/json" },
391 body: JSON.stringify({
392 text: "Test reply",
393 rootPostId: topicId,
394 parentPostId: topicId,
395 }),
396 });
397
398 expect(res.status).toBe(503);
399 const data = await res.json();
400 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
401
402 getPostsByIdsSpy.mockRestore();
403 });
404});
405
406describe("POST /api/posts - ban enforcement", () => {
407 let ctx: TestContext;
408 let app: Hono<{ Variables: Variables }>;
409 let topicId: string;
410
411 beforeEach(async () => {
412 ctx = await createTestContext();
413
414 // Insert test user (use onConflictDoNothing in case tests share users)
415 await ctx.db.insert(users).values({
416 did: "did:plc:ban-test-user",
417 handle: "bantestuser.test",
418 indexedAt: new Date(),
419 }).onConflictDoNothing();
420
421 // Insert topic (root post) with unique rkey
422 const banTopicRkey = TID.nextStr();
423 const [topicPost] = await ctx.db
424 .insert(posts)
425 .values({
426 did: "did:plc:ban-test-user",
427 rkey: banTopicRkey,
428 cid: `bafy${banTopicRkey}`,
429 text: "Topic for ban tests",
430 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
431 createdAt: new Date(),
432 indexedAt: new Date(),
433 })
434 .returning({ id: posts.id });
435
436 topicId = topicPost.id.toString();
437
438 // Set up mock user
439 mockUser = {
440 did: "did:plc:ban-test-user",
441 handle: "bantestuser.test",
442 pdsUrl: "https://test.pds",
443 agent: {
444 com: {
445 atproto: {
446 repo: {
447 putRecord: vi.fn(async () => ({
448 data: {
449 uri: "at://did:plc:ban-test-user/space.atbb.post/3lbkbanreply",
450 cid: "bafybanreply",
451 },
452 })),
453 },
454 },
455 },
456 },
457 };
458
459 app = new Hono<{ Variables: Variables }>();
460 app.route("/api/posts", createPostsRoutes(ctx));
461 });
462
463 afterEach(async () => {
464 await ctx.cleanup();
465 });
466
467 it("allows non-banned user to create reply", async () => {
468 const res = await app.request("/api/posts", {
469 method: "POST",
470 headers: { "Content-Type": "application/json" },
471 body: JSON.stringify({
472 text: "Reply from non-banned user",
473 rootPostId: topicId,
474 parentPostId: topicId,
475 }),
476 });
477
478 expect(res.status).toBe(201);
479 const data = await res.json();
480 expect(data.uri).toBeDefined();
481 });
482
483 it("blocks banned user from creating reply", async () => {
484 // Ban the user
485 const banRkey = TID.nextStr();
486 await ctx.db.insert(modActions).values({
487 did: ctx.config.forumDid,
488 rkey: banRkey,
489 cid: `bafy${banRkey}`,
490 action: "space.atbb.modAction.ban",
491 subjectDid: mockUser.did,
492 createdBy: "did:plc:admin",
493 createdAt: new Date(),
494 indexedAt: new Date(),
495 });
496
497 const res = await app.request("/api/posts", {
498 method: "POST",
499 headers: { "Content-Type": "application/json" },
500 body: JSON.stringify({
501 text: "Reply from banned user",
502 rootPostId: topicId,
503 parentPostId: topicId,
504 }),
505 });
506
507 expect(res.status).toBe(403);
508 const data = await res.json();
509 expect(data.error).toBe("You are banned from this forum");
510 });
511
512 it("returns 503 when ban check fails with database error", async () => {
513 const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {});
514
515 const helpers = await import("../helpers.js");
516 const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans");
517 getActiveBansSpy.mockRejectedValueOnce(new Error("Database connection lost"));
518
519 const res = await app.request("/api/posts", {
520 method: "POST",
521 headers: { "Content-Type": "application/json" },
522 body: JSON.stringify({
523 text: "Reply attempt during DB error",
524 rootPostId: topicId,
525 parentPostId: topicId,
526 }),
527 });
528
529 expect(res.status).toBe(503);
530 const data = await res.json();
531 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
532
533 expect(consoleErrorSpy).toHaveBeenCalledWith(
534 "Unable to verify ban status",
535 expect.objectContaining({
536 operation: "POST /api/posts - ban check",
537 userId: mockUser.did,
538 error: "Database connection lost",
539 })
540 );
541
542 consoleErrorSpy.mockRestore();
543 getActiveBansSpy.mockRestore();
544 });
545
546 it("returns 500 when ban check fails with unexpected error (fail closed)", async () => {
547 const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {});
548
549 const helpers = await import("../helpers.js");
550 const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans");
551 getActiveBansSpy.mockRejectedValueOnce(new Error("Unexpected internal error"));
552
553 const res = await app.request("/api/posts", {
554 method: "POST",
555 headers: { "Content-Type": "application/json" },
556 body: JSON.stringify({
557 text: "Reply attempt during unexpected error",
558 rootPostId: topicId,
559 parentPostId: topicId,
560 }),
561 });
562
563 expect(res.status).toBe(500);
564 const data = await res.json();
565 expect(data.error).toBe("Unable to verify ban status. Please contact support if this persists.");
566
567 expect(consoleErrorSpy).toHaveBeenCalledWith(
568 "Unable to verify ban status",
569 expect.objectContaining({
570 operation: "POST /api/posts - ban check",
571 userId: mockUser.did,
572 error: "Unexpected internal error",
573 })
574 );
575
576 consoleErrorSpy.mockRestore();
577 getActiveBansSpy.mockRestore();
578 });
579
580 it("re-throws programming errors from ban check", async () => {
581 const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {});
582
583 const helpers = await import("../helpers.js");
584 const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans");
585 getActiveBansSpy.mockImplementationOnce(() => {
586 throw new TypeError("Cannot read property 'has' of undefined");
587 });
588
589 // Hono catches re-thrown errors via its internal error handler
590 const res = await app.request("/api/posts", {
591 method: "POST",
592 headers: { "Content-Type": "application/json" },
593 body: JSON.stringify({
594 text: "Reply with programming error",
595 rootPostId: topicId,
596 parentPostId: topicId,
597 }),
598 });
599
600 // Hono's default error handler returns 500 for uncaught throws
601 expect(res.status).toBe(500);
602
603 // Verify CRITICAL error was logged (proves the re-throw path was executed)
604 expect(consoleErrorSpy).toHaveBeenCalledWith(
605 "CRITICAL: Programming error in POST /api/posts - ban check",
606 expect.objectContaining({
607 operation: "POST /api/posts - ban check",
608 userId: mockUser.did,
609 error: "Cannot read property 'has' of undefined",
610 stack: expect.any(String),
611 })
612 );
613
614 // Verify the normal error path was NOT taken
615 expect(consoleErrorSpy).not.toHaveBeenCalledWith(
616 "Unable to verify ban status",
617 expect.any(Object)
618 );
619
620 consoleErrorSpy.mockRestore();
621 getActiveBansSpy.mockRestore();
622 });
623});
624
625describe("POST /api/posts - lock enforcement", () => {
626 let ctx: TestContext;
627 let app: Hono<{ Variables: Variables }>;
628 let topicId: string;
629 let topicRkey: string;
630
631 beforeEach(async () => {
632 ctx = await createTestContext();
633
634 // Insert test user (use onConflictDoNothing in case tests share users)
635 await ctx.db.insert(users).values({
636 did: "did:plc:lock-test-user",
637 handle: "locktestuser.test",
638 indexedAt: new Date(),
639 }).onConflictDoNothing();
640
641 // Insert topic (root post) with unique rkey
642 topicRkey = TID.nextStr();
643 const [topicPost] = await ctx.db
644 .insert(posts)
645 .values({
646 did: "did:plc:lock-test-user",
647 rkey: topicRkey,
648 cid: `bafy${topicRkey}`,
649 text: "Topic for lock tests",
650 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
651 createdAt: new Date(),
652 indexedAt: new Date(),
653 })
654 .returning({ id: posts.id });
655
656 topicId = topicPost.id.toString();
657
658 // Set up mock user
659 mockUser = {
660 did: "did:plc:lock-test-user",
661 handle: "locktestuser.test",
662 pdsUrl: "https://test.pds",
663 agent: {
664 com: {
665 atproto: {
666 repo: {
667 putRecord: vi.fn(async () => ({
668 data: {
669 uri: "at://did:plc:lock-test-user/space.atbb.post/3lbklockreply",
670 cid: "bafylockreply",
671 },
672 })),
673 },
674 },
675 },
676 },
677 };
678
679 app = new Hono<{ Variables: Variables }>();
680 app.route("/api/posts", createPostsRoutes(ctx));
681 });
682
683 afterEach(async () => {
684 await ctx.cleanup();
685 });
686
687 it("allows reply when topic is unlocked", async () => {
688 const res = await app.request("/api/posts", {
689 method: "POST",
690 headers: { "Content-Type": "application/json" },
691 body: JSON.stringify({
692 text: "Reply to unlocked topic",
693 rootPostId: topicId,
694 parentPostId: topicId,
695 }),
696 });
697
698 expect(res.status).toBe(201);
699 const data = await res.json();
700 expect(data.uri).toBeDefined();
701 });
702
703 it("blocks reply when topic is locked", async () => {
704 // Lock the topic
705 const lockRkey = TID.nextStr();
706 const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`;
707
708 await ctx.db.insert(modActions).values({
709 did: ctx.config.forumDid,
710 rkey: lockRkey,
711 cid: `bafy${lockRkey}`,
712 action: "space.atbb.modAction.lock",
713 subjectPostUri: topicUri,
714 createdBy: "did:plc:admin",
715 createdAt: new Date(),
716 indexedAt: new Date(),
717 });
718
719 const res = await app.request("/api/posts", {
720 method: "POST",
721 headers: { "Content-Type": "application/json" },
722 body: JSON.stringify({
723 text: "Reply to locked topic",
724 rootPostId: topicId,
725 parentPostId: topicId,
726 }),
727 });
728
729 expect(res.status).toBe(403);
730 const data = await res.json();
731 expect(data.error).toContain("locked");
732 });
733
734 it("allows reply when topic was locked then unlocked", async () => {
735 const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`;
736
737 // Lock the topic first
738 const lockRkey = TID.nextStr();
739 await ctx.db.insert(modActions).values({
740 did: ctx.config.forumDid,
741 rkey: lockRkey,
742 cid: `bafy${lockRkey}`,
743 action: "space.atbb.modAction.lock",
744 subjectPostUri: topicUri,
745 createdBy: "did:plc:admin",
746 createdAt: new Date("2024-01-01T00:00:00Z"),
747 indexedAt: new Date("2024-01-01T00:00:00Z"),
748 });
749
750 // Then unlock the topic (more recent action)
751 const unlockRkey = TID.nextStr();
752 await ctx.db.insert(modActions).values({
753 did: ctx.config.forumDid,
754 rkey: unlockRkey,
755 cid: `bafy${unlockRkey}`,
756 action: "space.atbb.modAction.unlock",
757 subjectPostUri: topicUri,
758 createdBy: "did:plc:admin",
759 createdAt: new Date("2024-01-02T00:00:00Z"),
760 indexedAt: new Date("2024-01-02T00:00:00Z"),
761 });
762
763 const res = await app.request("/api/posts", {
764 method: "POST",
765 headers: { "Content-Type": "application/json" },
766 body: JSON.stringify({
767 text: "Reply to re-opened topic",
768 rootPostId: topicId,
769 parentPostId: topicId,
770 }),
771 });
772
773 expect(res.status).toBe(201);
774 const data = await res.json();
775 expect(data.uri).toBeDefined();
776 });
777});