test(i18n): add translated page CRUD locale validation coverage

This commit is contained in:
2026-02-12 20:53:06 +01:00
parent 749fb80083
commit 506e2feb10
7 changed files with 185 additions and 1 deletions

View File

@@ -190,7 +190,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Component tests for admin forms (pages/media/navigation) - [x] [P1] Component tests for admin forms (pages/media/navigation)
- [x] [P1] Integration tests for owner invariant and hidden support-user protection - [x] [P1] Integration tests for owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior - [x] [P1] Integration tests for registration allow/deny behavior
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation - [x] [P1] Integration tests for translated content CRUD and locale-specific validation
- [~] [P1] E2E happy paths: create page, publish, see on public app - [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display - [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions - [~] [P1] E2E happy paths: commissions kanban transitions
@@ -321,6 +321,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`). - [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
- [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`. - [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`.
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests. - [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
## How We Use This File ## How We Use This File

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"]) export const pageStatusSchema = z.enum(["draft", "published"])
export const pageLocaleSchema = z.enum(["de", "en", "es", "fr"])
export const createPageInputSchema = z.object({ export const createPageInputSchema = z.object({
title: z.string().min(1).max(180), title: z.string().min(1).max(180),
@@ -23,6 +24,16 @@ export const updatePageInputSchema = z.object({
seoDescription: z.string().max(320).nullable().optional(), seoDescription: z.string().max(320).nullable().optional(),
}) })
export const upsertPageTranslationInputSchema = z.object({
pageId: z.string().uuid(),
locale: pageLocaleSchema,
title: z.string().min(1).max(180),
summary: z.string().max(500).nullable().optional(),
content: z.string().min(1),
seoTitle: z.string().max(180).nullable().optional(),
seoDescription: z.string().max(320).nullable().optional(),
})
export const createNavigationMenuInputSchema = z.object({ export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180), name: z.string().min(1).max(180),
slug: z.string().min(1).max(180), slug: z.string().min(1).max(180),
@@ -52,6 +63,7 @@ export const updateNavigationItemInputSchema = z.object({
export type CreatePageInput = z.infer<typeof createPageInputSchema> export type CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema> export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type UpsertPageTranslationInput = z.infer<typeof upsertPageTranslationInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema> export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema> export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema> export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>

View File

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "PageTranslation" (
"id" TEXT NOT NULL,
"pageId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"seoTitle" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PageTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PageTranslation_locale_idx" ON "PageTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PageTranslation_pageId_locale_key" ON "PageTranslation"("pageId", "locale");
-- AddForeignKey
ALTER TABLE "PageTranslation" ADD CONSTRAINT "PageTranslation_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -267,10 +267,28 @@ model Page {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
navItems NavigationItem[] navItems NavigationItem[]
translations PageTranslation[]
@@index([status]) @@index([status])
} }
model PageTranslation {
id String @id @default(uuid())
pageId String
locale String
title String
summary String?
content String
seoTitle String?
seoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, locale])
@@index([locale])
}
model NavigationMenu { model NavigationMenu {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String

View File

@@ -41,12 +41,15 @@ export {
deletePage, deletePage,
getPageById, getPageById,
getPublishedPageBySlug, getPublishedPageBySlug,
getPublishedPageBySlugForLocale,
listNavigationMenus, listNavigationMenus,
listPages, listPages,
listPageTranslations,
listPublicNavigation, listPublicNavigation,
listPublishedPageSlugs, listPublishedPageSlugs,
updateNavigationItem, updateNavigationItem,
updatePage, updatePage,
upsertPageTranslation,
} from "./pages-navigation" } from "./pages-navigation"
export { export {
createPost, createPost,

View File

@@ -7,6 +7,11 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
pageTranslation: {
upsert: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
}, },
navigationMenu: { navigationMenu: {
@@ -30,8 +35,10 @@ import {
createNavigationItem, createNavigationItem,
createNavigationMenu, createNavigationMenu,
createPage, createPage,
getPublishedPageBySlugForLocale,
listPublicNavigation, listPublicNavigation,
updatePage, updatePage,
upsertPageTranslation,
} from "./pages-navigation" } from "./pages-navigation"
describe("pages-navigation service", () => { describe("pages-navigation service", () => {
@@ -120,4 +127,63 @@ describe("pages-navigation service", () => {
}, },
]) ])
}) })
it("validates locale when upserting page translation", async () => {
await expect(() =>
upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
content: "Contenuto",
}),
).rejects.toThrow()
})
it("upserts page translation and reads localized page with fallback", async () => {
mockDb.pageTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.page.findFirst
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [
{
locale: "de",
title: "Uber Uns",
summary: "Zusammenfassung",
content: "Inhalt",
seoTitle: "SEO DE",
seoDescription: "Beschreibung",
},
],
})
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [],
})
await upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Uber Uns",
content: "Inhalt",
})
const translated = await getPublishedPageBySlugForLocale("about", "de")
const fallback = await getPublishedPageBySlugForLocale("about", "fr")
expect(mockDb.pageTranslation.upsert).toHaveBeenCalledTimes(1)
expect(translated?.title).toBe("Uber Uns")
expect(translated?.content).toBe("Inhalt")
expect(fallback?.title).toBe("About")
expect(fallback?.content).toBe("Base content")
})
}) })

View File

@@ -4,6 +4,7 @@ import {
createPageInputSchema, createPageInputSchema,
updateNavigationItemInputSchema, updateNavigationItemInputSchema,
updatePageInputSchema, updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content" } from "@cms/content"
import { db } from "./client" import { db } from "./client"
@@ -54,6 +55,38 @@ export async function getPublishedPageBySlug(slug: string) {
}) })
} }
export async function getPublishedPageBySlugForLocale(slug: string, locale: string) {
const page = await db.page.findFirst({
where: {
slug,
status: "published",
},
include: {
translations: {
where: {
locale,
},
take: 1,
},
},
})
if (!page) {
return null
}
const translation = page.translations[0]
return {
...page,
title: translation?.title ?? page.title,
summary: translation?.summary ?? page.summary,
content: translation?.content ?? page.content,
seoTitle: translation?.seoTitle ?? page.seoTitle,
seoDescription: translation?.seoDescription ?? page.seoDescription,
}
}
export async function createPage(input: unknown) { export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input) const payload = createPageInputSchema.parse(input)
@@ -85,6 +118,33 @@ export async function deletePage(id: string) {
}) })
} }
export async function upsertPageTranslation(input: unknown) {
const payload = upsertPageTranslationInputSchema.parse(input)
const { pageId, locale, ...data } = payload
return db.pageTranslation.upsert({
where: {
pageId_locale: {
pageId,
locale,
},
},
create: {
pageId,
locale,
...data,
},
update: data,
})
}
export async function listPageTranslations(pageId: string) {
return db.pageTranslation.findMany({
where: { pageId },
orderBy: [{ locale: "asc" }],
})
}
export async function listNavigationMenus() { export async function listNavigationMenus() {
return db.navigationMenu.findMany({ return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }], orderBy: [{ location: "asc" }, { name: "asc" }],