Files

128 lines
6.4 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 } from "@/app/api/posts/[id]/route";
import { GET as getSession } from "@/app/api/session/route";
import { FeedView } from "@/components/FeedView";
import { toCardPost, toCardPosts, canDelete } from "@/lib/feed-model";
import { DeleteError, deleteAndRemove, sendDelete } from "@/lib/delete-client";
import { loginToMinnect } from "@/lib/minnect";
import { createSession } from "@/lib/session-store";
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
const item = (id: string, authorId: string) => ({
type: "post", publishedAt: "2026-10-19T10:00:00Z",
post: { id, content: `text ${id}`, author: { id: authorId, displayName: "X" } },
});
const posts = () => toCardPosts([item("mine", "F1"), item("theirs", "F2")]);
function view(feedUserId: string | null) {
return renderToStaticMarkup(createElement(FeedView, {
posts: posts(), status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0,
onRetry: () => {}, onShowNew: () => {}, feedUserId, onDelete: () => {},
}));
}
function session(feedUserId: string | null = "F1") {
return createSession({
profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
feedAccessToken: "T", feedAccessTokenExpiresAt: null,
person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null, feedUserId }, createdAt: Date.now(),
});
}
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("delete control visibility", () => {
it("own post has the menu; someone else's has none", () => {
const html = view("F1");
expect(html.match(/aria-label="More actions"/g)).toHaveLength(1);
const cards = html.split('data-testid="post-card"');
expect(cards[1]).toContain("More actions");
expect(cards[2]).not.toContain("More actions");
});
it("no feed user id means no delete control anywhere", () => {
expect(view(null)).not.toContain("More actions");
expect(view("F9")).not.toContain("More actions");
expect(canDelete(toCardPost(item("a", "F1"))!, undefined)).toBe(false);
});
});
describe("DELETE /api/posts/:id", () => {
it("401 sign_in without a session", async () => {
const res = await DELETE(new NextRequest("http://localhost/api/posts/p1", { method: "DELETE" }), ctx("p1"));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
});
it("calls the feed DELETE and returns ok on 200", async () => {
const f = vi.fn(async () => new Response("{}", { status: 200 }));
const res = await withFetch(f as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: { cookie: `mfb_session=${session()}` } }), ctx("p1")));
expect(res.status).toBe(200);
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
expect(new URL(url).pathname).toBe("/api/v1/posts/p1");
expect(init.method).toBe("DELETE");
});
it("a feed failure is 502 delete_failed; a bad id never reaches the feed", async () => {
const h = { cookie: `mfb_session=${session()}` };
const bad = vi.fn(async () => new Response("{}", { status: 500 }));
const r = await withFetch(bad as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1")));
expect([r.status, await r.json()]).toEqual([502, { error: "delete_failed" }]);
const f = vi.fn();
const r2 = await withFetch(f as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("../admin/x")));
expect(r2.status).toBe(400);
expect(f).not.toHaveBeenCalled();
});
});
describe("removing the card", () => {
it("the card leaves the list after a 200", async () => {
const ok = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch;
const list = posts();
const next = await deleteAndRemove(list, list[0], (id) => sendDelete(id, ok));
expect(next.map((p) => p.id)).toEqual(["theirs"]);
});
it("a failed delete throws and the caller keeps the card", async () => {
const fail = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch;
const list = posts();
await expect(deleteAndRemove(list, list[0], (id) => sendDelete(id, fail))).rejects.toBeInstanceOf(DeleteError);
expect(list).toHaveLength(2);
const out = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch;
await expect(sendDelete("mine", out)).rejects.toMatchObject({ signedOut: true });
});
});
describe("feed user id", () => {
it("GET /api/session exposes feedUserId and no token", async () => {
const res = await getSession(new NextRequest("http://localhost/api/session", { headers: { cookie: `mfb_session=${session("F1")}` } }));
const body = await res.json();
expect(body).toEqual({ name: "A", email: "a@b.co", pictureUrl: null, feedUserId: "F1" });
});
it("sign-in captures data.id from the feed auth/me", async () => {
const f = vi.fn(async (url: string) => {
const u = String(url);
if (u.endsWith("/api/v4/auth/login")) return new Response(JSON.stringify({ data: { accessToken: "a", refreshToken: "r", expiresIn: 3600 } }), { status: 200 });
if (u.endsWith("/api/v4/auth/me")) return new Response(JSON.stringify({ data: { userId: "u1", email: "a@b.co", firstName: "A" } }), { status: 200 });
if (u.endsWith("/external-services/feed")) return new Response(JSON.stringify({ accessToken: "ft" }), { status: 200 });
if (u.endsWith("/api/v1/auth/me")) return new Response(JSON.stringify({ data: { id: "F1" } }), { status: 200 });
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch;
const r = await loginToMinnect("a@b.co", "pw", { fetchImpl: f });
expect(r.person.feedUserId).toBe("F1");
const down = vi.fn(async (url: string) => String(url).endsWith("/api/v1/auth/me") ? new Response("{}", { status: 500 }) : (f as unknown as (u: string) => Promise<Response>)(url)) as unknown as typeof fetch;
expect((await loginToMinnect("a@b.co", "pw", { fetchImpl: down })).person.feedUserId).toBeNull();
});
});