/** * Unit tests for `notFound()` / `isNotFoundResponse()` (Phase 6.3). * * Mirrors the shape of the redirect() tests (see packages/core/tests/server/ * redirect-loader.test.ts). These stay local to the helper — integration * coverage that exercises the full SSR pipeline lives in the server test * at packages/core/tests/server/not-found-page.test.ts. */ import { describe, it, expect } from "bun:test"; import { notFound, isNotFoundResponse, NOT_FOUND_BRAND, } from "../not-found"; import { redirect } from "../redirect"; import { ManduFilling } from "../../filling/filling"; import { ManduContext } from "../../filling/context"; describe("notFound()", () => { it("returns a Response with status 404", () => { const res = notFound(); expect(res).toBeInstanceOf(Response); expect(res.status).toBe(404); }); it("carries the provided message in its body", async () => { const res = notFound({ message: "Post not found" }); const body = await res.text(); expect(body).toBe("Post not found"); }); it("is recognized by isNotFoundResponse()", () => { expect(isNotFoundResponse(notFound())).toBe(true); expect(isNotFoundResponse(notFound({ message: "x" }))).toBe(true); }); it("rejects a bare `new Response(null, { status: 404 })` (brand check)", () => { const bare = new Response(null, { status: 404 }); expect(isNotFoundResponse(bare)).toBe(false); // Double-check: something we know is 404 but not ours (e.g. upstream proxy) const proxied = new Response("upstream 404", { status: 404, headers: { "Content-Type": "text/plain" }, }); expect(isNotFoundResponse(proxied)).toBe(false); }); it("rejects a redirect() Response (notFound is a distinct brand)", () => { expect(isNotFoundResponse(redirect("/foo"))).toBe(false); }); it("rejects null / undefined / {} / primitives (type guard is strict)", () => { expect(isNotFoundResponse(null)).toBe(false); expect(isNotFoundResponse(undefined)).toBe(false); expect(isNotFoundResponse({})).toBe(false); expect(isNotFoundResponse("string")).toBe(false); expect(isNotFoundResponse(404)).toBe(false); expect(isNotFoundResponse(new Error("not a response"))).toBe(false); }); it("status is always 404 regardless of the message", () => { expect(notFound().status).toBe(404); expect(notFound({ message: "a" }).status).toBe(404); expect(notFound({ message: "" }).status).toBe(404); // empty still 404 expect(notFound({ message: "x".repeat(1000) }).status).toBe(404); }); it("body is text/plain (not JSON) — explicit content-type contract", async () => { const res = notFound({ message: "missing" }); const ct = res.headers.get("content-type") ?? ""; expect(ct).toContain("text/plain"); expect(ct).toContain("charset=utf-8"); // Body must be the literal message, not a JSON envelope. expect(await res.text()).toBe("missing"); }); it("default body (no args) is the literal 'Not Found'", async () => { const res = notFound(); expect(await res.text()).toBe("Not Found"); }); it("loader returning notFound() is picked up by isNotFoundResponse", async () => { const filling = new ManduFilling(); filling.loader(() => notFound({ message: "post gone" })); const ctx = new ManduContext(new Request("http://test/post/1"), { id: "1" }); const returned = await filling.executeLoader(ctx); expect(isNotFoundResponse(returned)).toBe(true); // And the body survived. const body = await (returned as Response).text(); expect(body).toBe("post gone"); }); it("loader throwing notFound() is picked up the same way (Remix idiom)", async () => { const filling = new ManduFilling(); filling.loader(() => { throw notFound({ message: "thrown" }); }); const ctx = new ManduContext(new Request("http://test/x"), {}); let caught: unknown = null; try { await filling.executeLoader(ctx); } catch (e) { caught = e; } expect(isNotFoundResponse(caught)).toBe(true); expect(await (caught as Response).text()).toBe("thrown"); }); it("integration simulation: loadPageData-like branch distinguishes return vs throw", async () => { // Emulates the exact shape of the SSR pipeline's try/catch: `returned` // and `thrown` both route through `isNotFoundResponse`. This is the // contract the server integration relies on. const returnCase = notFound({ message: "A" }); const throwCase = notFound({ message: "B" }); // Both must be recognised. expect(isNotFoundResponse(returnCase)).toBe(true); expect(isNotFoundResponse(throwCase)).toBe(true); // And each preserves its own message (no shared mutable state). expect(await returnCase.text()).toBe("A"); expect(await throwCase.text()).toBe("B"); }); it("accepts a call with no arguments and still produces a valid sentinel", () => { const res = notFound(); expect(res).toBeInstanceOf(Response); expect(res.status).toBe(404); expect(isNotFoundResponse(res)).toBe(true); }); it("exports NOT_FOUND_BRAND as a unique symbol keyed to the package", () => { // The brand must be a Symbol.for() so multiple copies of @mandujs/core // (e.g. monorepo duplication) agree on identity. If this changes we've // broken cross-package detection — regression guard. expect(typeof NOT_FOUND_BRAND).toBe("symbol"); expect(Symbol.keyFor(NOT_FOUND_BRAND)).toBe("@mandujs/core/not-found"); }); it("two separate notFound() calls produce independently-branded Responses", () => { // WeakSet membership is per-instance — the brand on one must not // leak to another. Important for test isolation. const a = notFound(); const b = notFound(); expect(a).not.toBe(b); expect(isNotFoundResponse(a)).toBe(true); expect(isNotFoundResponse(b)).toBe(true); }); });