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 { ZodError } from "zod";
|
||||||
import { getPageBySlugUseCase } from "@/application/use-cases/get-page-by-slug.use-case";
|
import { getPageBySlugUseCase } from "@/application/use-cases/get-page-by-slug.use-case";
|
||||||
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
||||||
|
import { PageNotFoundError } from "@/entities/errors/page";
|
||||||
|
|
||||||
describe("getPageBySlugUseCase", () => {
|
describe("getPageBySlugUseCase", () => {
|
||||||
it("returns the page when found", async () => {
|
it("returns the page when found", async () => {
|
||||||
const repo = new MockPagesRepository();
|
const repo = new MockPagesRepository();
|
||||||
const useCase = getPageBySlugUseCase(repo);
|
const useCase = getPageBySlugUseCase(repo);
|
||||||
const result = await useCase({ slug: "about" });
|
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 repo = new MockPagesRepository();
|
||||||
const useCase = getPageBySlugUseCase(repo);
|
const useCase = getPageBySlugUseCase(repo);
|
||||||
const result = await useCase({ slug: "missing-page" });
|
await expect(useCase({ slug: "missing-page" })).rejects.toBeInstanceOf(
|
||||||
expect(result).toBeUndefined();
|
PageNotFoundError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ZodError when repository returns malformed page data", async () => {
|
it("throws ZodError when repository returns malformed page data", async () => {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { PageNotFoundError } from "../../entities/errors/page";
|
||||||
import { pageSchema } from "../../entities/models/page";
|
import { pageSchema } from "../../entities/models/page";
|
||||||
import type { IPagesRepository } from "../repositories/pages.repository.interface";
|
import type { IPagesRepository } from "../repositories/pages.repository.interface";
|
||||||
|
|
||||||
// ── Input ────────────────────────────────────────────────────────────────
|
// ── 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>;
|
export type GetPageBySlugInput = z.infer<typeof getPageBySlugInputSchema>;
|
||||||
|
|
||||||
// ── Output ───────────────────────────────────────────────────────────────
|
// ── Output ───────────────────────────────────────────────────────────────
|
||||||
@@ -16,8 +19,12 @@ export type IGetPageBySlugUseCase = ReturnType<typeof getPageBySlugUseCase>;
|
|||||||
|
|
||||||
export const getPageBySlugUseCase =
|
export const getPageBySlugUseCase =
|
||||||
(pagesRepository: IPagesRepository) =>
|
(pagesRepository: IPagesRepository) =>
|
||||||
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput | undefined> => {
|
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput> => {
|
||||||
const page = await pagesRepository.getPageBySlug(input.slug);
|
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);
|
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 caller = marketingPagesRouter.createCaller({});
|
||||||
const result = await caller.pageBySlug({ slug: "does-not-exist" });
|
try {
|
||||||
expect(result).toBeUndefined();
|
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 { getPageBySlugUseCase } from "@/application/use-cases/get-page-by-slug.use-case";
|
||||||
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
import { MockPagesRepository } from "@/infrastructure/repositories/pages.repository.mock";
|
||||||
import { InputParseError } from "@/entities/errors/common";
|
import { InputParseError } from "@/entities/errors/common";
|
||||||
|
import { PageNotFoundError } from "@/entities/errors/page";
|
||||||
|
|
||||||
describe("getPageBySlugController", () => {
|
describe("getPageBySlugController", () => {
|
||||||
it("returns the page when found", async () => {
|
it("returns the page when found", async () => {
|
||||||
@@ -11,7 +12,7 @@ describe("getPageBySlugController", () => {
|
|||||||
const controller = getPageBySlugController(useCase);
|
const controller = getPageBySlugController(useCase);
|
||||||
|
|
||||||
const result = await controller({ slug: "about" });
|
const result = await controller({ slug: "about" });
|
||||||
expect(result?.slug).toBe("about");
|
expect(result.slug).toBe("about");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws InputParseError on missing slug", async () => {
|
it("throws InputParseError on missing slug", async () => {
|
||||||
@@ -19,17 +20,16 @@ describe("getPageBySlugController", () => {
|
|||||||
const useCase = getPageBySlugUseCase(repo);
|
const useCase = getPageBySlugUseCase(repo);
|
||||||
const controller = getPageBySlugController(useCase);
|
const controller = getPageBySlugController(useCase);
|
||||||
|
|
||||||
await expect(
|
await expect(controller({})).rejects.toBeInstanceOf(InputParseError);
|
||||||
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 repo = new MockPagesRepository();
|
||||||
const useCase = getPageBySlugUseCase(repo);
|
const useCase = getPageBySlugUseCase(repo);
|
||||||
const controller = getPageBySlugController(useCase);
|
const controller = getPageBySlugController(useCase);
|
||||||
|
|
||||||
const result = await controller({ slug: "nonexistent" });
|
await expect(controller({ slug: "nonexistent" })).rejects.toBeInstanceOf(
|
||||||
expect(result).toBeUndefined();
|
PageNotFoundError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ function presenter(value: GetPageBySlugOutput) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IGetPageBySlugController = ReturnType<typeof getPageBySlugController>;
|
export type IGetPageBySlugController = ReturnType<
|
||||||
|
typeof getPageBySlugController
|
||||||
|
>;
|
||||||
|
|
||||||
export const getPageBySlugController =
|
export const getPageBySlugController =
|
||||||
(getPageBySlugUseCase: IGetPageBySlugUseCase) =>
|
(getPageBySlugUseCase: IGetPageBySlugUseCase) =>
|
||||||
async (input: unknown): Promise<ReturnType<typeof presenter> | undefined> => {
|
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = getPageBySlugInputSchema.safeParse(input);
|
const parsed = getPageBySlugInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
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);
|
const result = await getPageBySlugUseCase(parsed.data);
|
||||||
if (result === undefined) return undefined;
|
|
||||||
return presenter(result);
|
return presenter(result);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
|||||||
import { getQueryClient } from "@repo/core-trpc";
|
import { getQueryClient } from "@repo/core-trpc";
|
||||||
import { marketingPagesContainer } from "../../di/container";
|
import { marketingPagesContainer } from "../../di/container";
|
||||||
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
|
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 type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller";
|
||||||
import { PageContent as PageContentClient } from "./page-content.client";
|
import { PageContent as PageContentClient } from "./page-content.client";
|
||||||
|
|
||||||
@@ -9,24 +11,30 @@ export async function PageContent({ slug }: { slug: string }) {
|
|||||||
const controller = marketingPagesContainer.get<IGetPageBySlugController>(
|
const controller = marketingPagesContainer.get<IGetPageBySlugController>(
|
||||||
MARKETING_PAGES_SYMBOLS.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't been published yet.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["marketingPages", "pageBySlug", { input: { slug } }],
|
["marketingPages", "pageBySlug", { input: { slug } }],
|
||||||
page,
|
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't been published yet.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<PageContentClient slug={slug} />
|
<PageContentClient slug={slug} />
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { Page } from "../../entities/models/page";
|
|||||||
|
|
||||||
export function usePageBySlug(slug: string) {
|
export function usePageBySlug(slug: string) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
// A missing slug now rejects with NOT_FOUND (PageNotFoundError), so a
|
||||||
|
// resolved query always carries a Page.
|
||||||
return useSuspenseQuery(
|
return useSuspenseQuery(
|
||||||
trpc.marketingPages.pageBySlug.queryOptions({ slug }),
|
trpc.marketingPages.pageBySlug.queryOptions({ slug }),
|
||||||
) as { data: Page | null };
|
) as { data: Page };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user