test(i18n): add translated page CRUD locale validation coverage
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
|||||||
Reference in New Issue
Block a user