import { describe, expect, it, vi } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { NextRequest } from "next/server"; import { DELETE, POST } from "@/app/api/posts/[id]/like/route"; import { PostCard } from "@/components/PostCard"; import { toCardPost, type CardPost } from "@/lib/feed-model"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { createSession } from "@/lib/session-store"; const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); const card = (extra: Partial = {}): CardPost => ({ ...toCardPost({ type: "post", publishedAt: "2026-10-19T10:00:00Z", likesCount: 3, post: { id: "p1", content: "hi", author: {}, isLiked: false } })!, ...extra, }); function session() { return createSession({ profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r", feedAccessToken: "T", feedAccessTokenExpiresAt: null, person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(), }); } /** A fake feed that keeps real like state: a second like never raises the count. */ function fakeFeed(withCount: boolean) { const state = { liked: false, count: 3, calls: [] as string[] }; const fn = vi.fn(async (url: string, init?: RequestInit) => { state.calls.push(`${init?.method} ${new URL(String(url), "http://x").pathname}`); if (init?.method === "POST" && !state.liked) { state.liked = true; state.count += 1; } if (init?.method === "DELETE" && state.liked) { state.liked = false; state.count -= 1; } const data: Record = { success: true }; if (withCount) data.likesCount = state.count; return new Response(JSON.stringify({ data }), { status: 200 }); }); return { state, fn }; } async function withFetch(fn: typeof fetch, run: () => Promise) { const orig = globalThis.fetch; globalThis.fetch = fn; try { return await run(); } finally { globalThis.fetch = orig; } } describe("like routes", () => { it("401 sign_in without a session", async () => { for (const h of [POST, DELETE]) { const res = await h(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST" }), ctx("p1")); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: "sign_in" }); } }); it("like then unlike hit the matching feed routes and return server state", async () => { const sid = session(); const feed = fakeFeed(true); const h = { cookie: `mfb_session=${sid}` }; await withFetch(feed.fn as unknown as typeof fetch, async () => { const a = await POST(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST", headers: h }), ctx("p1")); expect(await a.json()).toEqual({ isLiked: true, likesCount: 4 }); const b = await DELETE(new NextRequest("http://localhost/api/posts/p1/like", { method: "DELETE", headers: h }), ctx("p1")); expect(await b.json()).toEqual({ isLiked: false, likesCount: 3 }); }); expect(feed.state.calls).toEqual(["POST /api/v1/posts/p1/like", "DELETE /api/v1/posts/p1/like"]); }); it("a second like does not raise the count and is not retried", async () => { const sid = session(); const feed = fakeFeed(true); const h = { cookie: `mfb_session=${sid}` }; await withFetch(feed.fn as unknown as typeof fetch, async () => { await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); expect(await r.json()).toEqual({ isLiked: true, likesCount: 4 }); }); expect(feed.fn).toHaveBeenCalledTimes(2); }); it("returns likesCount null when the feed carries none; failure is 502 like_failed", async () => { const sid = session(); const h = { cookie: `mfb_session=${sid}` }; await withFetch(fakeFeed(false).fn as unknown as typeof fetch, async () => { const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); expect(await r.json()).toEqual({ isLiked: true, likesCount: null }); }); const bad = vi.fn(async () => new Response("{}", { status: 500 })) as unknown as typeof fetch; await withFetch(bad, async () => { const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1")); expect(r.status).toBe(502); expect(await r.json()).toEqual({ error: "like_failed" }); }); const notOk = vi.fn(async () => new Response(JSON.stringify({ data: { success: false } }), { status: 200 })) as unknown as typeof fetch; await withFetch(notOk, async () => { const r = await DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1")); expect(r.status).toBe(502); }); }); it("rejects a post id that is not a plain id", async () => { const sid = session(); const f = vi.fn(); await withFetch(f as unknown as typeof fetch, async () => { const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: { cookie: `mfb_session=${sid}` } }), ctx("../admin/x")); expect(r.status).toBe(400); }); expect(f).not.toHaveBeenCalled(); }); }); describe("card like state", () => { it("reads isLiked from the post", () => { const c = toCardPost({ type: "post", post: { id: "1", isLiked: true, author: {} } }); expect(c?.isLiked).toBe(true); }); it("like then unlike through the client matches server state", async () => { const feed = fakeFeed(true); const proxy = vi.fn(async (url: string, init?: RequestInit) => { const r = await feed.fn(url, init); const b = (await r.json()) as { data: { likesCount: number } }; return new Response(JSON.stringify({ isLiked: init?.method === "POST", likesCount: b.data.likesCount }), { status: 200 }); }) as unknown as typeof fetch; let c = card(); c = applyLikeOutcome(c, true, await sendLike("p1", true, proxy)); expect([c.isLiked, c.likesCount]).toEqual([true, 4]); c = applyLikeOutcome(c, false, await sendLike("p1", false, proxy)); expect([c.isLiked, c.likesCount]).toEqual([false, 3]); }); it("without a server count, flips and adjusts by one; a repeat does not double count", () => { let c = card(); c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null }); expect([c.isLiked, c.likesCount]).toEqual([true, 4]); c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null }); expect(c.likesCount).toBe(4); c = applyLikeOutcome(c, false, { isLiked: false, likesCount: null }); expect([c.isLiked, c.likesCount]).toEqual([false, 3]); }); it("a failed call throws and the previous card is untouched", async () => { const before = card(); const f = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch; await expect(sendLike("p1", true, f)).rejects.toBeInstanceOf(LikeError); expect(before.isLiked).toBe(false); expect(before.likesCount).toBe(3); const signedOut = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch; await expect(sendLike("p1", true, signedOut)).rejects.toMatchObject({ signedOut: true }); }); it("renders outlined vs filled heart, pressed state, and the action error", () => { const off = renderToStaticMarkup(createElement(PostCard, { post: card(), onToggleLike: () => {} })); expect(off).toContain('aria-pressed="false"'); expect(off).toContain('fill="none"'); expect(off).not.toContain("Could not update like."); const on = renderToStaticMarkup(createElement(PostCard, { post: card({ isLiked: true }), onToggleLike: () => {}, likeError: true })); expect(on).toContain('aria-pressed="true"'); expect(on).toContain('fill="currentColor"'); expect(on).toContain("Could not update like."); }); });