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 };
}