import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("server-only", () => ({})); const mocks = vi.hoisted(() => ({ limit: vi.fn(), set: vi.fn(), eval: vi.fn() })); vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit })); vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: mocks.set, eval: mocks.eval }) })); import { withCommentSpamProtection } from "./spam"; import { HttpError } from "@/lib/security/http"; describe("comment spam protection", () => { beforeEach(() => { vi.clearAllMocks(); mocks.limit.mockResolvedValue(undefined); mocks.set.mockResolvedValue("OK"); mocks.eval.mockResolvedValue(1); }); it("limits all writes and retains the duplicate reservation on success", async () => { const publish = vi.fn().mockResolvedValue({ id: "saved" }); await expect(withCommentSpamProtection("author", "Hello", false, publish)).resolves.toEqual({ id: "saved" }); expect(mocks.limit.mock.calls).toEqual([ ["comment-write-hour", "author", 30, 3600], ["comment-write-minute", "author", 5], ["comment-write-cooldown", "author", 1, 5], ]); expect(mocks.set).toHaveBeenCalledWith(expect.any(String), expect.any(String), "EX", 300, "NX"); expect(mocks.eval).not.toHaveBeenCalled(); }); it("uses the same private duplicate key for normalized text across targets", async () => { await withCommentSpamProtection("author", " HELLO\u200b\n world", false, async () => undefined); await withCommentSpamProtection("author", "hello world", false, async () => undefined); expect(mocks.set.mock.calls[0][0]).toBe(mocks.set.mock.calls[1][0]); expect(mocks.set.mock.calls[0][0]).not.toContain("hello"); await withCommentSpamProtection("other", "hello world", false, async () => undefined); expect(mocks.set.mock.calls[2][0]).not.toBe(mocks.set.mock.calls[0][0]); }); it("rejects simultaneous or recent duplicate posts before uploading or saving", async () => { mocks.set.mockResolvedValue(null); const publish = vi.fn(); await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429, message: "comment-duplicate", retryAfter: 300 }); expect(publish).not.toHaveBeenCalled(); }); it("releases only its own reservation when publication fails", async () => { const failure = new Error("upload failed"); await expect(withCommentSpamProtection("author", "Hello", false, async () => { throw failure; })).rejects.toBe(failure); const [key, token] = mocks.set.mock.calls[0]; expect(mocks.eval).toHaveBeenCalledWith(expect.stringContaining("ARGV[1]"), 1, key, token); }); it("blocks abuse in edits and leaves persistence untouched", async () => { const publish = vi.fn(); await expect(withCommentSpamProtection("author", "fuck you", true, publish)).rejects.toMatchObject({ status: 400, message: "comment-abusive-language" }); expect(publish).not.toHaveBeenCalled(); expect(mocks.set).not.toHaveBeenCalled(); }); it.each([true, false])("keeps edits and image-only comments usable while rate limiting them (editing=%s)", async (editing) => { const publish = vi.fn().mockResolvedValue("saved"); await expect(withCommentSpamProtection("author", editing ? "same text" : "", editing, publish)).resolves.toBe("saved"); expect(mocks.limit).toHaveBeenCalledTimes(3); expect(mocks.set).not.toHaveBeenCalled(); }); it("stops publication if the shared rate limit or Redis is unavailable", async () => { const publish = vi.fn(); mocks.limit.mockRejectedValueOnce(new HttpError(429, "too-many-requests", 5)); await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toMatchObject({ status: 429 }); mocks.set.mockRejectedValueOnce(new Error("Redis unavailable")); await expect(withCommentSpamProtection("author", "Hello", false, publish)).rejects.toThrow("Redis unavailable"); expect(publish).not.toHaveBeenCalled(); }); });