x
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user