import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Hono } from "hono"; import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; import type { Variables } from "../../types.js"; import { posts, users, modActions } from "@atbb/db"; import { TID } from "@atproto/common-web"; import { SpaceAtbbPost as Post } from "@atbb/lexicon"; // Mock auth and permission middleware at the module level let mockPutRecord: ReturnType; let mockUser: any; vi.mock("../../middleware/auth.js", () => ({ requireAuth: vi.fn(() => async (c: any, next: any) => { c.set("user", mockUser); await next(); }), })); vi.mock("../../middleware/permissions.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, // Keep requireNotBanned real so ban enforcement tests work requirePermission: vi.fn(() => async (c: any, next: any) => { await next(); }), }; }); // Import after mocking const { createPostsRoutes } = await import("../posts.js"); describe("POST /api/posts", () => { let ctx: TestContext; let app: Hono<{ Variables: Variables }>; let topicId: string; let replyId: string; beforeEach(async () => { ctx = await createTestContext(); // Insert test user await ctx.db.insert(users).values({ did: "did:plc:test-user", handle: "testuser.test", indexedAt: new Date(), }); // Insert topic (root post) and get its ID const [topicPost] = await ctx.db .insert(posts) .values({ did: "did:plc:test-user", rkey: "3lbk7topic", cid: "bafytopic", text: "Topic post", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, createdAt: new Date(), indexedAt: new Date(), }) .returning({ id: posts.id }); // Store topic ID for tests topicId = topicPost.id.toString(); // Insert reply and get its ID const [replyPost] = await ctx.db .insert(posts) .values({ did: "did:plc:test-user", rkey: "3lbk8reply", cid: "bafyreply", text: "Reply post", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, rootPostId: topicPost.id, parentPostId: topicPost.id, rootUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", parentUri: "at://did:plc:test-user/space.atbb.post/3lbk7topic", createdAt: new Date(), indexedAt: new Date(), }) .returning({ id: posts.id }); // Store reply ID for tests replyId = replyPost.id.toString(); // Mock putRecord to track calls mockPutRecord = vi.fn(async () => ({ data: { uri: "at://did:plc:test-user/space.atbb.post/3lbk9test", cid: "bafytest", }, })); // Set up mock user for auth middleware mockUser = { did: "did:plc:test-user", handle: "testuser.test", pdsUrl: "https://test.pds", agent: { com: { atproto: { repo: { putRecord: mockPutRecord, }, }, }, }, }; app = new Hono<{ Variables: Variables }>(); app.route("/api/posts", createPostsRoutes(ctx)); }); afterEach(async () => { await ctx.cleanup(); }); it("creates reply to topic", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "My reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toBeTruthy(); expect(data.cid).toBeTruthy(); expect(data.rkey).toBeTruthy(); // Verify the reply ref written to PDS passes Post.isReplyRef() — the actual // contract the indexer uses. A string literal check on $type would pass even // with a typo that still breaks the indexer's runtime guard. const putCall = mockPutRecord.mock.calls[0][0]; expect(Post.isReplyRef(putCall.record.reply)).toBe(true); expect(putCall.record.reply.root.uri).toMatch(/^at:\/\/did:plc:.*\/space\.atbb\.post\//); expect(putCall.record.reply.parent.uri).toMatch(/^at:\/\/did:plc:.*\/space\.atbb\.post\//); // Verify the forum ref also has its $type discriminator, consistent with // replyRef and boardRef — all three use the same lexicon ref pattern. expect(Post.isForumRef(putCall.record.forum)).toBe(true); }); it("creates reply to reply", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Nested reply", rootPostId: topicId, parentPostId: replyId, // Reply to the reply }), }); expect(res.status).toBe(201); // Same isReplyRef contract check as the direct-reply case — nested replies // use the same construction path and must also include $type. const putCall = mockPutRecord.mock.calls[0][0]; expect(Post.isReplyRef(putCall.record.reply)).toBe(true); }); it("returns 400 for invalid parent ID format", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test", rootPostId: "not-a-number", parentPostId: "1", }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid"); }); it("returns 404 when root post does not exist", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test", rootPostId: "999", parentPostId: "999", }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("not found"); }); it("returns 404 when parent post does not exist", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test", rootPostId: topicId, parentPostId: "999", }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("not found"); }); it("returns 400 when parent belongs to different thread", async () => { // Insert a different topic and get its ID const [otherTopic] = await ctx.db .insert(posts) .values({ did: "did:plc:test-user", rkey: "3lbkaother", cid: "bafyother", text: "Other topic", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, createdAt: new Date(), indexedAt: new Date(), }) .returning({ id: posts.id }); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test", rootPostId: topicId, parentPostId: otherTopic.id.toString(), // Different thread }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("thread"); }); // Critical Issue #1: Test type guard for validatePostText it("returns 400 when text is missing", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rootPostId: topicId, parentPostId: topicId, }), // No text field }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Text is required"); }); it("returns 400 for non-string text (array)", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: ["not", "a", "string"], rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("must be a string"); }); // Critical Issue #2: Test malformed JSON handling it("returns 400 for malformed JSON", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: '{"text": "incomplete', }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); }); // Critical test coverage: PDS network errors (503) it("returns 503 when PDS connection fails (network error)", async () => { mockPutRecord.mockRejectedValueOnce(new Error("Network request failed")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); it("returns 503 when DNS resolution fails (ENOTFOUND)", async () => { mockPutRecord.mockRejectedValueOnce(new Error("getaddrinfo ENOTFOUND")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); it("returns 503 when request times out", async () => { mockPutRecord.mockRejectedValueOnce(new Error("timeout of 5000ms exceeded")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); // Critical test coverage: PDS server errors (500) it("returns 500 when PDS returns server error", async () => { mockPutRecord.mockRejectedValueOnce(new Error("PDS internal error")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Failed to create post"); }); it("returns 500 for unexpected errors", async () => { mockPutRecord.mockRejectedValueOnce(new Error("Unexpected error occurred")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Failed to create post"); }); it("returns 503 when database is unavailable during post creation", async () => { const helpers = await import("../helpers.js"); const getPostsByIdsSpy = vi.spyOn(helpers, "getPostsByIds"); getPostsByIdsSpy.mockRejectedValueOnce(new Error("Database connection lost")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Test reply", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); getPostsByIdsSpy.mockRestore(); }); }); describe("POST /api/posts - ban enforcement", () => { let ctx: TestContext; let app: Hono<{ Variables: Variables }>; let topicId: string; beforeEach(async () => { ctx = await createTestContext(); // Insert test user (use onConflictDoNothing in case tests share users) await ctx.db.insert(users).values({ did: "did:plc:ban-test-user", handle: "bantestuser.test", indexedAt: new Date(), }).onConflictDoNothing(); // Insert topic (root post) with unique rkey const banTopicRkey = TID.nextStr(); const [topicPost] = await ctx.db .insert(posts) .values({ did: "did:plc:ban-test-user", rkey: banTopicRkey, cid: `bafy${banTopicRkey}`, text: "Topic for ban tests", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, createdAt: new Date(), indexedAt: new Date(), }) .returning({ id: posts.id }); topicId = topicPost.id.toString(); // Set up mock user mockUser = { did: "did:plc:ban-test-user", handle: "bantestuser.test", pdsUrl: "https://test.pds", agent: { com: { atproto: { repo: { putRecord: vi.fn(async () => ({ data: { uri: "at://did:plc:ban-test-user/space.atbb.post/3lbkbanreply", cid: "bafybanreply", }, })), }, }, }, }, }; app = new Hono<{ Variables: Variables }>(); app.route("/api/posts", createPostsRoutes(ctx)); }); afterEach(async () => { await ctx.cleanup(); }); it("allows non-banned user to create reply", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply from non-banned user", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toBeDefined(); }); it("blocks banned user from creating reply", async () => { // Ban the user const banRkey = TID.nextStr(); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: banRkey, cid: `bafy${banRkey}`, action: "space.atbb.modAction.ban", subjectDid: mockUser.did, createdBy: "did:plc:admin", createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply from banned user", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toBe("You are banned from this forum"); }); it("returns 503 when ban check fails with database error", async () => { const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {}); const helpers = await import("../helpers.js"); const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); getActiveBansSpy.mockRejectedValueOnce(new Error("Database connection lost")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply attempt during DB error", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(consoleErrorSpy).toHaveBeenCalledWith( "Unable to verify ban status", expect.objectContaining({ operation: "POST /api/posts - ban check", userId: mockUser.did, error: "Database connection lost", }) ); consoleErrorSpy.mockRestore(); getActiveBansSpy.mockRestore(); }); it("returns 500 when ban check fails with unexpected error (fail closed)", async () => { const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {}); const helpers = await import("../helpers.js"); const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); getActiveBansSpy.mockRejectedValueOnce(new Error("Unexpected internal error")); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply attempt during unexpected error", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toBe("Unable to verify ban status. Please contact support if this persists."); expect(consoleErrorSpy).toHaveBeenCalledWith( "Unable to verify ban status", expect.objectContaining({ operation: "POST /api/posts - ban check", userId: mockUser.did, error: "Unexpected internal error", }) ); consoleErrorSpy.mockRestore(); getActiveBansSpy.mockRestore(); }); it("re-throws programming errors from ban check", async () => { const consoleErrorSpy = vi.spyOn(ctx.logger, "error").mockImplementation(() => {}); const helpers = await import("../helpers.js"); const getActiveBansSpy = vi.spyOn(helpers, "getActiveBans"); getActiveBansSpy.mockImplementationOnce(() => { throw new TypeError("Cannot read property 'has' of undefined"); }); // Hono catches re-thrown errors via its internal error handler const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply with programming error", rootPostId: topicId, parentPostId: topicId, }), }); // Hono's default error handler returns 500 for uncaught throws expect(res.status).toBe(500); // Verify CRITICAL error was logged (proves the re-throw path was executed) expect(consoleErrorSpy).toHaveBeenCalledWith( "CRITICAL: Programming error in POST /api/posts - ban check", expect.objectContaining({ operation: "POST /api/posts - ban check", userId: mockUser.did, error: "Cannot read property 'has' of undefined", stack: expect.any(String), }) ); // Verify the normal error path was NOT taken expect(consoleErrorSpy).not.toHaveBeenCalledWith( "Unable to verify ban status", expect.any(Object) ); consoleErrorSpy.mockRestore(); getActiveBansSpy.mockRestore(); }); }); describe("POST /api/posts - lock enforcement", () => { let ctx: TestContext; let app: Hono<{ Variables: Variables }>; let topicId: string; let topicRkey: string; beforeEach(async () => { ctx = await createTestContext(); // Insert test user (use onConflictDoNothing in case tests share users) await ctx.db.insert(users).values({ did: "did:plc:lock-test-user", handle: "locktestuser.test", indexedAt: new Date(), }).onConflictDoNothing(); // Insert topic (root post) with unique rkey topicRkey = TID.nextStr(); const [topicPost] = await ctx.db .insert(posts) .values({ did: "did:plc:lock-test-user", rkey: topicRkey, cid: `bafy${topicRkey}`, text: "Topic for lock tests", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, createdAt: new Date(), indexedAt: new Date(), }) .returning({ id: posts.id }); topicId = topicPost.id.toString(); // Set up mock user mockUser = { did: "did:plc:lock-test-user", handle: "locktestuser.test", pdsUrl: "https://test.pds", agent: { com: { atproto: { repo: { putRecord: vi.fn(async () => ({ data: { uri: "at://did:plc:lock-test-user/space.atbb.post/3lbklockreply", cid: "bafylockreply", }, })), }, }, }, }, }; app = new Hono<{ Variables: Variables }>(); app.route("/api/posts", createPostsRoutes(ctx)); }); afterEach(async () => { await ctx.cleanup(); }); it("allows reply when topic is unlocked", async () => { const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply to unlocked topic", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toBeDefined(); }); it("blocks reply when topic is locked", async () => { // Lock the topic const lockRkey = TID.nextStr(); const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`; await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: lockRkey, cid: `bafy${lockRkey}`, action: "space.atbb.modAction.lock", subjectPostUri: topicUri, createdBy: "did:plc:admin", createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply to locked topic", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toContain("locked"); }); it("allows reply when topic was locked then unlocked", async () => { const topicUri = `at://did:plc:lock-test-user/space.atbb.post/${topicRkey}`; // Lock the topic first const lockRkey = TID.nextStr(); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: lockRkey, cid: `bafy${lockRkey}`, action: "space.atbb.modAction.lock", subjectPostUri: topicUri, createdBy: "did:plc:admin", createdAt: new Date("2024-01-01T00:00:00Z"), indexedAt: new Date("2024-01-01T00:00:00Z"), }); // Then unlock the topic (more recent action) const unlockRkey = TID.nextStr(); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: unlockRkey, cid: `bafy${unlockRkey}`, action: "space.atbb.modAction.unlock", subjectPostUri: topicUri, createdBy: "did:plc:admin", createdAt: new Date("2024-01-02T00:00:00Z"), indexedAt: new Date("2024-01-02T00:00:00Z"), }); const res = await app.request("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Reply to re-opened topic", rootPostId: topicId, parentPostId: topicId, }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toBeDefined(); }); });