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

@@ -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) } : {}),