This commit is contained in:
Your Name committed 2026-10-02 07:10:25 +07:00
1 parent 75770ca122
commit f258e3e176
16 files changed
+1207 -348

No files matched your search

@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import {
assertProductImageIdsBelongToProduct,
normalizeProductImageInput,
normalizeProductImagesInput,
ProductImageConflictError,
ProductImagePayloadError,
} from './productImages'
describe('product image request normalization', () => {
test('normalizes mixed legacy and paired image arrays', () => {
expect(normalizeProductImagesInput([
'/uploads/products/legacy.webp',
{
url: '/uploads/products/desktop.webp',
mobileUrl: '/uploads/products/mobile.webp',
order: 4,
},
])).toEqual([
{ url: '/uploads/products/legacy.webp' },
{
url: '/uploads/products/desktop.webp',
mobileUrl: '/uploads/products/mobile.webp',
order: 4,
},
])
})
test('preserves omitted mobile URLs and normalizes empty values to null', () => {
expect(normalizeProductImageInput({ url: '/desktop.webp' }).mobileUrl).toBeUndefined()
expect(normalizeProductImageInput({ url: '/desktop.webp', mobileUrl: '' }).mobileUrl).toBeNull()
})
test('returns indexed validation detail for invalid URL types', () => {
try {
normalizeProductImagesInput([{ url: '/desktop.webp' }, { url: false }])
throw new Error('Expected normalization to fail')
} catch (error) {
expect(error).toBeInstanceOf(ProductImagePayloadError)
expect((error as ProductImagePayloadError).issues[0]?.path).toBe('images.1.url')
}
})
test('rejects duplicate and foreign image IDs before destructive synchronization', () => {
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 1])).toThrow(ProductImageConflictError)
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 3])).toThrow(ProductImageConflictError)
expect(() => assertProductImageIdsBelongToProduct([1, 2], [2, 1])).not.toThrow()
})
})
+92
View File
@@ -0,0 +1,92 @@
import {
ProductImageInputSchema,
type NormalizedProductImageInput,
} from '@homaroma/types'
export class ProductImagePayloadError extends Error {
readonly code = 'INVALID_PRODUCT_IMAGES'
readonly issues: Array<{ path: string; message: string }>
constructor(index: number, issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>) {
super(`Invalid product image at index ${index}`)
this.name = 'ProductImagePayloadError'
const flattenedIssues = flattenValidationIssues(issues)
const detailedIssues = flattenedIssues.some((issue) => issue.path.length > 0)
? flattenedIssues.filter((issue) => issue.path.length > 0)
: flattenedIssues
this.issues = detailedIssues.map((issue) => ({
path: ['images', index, ...issue.path].join('.'),
message: issue.message,
}))
}
}
export class ProductImageConflictError extends Error {
readonly code = 'PRODUCT_IMAGE_CONFLICT'
constructor(message: string) {
super(message)
this.name = 'ProductImageConflictError'
}
}
function flattenValidationIssues(
issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>,
): Array<{ path: PropertyKey[]; message: string }> {
return issues.flatMap((issue) => {
if (!Array.isArray(issue.errors)) {
return [{ path: issue.path, message: issue.message }]
}
const nested = issue.errors.flatMap((branch) =>
Array.isArray(branch)
? flattenValidationIssues(branch as Array<{ path: PropertyKey[]; message: string; errors?: unknown }>)
: [],
)
return nested.length > 0 ? nested : [{ path: issue.path, message: issue.message }]
})
}
export function normalizeProductImageInput(
input: unknown,
index = 0,
): NormalizedProductImageInput {
const result = ProductImageInputSchema.safeParse(input)
if (!result.success) {
throw new ProductImagePayloadError(index, result.error.issues)
}
return result.data
}
export function normalizeProductImagesInput(input: unknown): NormalizedProductImageInput[] {
if (!Array.isArray(input)) {
throw new ProductImagePayloadError(0, [{
path: [],
message: 'Product images must be an array',
}])
}
return input.map((image, index) => normalizeProductImageInput(image, index))
}
export function assertProductImageIdsBelongToProduct(
existingIds: number[],
incomingIds: number[],
) {
if (new Set(incomingIds).size !== incomingIds.length) {
throw new ProductImageConflictError('Product image IDs must be unique')
}
const existing = new Set(existingIds)
const unknownIds = incomingIds.filter((id) => !existing.has(id))
if (unknownIds.length > 0) {
throw new ProductImageConflictError(`Product images changed or do not belong to this product: ${unknownIds.join(', ')}`)
}
}
export function productImageErrorResponse(error: ProductImagePayloadError) {
return {
success: false as const,
message: 'Invalid product images',
issues: error.issues,
}
}
@@ -0,0 +1,187 @@
import { describe, expect, test } from 'bun:test'
import {
normalizeProductTranslationCreate,
normalizeProductTranslationUpdate,
ProductTranslationPayloadError,
syncProductTranslations,
} from './productTranslations'
describe('product translation update normalization', () => {
test('normalizes multilingual objects and syncs the Thai legacy fields', () => {
expect(normalizeProductTranslationUpdate({
name: { th: 'ชื่อสินค้า', en: 'Product name' },
description: { th: 'คำอธิบาย', ja: '商品説明' },
})).toEqual({
base: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
byLanguage: {
th: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
en: { name: 'Product name' },
ja: { description: '商品説明' },
},
})
})
test('treats strings as Thai values', () => {
expect(normalizeProductTranslationUpdate({
name: 'ชื่อใหม่',
details: 'รายละเอียดใหม่',
})).toEqual({
base: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
byLanguage: {
th: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
},
})
})
test('preserves omitted fields and locales', () => {
expect(normalizeProductTranslationUpdate({ description: { en: 'Updated only' } })).toEqual({
base: {},
byLanguage: { en: { description: 'Updated only' } },
})
})
test('preserves explicit empty strings as intentional updates', () => {
expect(normalizeProductTranslationUpdate({
name: { th: '' },
description: '',
details: { en: '' },
})).toEqual({
base: { name: '', description: '' },
byLanguage: {
th: { name: '', description: '' },
en: { details: '' },
},
})
})
test.each([
['__proto__', JSON.parse('{"name":{"__proto__":"polluted"}}')],
['constructor', { name: { constructor: 'polluted' } }],
['fr', { name: { fr: 'Nom du produit' } }],
])('rejects unsupported or unsafe locale key %s', (locale, payload) => {
expect(() => normalizeProductTranslationUpdate(payload)).toThrow(ProductTranslationPayloadError)
try {
normalizeProductTranslationUpdate(payload)
throw new Error('Expected translation normalization to fail')
} catch (error) {
expect(error).toBeInstanceOf(ProductTranslationPayloadError)
expect((error as ProductTranslationPayloadError).code).toBe('INVALID_PRODUCT_TRANSLATIONS')
expect((error as ProductTranslationPayloadError).issues[0]?.path).toBe(`name.${locale}`)
}
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
})
})
describe('product translation transaction synchronization', () => {
test('writes every locale through the supplied transaction client', async () => {
const calls: unknown[] = []
const tx = {
productTranslation: {
upsert: async (args: unknown) => {
calls.push(args)
return args
},
},
}
const plan = normalizeProductTranslationUpdate({
name: { th: 'ชื่อใหม่', en: 'New name' },
description: { en: '' },
})
await syncProductTranslations(tx as never, 2, plan, 'ชื่อเดิม')
expect(calls).toHaveLength(2)
expect(calls).toContainEqual({
where: { productId_langCode: { productId: 2, langCode: 'th' } },
update: { name: 'ชื่อใหม่' },
create: {
productId: 2,
langCode: 'th',
name: 'ชื่อใหม่',
},
})
expect(calls).toContainEqual({
where: { productId_langCode: { productId: 2, langCode: 'en' } },
update: { name: 'New name', description: '' },
create: {
productId: 2,
langCode: 'en',
name: 'New name',
description: '',
},
})
})
test('uses the current product name when creating a description-only locale', async () => {
let call: any
const tx = {
productTranslation: {
upsert: async (args: unknown) => {
call = args
return args
},
},
}
await syncProductTranslations(
tx as never,
2,
normalizeProductTranslationUpdate({ description: { ja: '説明' } }),
'Fallback name',
)
expect(call.create.name).toBe('Fallback name')
expect(call.update).toEqual({ description: '説明' })
})
})
describe('product translation create normalization', () => {
test('preserves nested translations and lets direct localized fields take precedence', () => {
expect(normalizeProductTranslationCreate({
name: { th: 'ชื่อไทย', en: 'Direct English' },
description: { th: 'คำอธิบายไทย' },
translations: {
en: { name: 'Nested English', description: 'Nested description' },
ja: { name: '日本語名' },
},
})).toEqual({
base: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
byLanguage: {
en: { name: 'Direct English', description: 'Nested description', details: '' },
ja: { name: '日本語名', description: 'คำอธิบายไทย', details: '' },
th: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
},
})
})
test('creates the legacy Thai fallback row when no translations are supplied', () => {
expect(normalizeProductTranslationCreate({ name: 'New product' })).toEqual({
base: { name: 'New product', description: '', details: '' },
byLanguage: {
th: { name: 'New product', description: '', details: '' },
},
})
})
test('does not add a Thai row for string fields when nested translations select other locales', () => {
expect(normalizeProductTranslationCreate({
name: 'Fallback product',
translations: { en: { name: 'English product' } },
})).toEqual({
base: { name: 'Fallback product', description: '', details: '' },
byLanguage: {
en: { name: 'English product', description: '', details: '' },
},
})
})
test.each(['__proto__', 'constructor', 'fr'])(
'rejects unsafe or unsupported nested translation locale %s',
(locale) => {
const payload = JSON.parse(`{"translations":{"${locale}":{"name":"unsafe"}}}`)
expect(() => normalizeProductTranslationCreate(payload)).toThrow(ProductTranslationPayloadError)
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
},
)
})
+179
View File
@@ -0,0 +1,179 @@
import type { Prisma } from '@prisma/client'
const TRANSLATABLE_FIELDS = ['name', 'description', 'details'] as const
export const PRODUCT_TRANSLATION_LOCALES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
const PRODUCT_TRANSLATION_LOCALE_SET = new Set<string>(PRODUCT_TRANSLATION_LOCALES)
type TranslatableField = (typeof TRANSLATABLE_FIELDS)[number]
type TranslationValues = Partial<Record<TranslatableField, string>>
interface ProductTranslationCreatePlan {
base: Required<TranslationValues>
byLanguage: Record<string, Required<TranslationValues>>
}
export interface ProductTranslationUpdatePlan {
base: TranslationValues
byLanguage: Record<string, TranslationValues>
}
export class ProductTranslationPayloadError extends Error {
readonly code = 'INVALID_PRODUCT_TRANSLATIONS'
readonly issues: Array<{ path: string; message: string }>
constructor(path: string, message: string) {
super(message)
this.name = 'ProductTranslationPayloadError'
this.issues = [{ path, message }]
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function assertSupportedLocale(fieldPath: string, langCode: string) {
if (!PRODUCT_TRANSLATION_LOCALE_SET.has(langCode)) {
throw new ProductTranslationPayloadError(
`${fieldPath}.${langCode}`,
`Unsupported product translation locale: ${langCode}`,
)
}
}
function legacyBaseValue(rawValue: unknown, fallback: string) {
if (typeof rawValue === 'string') return rawValue
if (!isRecord(rawValue)) return fallback
const thai = typeof rawValue.th === 'string' ? rawValue.th : ''
const english = typeof rawValue.en === 'string' ? rawValue.en : ''
return thai || english || fallback
}
/**
* Normalizes the two request shapes supported by product writes:
* - a string updates Thai
* - an object updates only the explicitly supplied locales
*
* Empty strings are intentional values. Missing fields/locales are excluded so
* partial updates never erase translations that were not sent by the caller.
*/
export function normalizeProductTranslationUpdate(
payload: Record<string, unknown>,
): ProductTranslationUpdatePlan {
const plan: ProductTranslationUpdatePlan = {
base: {},
byLanguage: Object.create(null) as Record<string, TranslationValues>,
}
for (const field of TRANSLATABLE_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
const rawValue = payload[field]
if (typeof rawValue === 'string') {
plan.byLanguage.th ??= {}
plan.byLanguage.th[field] = rawValue
plan.base[field] = rawValue
continue
}
if (!isRecord(rawValue)) continue
for (const [langCode, localizedValue] of Object.entries(rawValue)) {
assertSupportedLocale(field, langCode)
if (typeof localizedValue !== 'string') continue
plan.byLanguage[langCode] ??= {}
plan.byLanguage[langCode][field] = localizedValue
if (langCode === 'th') plan.base[field] = localizedValue
}
}
return plan
}
/** Normalizes the legacy create payload, including its optional nested
* `translations` object. Direct name/description/details values retain
* precedence, matching the previous create behavior. */
export function normalizeProductTranslationCreate(
payload: Record<string, unknown>,
): ProductTranslationCreatePlan {
const direct = normalizeProductTranslationUpdate(payload)
const base = {
name: legacyBaseValue(payload.name, 'New Product'),
description: legacyBaseValue(payload.description, ''),
details: legacyBaseValue(payload.details, ''),
}
const pending = Object.create(null) as Record<string, TranslationValues>
if (isRecord(payload.translations)) {
for (const [langCode, rawTranslation] of Object.entries(payload.translations)) {
assertSupportedLocale('translations', langCode)
if (!isRecord(rawTranslation)) continue
const values: TranslationValues = {}
for (const field of TRANSLATABLE_FIELDS) {
if (typeof rawTranslation[field] === 'string') values[field] = rawTranslation[field]
}
if (Object.keys(values).length > 0) pending[langCode] = values
}
}
for (const [langCode, values] of Object.entries(direct.byLanguage)) {
const objectLocalizedValues: TranslationValues = {}
for (const field of TRANSLATABLE_FIELDS) {
if (isRecord(payload[field]) && values[field] !== undefined) {
objectLocalizedValues[field] = values[field]
}
}
if (Object.keys(objectLocalizedValues).length > 0) {
pending[langCode] = { ...(pending[langCode] ?? {}), ...objectLocalizedValues }
}
}
if (Object.keys(pending).length === 0) pending.th = { ...base }
const byLanguage = Object.create(null) as Record<string, Required<TranslationValues>>
for (const [langCode, values] of Object.entries(pending)) {
byLanguage[langCode] = {
name: values.name || base.name,
description: values.description || base.description,
details: values.details || base.details,
}
}
return { base, byLanguage }
}
export function productTranslationErrorResponse(error: ProductTranslationPayloadError) {
return {
success: false as const,
code: error.code,
message: 'Invalid product translations',
issues: error.issues,
}
}
type ProductTranslationWriter = Pick<Prisma.TransactionClient, 'productTranslation'>
/**
* Applies a normalized plan through the caller's transaction client. Keeping
* this helper transaction-client-only prevents product and translation writes
* from accidentally being split across independent requests/transactions.
*/
export async function syncProductTranslations(
tx: ProductTranslationWriter,
productId: number,
plan: ProductTranslationUpdatePlan,
fallbackName: string | null | undefined,
) {
for (const [langCode, values] of Object.entries(plan.byLanguage)) {
await tx.productTranslation.upsert({
where: { productId_langCode: { productId, langCode } },
update: values,
create: {
productId,
langCode,
name: values.name ?? fallbackName ?? '',
...(values.description !== undefined ? { description: values.description } : {}),
...(values.details !== undefined ? { details: values.details } : {}),
},
})
}
}