This commit is contained in:
Your Name committed 2026-10-01 15:42:52 +07:00
1 parent 2d39edbd57
commit 78b722e120
24 files changed
+2130 -65

No files matched your search

@@ -0,0 +1,318 @@
import { describe, expect, test } from "bun:test";
import {
createCustomProductDetailSection,
createProductMainDetailSection,
isProductDetailSectionType,
validateProductDetailSectionPayload,
} from "./productDetailSections";
const validHeroCarouselPayload = {
slides: [
{
imageUrl: "/uploads/products/hero-wide.webp",
mobileImageUrl: "/uploads/products/hero-mobile.webp",
linkUrl: "/products/2",
alt: { th: "ภาพโปรโมชัน", en: "Promotion" },
title: { th: "สินค้าแนะนำ", en: "Featured product" },
subtitle: { th: "ข้อความรอง", en: "Supporting copy" },
buttonText: { th: "ดูสินค้า", en: "View product" },
focalPoint: { x: "50%", y: "45%" },
},
],
autoplay: true,
interval: 4500,
aspectRatio: "21/7",
mobileAspectRatio: "16/9",
showArrows: true,
showDots: true,
};
describe("product detail section contract", () => {
test("allows DIVIDER with the shared strict presentation contract", () => {
expect(isProductDetailSectionType("DIVIDER")).toBe(true);
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
});
expect(result.success).toBe(true);
});
test("rejects an out-of-range DIVIDER thickness", () => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "full",
style: "dashed",
thickness: 5,
spacing: "lg",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["thickness"]);
}
});
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
"accepts product DIVIDER color %s",
(color) => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "dotted",
thickness: 2,
spacing: "lg",
color,
});
expect(result.success).toBe(true);
},
);
test.each(["transparent", "#12", "#12345", "#123456789", "#000; border-image: url(javascript:alert(1))"])(
"rejects unsafe product DIVIDER color %s",
(color) => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "dotted",
thickness: 2,
spacing: "lg",
color,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["color"]);
expect(result.issues[0]?.message).toBe(
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
);
}
},
);
test("allows HERO_CAROUSEL for product detail sections", () => {
expect(isProductDetailSectionType("HERO_CAROUSEL")).toBe(true);
const result = validateProductDetailSectionPayload(
"HERO_CAROUSEL",
validHeroCarouselPayload,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validHeroCarouselPayload);
}
});
test("uses the shared HERO_CAROUSEL schema to reject an empty slide list", () => {
const result = validateProductDetailSectionPayload("HERO_CAROUSEL", {
slides: [],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["slides"]);
}
});
test("rejects page-only section types even when they have a page schema", () => {
const result = validateProductDetailSectionPayload("CONTACT_FORM", {
title: { en: "Contact" },
});
expect(result.success).toBe(false);
expect(isProductDetailSectionType("CONTACT_FORM")).toBe(false);
});
});
describe("custom product detail section creation", () => {
test("materializes defaults before adding the first custom section", async () => {
const operations: string[] = [];
const createdRows: Array<Record<string, unknown>> = [];
const tx = {
productDetailSection: {
async count() {
operations.push("count");
return 0;
},
async createMany(args: { data: Array<Record<string, unknown>> }) {
operations.push("createMany");
createdRows.push(...args.data);
return { count: args.data.length };
},
async findFirst(args: { where?: { type?: string } }) {
operations.push("findFirst");
if (args.where?.type) return null;
return { order: 5 };
},
async update() {
throw new Error("update should not be called");
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create(args: { data: Record<string, unknown> }) {
operations.push("create");
createdRows.push(args.data);
return { id: 99, ...args.data };
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "HERO_CAROUSEL",
payload: validHeroCarouselPayload,
isActive: true,
isLocked: false,
});
expect(operations).toEqual(["count", "createMany", "findFirst", "findFirst", "create"]);
expect(createdRows).toHaveLength(6);
expect(createdRows.slice(0, 5).map((row) => row.type)).toEqual([
"PRODUCT_MAIN",
"PRODUCT_DETAILS",
"PRODUCT_SPECS",
"PRODUCT_REVIEWS",
"RELATED_PRODUCTS",
]);
expect(created).toMatchObject({
id: 99,
productId: 2,
type: "HERO_CAROUSEL",
order: 6,
isActive: true,
isLocked: false,
});
});
test("does not duplicate defaults when sections already exist", async () => {
let createManyCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 5;
},
async createMany() {
createManyCalls += 1;
return { count: 5 };
},
async findFirst() {
return { order: 8 };
},
async update() {
throw new Error("update should not be called");
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create(args: { data: Record<string, unknown> }) {
return args.data;
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "HERO_CAROUSEL",
payload: validHeroCarouselPayload,
isActive: true,
isLocked: false,
});
expect(createManyCalls).toBe(0);
expect(created).toMatchObject({ order: 9 });
});
test("updates a matching seeded default instead of creating a duplicate", async () => {
let createCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 0;
},
async createMany() {
return { count: 5 };
},
async findFirst(args: { where?: { type?: string } }) {
if (args.where?.type === "PRODUCT_DETAILS") {
return { id: 12, order: 2, type: "PRODUCT_DETAILS" };
}
return { order: 5 };
},
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
return { id: args.where.id, productId: 2, type: "PRODUCT_DETAILS", order: 2, ...args.data };
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create() {
createCalls += 1;
return {};
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "PRODUCT_DETAILS",
payload: { title: { en: "Details" }, showHeading: true },
isActive: true,
isLocked: false,
});
expect(createCalls).toBe(0);
expect(created).toMatchObject({
id: 12,
productId: 2,
type: "PRODUCT_DETAILS",
order: 2,
payload: { title: { en: "Details" }, showHeading: true },
});
});
});
describe("product main section creation", () => {
test("materializes all defaults and updates the seeded main section", async () => {
let createCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 0;
},
async createMany(args: { data: Array<Record<string, unknown>> }) {
expect(args.data).toHaveLength(5);
return { count: args.data.length };
},
async findFirst() {
return { id: 10, order: 1, type: "PRODUCT_MAIN" };
},
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
return { id: args.where.id, productId: 2, type: "PRODUCT_MAIN", ...args.data };
},
async updateMany() {
throw new Error("updateMany should not be called for an empty product");
},
async create() {
createCalls += 1;
return {};
},
},
};
const payload = { layoutMode: "standard" };
const created = await createProductMainDetailSection(tx, 2, payload);
expect(createCalls).toBe(0);
expect(created).toMatchObject({
id: 10,
productId: 2,
type: "PRODUCT_MAIN",
order: 1,
payload,
isActive: true,
isLocked: true,
});
});
});
@@ -0,0 +1,171 @@
import {
PAGE_SECTION_PAYLOAD_SCHEMAS,
PRODUCT_DETAIL_SECTION_TYPES,
type ProductDetailSectionType,
} from "@homaroma/types";
import type { z } from "zod";
export type ProductDetailSectionValidationResult =
| { success: true; type: ProductDetailSectionType; data: unknown }
| { success: false; issues: z.ZodIssue[] };
export function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
interface ProductDetailSectionTransaction {
productDetailSection: {
count(args: any): Promise<number>;
createMany(args: any): Promise<unknown>;
findFirst(args: any): Promise<{ id?: number; order: number; type?: string } | null>;
update(args: any): Promise<any>;
updateMany(args: any): Promise<unknown>;
create(args: any): Promise<any>;
};
}
interface CreateCustomProductDetailSectionInput {
productId: number;
type: Exclude<ProductDetailSectionType, "PRODUCT_MAIN">;
payload: unknown;
order?: number;
isActive: boolean;
isLocked: boolean;
}
export async function createCustomProductDetailSection(
tx: ProductDetailSectionTransaction,
input: CreateCustomProductDetailSectionInput,
) {
const existingCount = await tx.productDetailSection.count({
where: { productId: input.productId },
});
if (existingCount === 0) {
await tx.productDetailSection.createMany({
data: defaultProductDetailSections(input.productId),
});
const seededSection = await tx.productDetailSection.findFirst({
where: { productId: input.productId, type: input.type },
});
if (seededSection?.id !== undefined) {
return await tx.productDetailSection.update({
where: { id: seededSection.id },
data: {
payload: input.payload,
isActive: input.isActive,
isLocked: input.isLocked,
...(input.order !== undefined ? { order: input.order } : {}),
},
});
}
}
const lastSection = await tx.productDetailSection.findFirst({
where: { productId: input.productId },
orderBy: { order: "desc" },
});
return await tx.productDetailSection.create({
data: {
productId: input.productId,
type: input.type,
order: input.order ?? (lastSection?.order || 0) + 1,
payload: input.payload,
isActive: input.isActive,
isLocked: input.isLocked,
},
});
}
export async function createProductMainDetailSection(
tx: ProductDetailSectionTransaction,
productId: number,
payload: unknown,
) {
const existingCount = await tx.productDetailSection.count({ where: { productId } });
if (existingCount === 0) {
await tx.productDetailSection.createMany({
data: defaultProductDetailSections(productId),
});
const seededMain = await tx.productDetailSection.findFirst({
where: { productId, type: "PRODUCT_MAIN" },
});
if (seededMain?.id === undefined) {
throw new Error("Seeded PRODUCT_MAIN section was not found");
}
return await tx.productDetailSection.update({
where: { id: seededMain.id },
data: { payload, order: 1, isActive: true, isLocked: true },
});
}
await tx.productDetailSection.updateMany({
where: { productId },
data: { order: { increment: 1 } },
});
return await tx.productDetailSection.create({
data: {
productId,
type: "PRODUCT_MAIN",
order: 1,
payload,
isActive: true,
isLocked: true,
},
});
}
export function isProductDetailSectionType(
type: string,
): type is ProductDetailSectionType {
return (PRODUCT_DETAIL_SECTION_TYPES as readonly string[]).includes(type);
}
export function validateProductDetailSectionPayload(
type: string,
payload: unknown,
): ProductDetailSectionValidationResult {
const normalizedType = type.toUpperCase();
if (!isProductDetailSectionType(normalizedType)) {
return {
success: false,
issues: [
{
code: "custom",
message: `Unknown product detail section type: ${type}`,
path: [],
},
] as z.ZodIssue[],
};
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return {
success: false,
issues: [
{
code: "custom",
message: "Payload must be a JSON object",
path: [],
},
] as z.ZodIssue[],
};
}
const result = PAGE_SECTION_PAYLOAD_SCHEMAS[normalizedType].safeParse(payload);
if (!result.success) {
return { success: false, issues: result.error.issues };
}
return { success: true, type: normalizedType, data: result.data };
}
@@ -2,8 +2,68 @@ import { describe, expect, test } from "bun:test";
import {
mergeSectionTranslations,
transformLocalizedPaths,
validateSectionPayload,
} from "./pages";
describe("Page section payload validation", () => {
test("accepts a DIVIDER payload", () => {
const result = validateSectionPayload("DIVIDER", {
width: "narrow",
style: "dotted",
thickness: 2,
spacing: "xl",
});
expect(result.success).toBe(true);
});
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
"accepts DIVIDER color %s",
(color) => {
const result = validateSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
color,
});
expect(result.success).toBe(true);
},
);
test.each(["red", "#12", "#12345", "#123456789", "#fff; background: url(javascript:alert(1))"])(
"rejects unsafe DIVIDER color %s",
(color) => {
const result = validateSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
color,
});
expect(result.success).toBe(false);
expect(result.issues?.[0]?.path).toEqual(["color"]);
expect(result.issues?.[0]?.message).toBe(
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
);
},
);
test("rejects an unsupported DIVIDER width", () => {
const result = validateSectionPayload("DIVIDER", {
width: "wide",
style: "solid",
thickness: 1,
spacing: "sm",
});
expect(result.success).toBe(false);
expect(result.issues?.[0]?.path).toEqual(["width"]);
});
});
describe("Page section localization", () => {
const paths = ["title", "items[].title", "items[].note"];
const payload = {
+4
View File
@@ -14,6 +14,7 @@ import {
FeaturedTilesSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroCarouselSectionPayloadSchema,
DividerSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ArticleHeaderSectionPayloadSchema,
@@ -1335,6 +1336,9 @@ export function validateSectionPayload(
case "HERO_CAROUSEL":
schema = HeroCarouselSectionPayloadSchema;
break;
case "DIVIDER":
schema = DividerSectionPayloadSchema;
break;
case "HERO_ARTICLE":
schema = HeroArticleSectionPayloadSchema;
break;
+24 -47
View File
@@ -1,7 +1,14 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { localizeProductAttribute } from "../lib/productAttributes";
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
import {
createCustomProductDetailSection,
createProductMainDetailSection,
defaultProductDetailSections,
validateProductDetailSectionPayload,
} from "../lib/productDetailSections";
import { hydrateSectionPayload } from "./pages";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
@@ -11,16 +18,6 @@ function summarizeValidationIssues(issues: any[]) {
}));
}
function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
async function assertActiveCategory(categoryId: number, client: any = prisma) {
const category = await client.category.findFirst({
where: { id: categoryId, deletedAt: null },
@@ -508,7 +505,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any;
const type = String(payload.type || "").toUpperCase();
const sectionPayload = payload.payload || {};
const validationResult = validateSectionPayload(type, sectionPayload);
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
@@ -520,7 +517,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const isProductMain = type === "PRODUCT_MAIN";
const validatedType = validationResult.type;
const isProductMain = validatedType === "PRODUCT_MAIN";
if (isProductMain) {
const existingMain = await prisma.productDetailSection.count({
where: { productId: id, type: "PRODUCT_MAIN" },
@@ -530,38 +528,16 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
}
const lastSection = await prisma.productDetailSection.findFirst({
where: { productId: id },
orderBy: { order: "desc" },
});
const section = isProductMain
? (await prisma.$transaction([
prisma.productDetailSection.updateMany({
where: { productId: id },
data: { order: { increment: 1 } },
}),
prisma.productDetailSection.create({
data: {
productId: id,
type,
order: 1,
payload: validationResult.data,
isActive: true,
isLocked: true,
},
}),
]))[1]
: await prisma.productDetailSection.create({
data: {
productId: id,
type,
order: Number.isFinite(Number(payload.order)) ? Number(payload.order) : (lastSection?.order || 0) + 1,
payload: validationResult.data,
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
},
});
? await prisma.$transaction((tx: Prisma.TransactionClient) => createProductMainDetailSection(tx, id, validationResult.data))
: await prisma.$transaction((tx: Prisma.TransactionClient) => createCustomProductDetailSection(tx, {
productId: id,
type: validatedType,
payload: validationResult.data,
...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}),
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
}));
return { success: true, section };
})
@@ -575,7 +551,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any;
const type = String(payload.type || existing.type).toUpperCase();
const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
const validationResult = validateSectionPayload(type, sectionPayload);
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
@@ -584,8 +560,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
});
}
const validatedType = validationResult.type;
if (existing.isLocked) {
if (payload.type && type !== existing.type) {
if (payload.type && validatedType !== existing.type) {
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
}
if (payload.isActive === false) {
@@ -606,7 +583,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const section = await prisma.productDetailSection.update({
where: { id: sectionId },
data: {
type,
type: validatedType,
payload: validationResult.data,
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),