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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,14 +11,12 @@ export async function PageContent({ slug }: { slug: string }) {
|
||||
const controller = marketingPagesContainer.get<IGetPageBySlugController>(
|
||||
MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
|
||||
);
|
||||
const page = await controller({ slug });
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["marketingPages", "pageBySlug", { input: { slug } }],
|
||||
page,
|
||||
);
|
||||
|
||||
if (!page) {
|
||||
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>
|
||||
@@ -26,6 +26,14 @@ export async function PageContent({ slug }: { slug: string }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.setQueryData(
|
||||
["marketingPages", "pageBySlug", { input: { slug } }],
|
||||
page,
|
||||
);
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user