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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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