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
at root/atb-54-add-lightdark-mode-toggle 777 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 // 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});