4wswt4ex2: Add like and unlike for post cards
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user