diff --git a/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts b/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts index 38fa26e..f5f259e 100644 --- a/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts +++ b/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts @@ -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 () => { diff --git a/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts b/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts index c7cbef7..9b1f831 100644 --- a/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts +++ b/packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts @@ -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; // ── Output ─────────────────────────────────────────────────────────────── @@ -16,8 +19,12 @@ export type IGetPageBySlugUseCase = ReturnType; export const getPageBySlugUseCase = (pagesRepository: IPagesRepository) => - async (input: GetPageBySlugInput): Promise => { + async (input: GetPageBySlugInput): Promise => { 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); }; diff --git a/packages/marketing-pages/src/integrations/api/router.test.ts b/packages/marketing-pages/src/integrations/api/router.test.ts index b3c48f4..1d63757 100644 --- a/packages/marketing-pages/src/integrations/api/router.test.ts +++ b/packages/marketing-pages/src/integrations/api/router.test.ts @@ -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"); + } }); }); diff --git a/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.test.ts b/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.test.ts index 2c488e1..b710eef 100644 --- a/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.test.ts +++ b/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.test.ts @@ -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, + ); }); }); diff --git a/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.ts b/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.ts index 6309de6..f4e5e12 100644 --- a/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.ts +++ b/packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.ts @@ -9,16 +9,19 @@ function presenter(value: GetPageBySlugOutput) { return value; } -export type IGetPageBySlugController = ReturnType; +export type IGetPageBySlugController = ReturnType< + typeof getPageBySlugController +>; export const getPageBySlugController = (getPageBySlugUseCase: IGetPageBySlugUseCase) => - async (input: unknown): Promise | undefined> => { + async (input: unknown): Promise> => { 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); }; diff --git a/packages/marketing-pages/src/ui/components/page-content.server.tsx b/packages/marketing-pages/src/ui/components/page-content.server.tsx index f2a353f..db13f38 100644 --- a/packages/marketing-pages/src/ui/components/page-content.server.tsx +++ b/packages/marketing-pages/src/ui/components/page-content.server.tsx @@ -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( MARKETING_PAGES_SYMBOLS.IGetPageBySlugController, ); - const page = await controller({ slug }); + + let page: GetPageBySlugOutput; + try { + page = await controller({ slug }); + } catch (err) { + if (err instanceof PageNotFoundError) { + return ( +
+

Not found

+

+ This page hasn't been published yet. +

+
+ ); + } + throw err; + } + const queryClient = getQueryClient(); queryClient.setQueryData( ["marketingPages", "pageBySlug", { input: { slug } }], page, ); - if (!page) { - return ( -
-

Not found

-

- This page hasn't been published yet. -

-
- ); - } - return ( diff --git a/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts index 62787d5..284dbc0 100644 --- a/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts +++ b/packages/marketing-pages/src/ui/hooks/use-page-by-slug.ts @@ -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 }; }