Files

161 lines
7.8 KiB
TypeScript

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> = {}): 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<string, unknown> = { success: true };
if (withCount) data.likesCount = state.count;
return new Response(JSON.stringify({ data }), { status: 200 });
});
return { state, fn };
}
async function withFetch<T>(fn: typeof fetch, run: () => Promise<T>) {
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.");
});
});