fix(marketing-pages): throw PageNotFoundError for missing slugs

getPageBySlug returned undefined while the feature already mapped
PageNotFoundError to NOT_FOUND and blog throws for missing slugs (B11).
Unify on the throwing contract: use case throws, controller narrows to
a non-optional presenter, the server component catches the domain error
to render its not-found state, and the router now surfaces NOT_FOUND.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:25:41 +02:00
parent dec24feaa0
commit bab8fb634f
7 changed files with 61 additions and 34 deletions

View File

@@ -2,20 +2,22 @@ import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { getPageBySlugUseCase } from "@/application/use-cases/get-page-by-slug.use-case";
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
import { PageNotFoundError } from "@/entities/errors/page";
describe("getPageBySlugUseCase", () => {
it("returns the page when found", async () => {
const repo = new MockPagesRepository();
const useCase = getPageBySlugUseCase(repo);
const result = await useCase({ slug: "about" });
expect(result?.slug).toBe("about");
expect(result.slug).toBe("about");
});
it("returns undefined when not found", async () => {
it("throws PageNotFoundError when not found", async () => {
const repo = new MockPagesRepository();
const useCase = getPageBySlugUseCase(repo);
const result = await useCase({ slug: "missing-page" });
expect(result).toBeUndefined();
await expect(useCase({ slug: "missing-page" })).rejects.toBeInstanceOf(
PageNotFoundError,
);
});
it("throws ZodError when repository returns malformed page data", async () => {

View File

@@ -1,10 +1,13 @@
import { z } from "zod";
import { PageNotFoundError } from "../../entities/errors/page";
import { pageSchema } from "../../entities/models/page";
import type { IPagesRepository } from "../repositories/pages.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const getPageBySlugInputSchema = z.object({ slug: z.string().min(1) }).strict();
export const getPageBySlugInputSchema = z
.object({ slug: z.string().min(1) })
.strict();
export type GetPageBySlugInput = z.infer<typeof getPageBySlugInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
@@ -16,8 +19,12 @@ export type IGetPageBySlugUseCase = ReturnType<typeof getPageBySlugUseCase>;
export const getPageBySlugUseCase =
(pagesRepository: IPagesRepository) =>
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput | undefined> => {
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput> => {
const page = await pagesRepository.getPageBySlug(input.slug);
if (!page) return undefined;
if (!page) {
// Same not-found contract as blog: throw the mapped domain error
// (translated to NOT_FOUND by the feature's tRPC error middleware).
throw new PageNotFoundError(`Page with slug "${input.slug}" not found`);
}
return getPageBySlugOutputSchema.parse(page);
};

View File

@@ -54,9 +54,14 @@ describe("marketingPagesRouter error mapping", () => {
}
});
it("returns undefined (not NOT_FOUND) for missing slug since use case returns undefined", async () => {
it("translates PageNotFoundError → NOT_FOUND for missing slug", async () => {
const caller = marketingPagesRouter.createCaller({});
const result = await caller.pageBySlug({ slug: "does-not-exist" });
expect(result).toBeUndefined();
try {
await caller.pageBySlug({ slug: "does-not-exist" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("NOT_FOUND");
}
});
});

View File

@@ -3,6 +3,7 @@ import { getPageBySlugController } from "@/interface-adapters/controllers/get-pa
import { getPageBySlugUseCase } from "@/application/use-cases/get-page-by-slug.use-case";
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
import { InputParseError } from "@/entities/errors/common";
import { PageNotFoundError } from "@/entities/errors/page";
describe("getPageBySlugController", () => {
it("returns the page when found", async () => {
@@ -11,7 +12,7 @@ describe("getPageBySlugController", () => {
const controller = getPageBySlugController(useCase);
const result = await controller({ slug: "about" });
expect(result?.slug).toBe("about");
expect(result.slug).toBe("about");
});
it("throws InputParseError on missing slug", async () => {
@@ -19,17 +20,16 @@ describe("getPageBySlugController", () => {
const useCase = getPageBySlugUseCase(repo);
const controller = getPageBySlugController(useCase);
await expect(
controller({}),
).rejects.toBeInstanceOf(InputParseError);
await expect(controller({})).rejects.toBeInstanceOf(InputParseError);
});
it("returns undefined when slug not found", async () => {
it("throws PageNotFoundError when slug not found", async () => {
const repo = new MockPagesRepository();
const useCase = getPageBySlugUseCase(repo);
const controller = getPageBySlugController(useCase);
const result = await controller({ slug: "nonexistent" });
expect(result).toBeUndefined();
await expect(controller({ slug: "nonexistent" })).rejects.toBeInstanceOf(
PageNotFoundError,
);
});
});

View File

@@ -9,16 +9,19 @@ function presenter(value: GetPageBySlugOutput) {
return value;
}
export type IGetPageBySlugController = ReturnType<typeof getPageBySlugController>;
export type IGetPageBySlugController = ReturnType<
typeof getPageBySlugController
>;
export const getPageBySlugController =
(getPageBySlugUseCase: IGetPageBySlugUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter> | undefined> => {
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getPageBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-page-by-slug input", { cause: parsed.error });
throw new InputParseError("Invalid get-page-by-slug input", {
cause: parsed.error,
});
}
const result = await getPageBySlugUseCase(parsed.data);
if (result === undefined) return undefined;
return presenter(result);
};

View File

@@ -2,6 +2,8 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@repo/core-trpc";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import { PageNotFoundError } from "../../entities/errors/page";
import type { GetPageBySlugOutput } from "../../application/use-cases/get-page-by-slug.use-case";
import type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller";
import { PageContent as PageContentClient } from "./page-content.client";
@@ -9,24 +11,30 @@ export async function PageContent({ slug }: { slug: string }) {
const controller = marketingPagesContainer.get<IGetPageBySlugController>(
MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
);
const page = await controller({ slug });
let page: GetPageBySlugOutput;
try {
page = await controller({ slug });
} catch (err) {
if (err instanceof PageNotFoundError) {
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-3xl font-bold text-foreground">Not found</h1>
<p className="mt-2 text-muted-foreground">
This page hasn&apos;t been published yet.
</p>
</main>
);
}
throw err;
}
const queryClient = getQueryClient();
queryClient.setQueryData(
["marketingPages", "pageBySlug", { input: { slug } }],
page,
);
if (!page) {
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-3xl font-bold text-foreground">Not found</h1>
<p className="mt-2 text-muted-foreground">
This page hasn&apos;t been published yet.
</p>
</main>
);
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageContentClient slug={slug} />

View File

@@ -6,7 +6,9 @@ import type { Page } from "../../entities/models/page";
export function usePageBySlug(slug: string) {
const trpc = useTRPC();
// A missing slug now rejects with NOT_FOUND (PageNotFoundError), so a
// resolved query always carries a Page.
return useSuspenseQuery(
trpc.marketingPages.pageBySlug.queryOptions({ slug }),
) as { data: Page | null };
) as { data: Page };
}