Fix API Error before M5

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent d5cacc1208
commit 7688657d3c
13 changed files with 315 additions and 36 deletions
+13 -1
View File
@@ -19,7 +19,8 @@
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@sport/types": "workspace:*",
@@ -28,7 +29,18 @@
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@types/jest": "^30.0.0",
"eslint": "catalog:",
"jest": "^30.4.2",
"ts-jest": "^29.4.12",
"typescript": "catalog:"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": [
"<rootDir>/src"
],
"testRegex": ".*\\.spec\\.ts$"
}
}
@@ -0,0 +1,49 @@
import { createProductSchema, updateProductSchema } from './catalog-admin';
/**
* These two schemas each caused a data-loss bug, and neither was visible from
* reading the code — both came from a library behaviour that changed underneath
* the intent expressed in the comments.
*/
describe('createProductSchema translations', () => {
it('accepts a product authored in one language only', () => {
// `z.record` keyed by an enum is exhaustive in Zod 4, which made this fail
// and left the "at least one language" refine unreachable. The editor
// advertises single-language authoring, so this is the supported path.
const result = createProductSchema.safeParse({
translations: { vi: { name: 'Áo Chạy Bộ' } },
basePriceAmount: 100_000,
});
expect(result.success).toBe(true);
});
it('still rejects a product with no language at all', () => {
const result = createProductSchema.safeParse({ translations: {}, basePriceAmount: 0 });
expect(result.success).toBe(false);
});
});
describe('updateProductSchema', () => {
it('leaves omitted collections absent rather than defaulting them to empty', () => {
// `.partial()` keeps `.default([])`, so an omitted key arrived as `[]` —
// and `[]` is truthy, so the service wrote it. Renaming a product cleared
// its gender and sport targeting, dropped every collection link and deleted
// all of its attributes.
const parsed = updateProductSchema.parse({
translations: { vi: { name: 'Chỉ Đổi Tên' } },
});
expect(parsed.genderTargets).toBeUndefined();
expect(parsed.sportTypes).toBeUndefined();
expect(parsed.collectionIds).toBeUndefined();
expect(parsed.attributes).toBeUndefined();
expect(parsed.options).toBeUndefined();
expect(parsed.basePriceAmount).toBeUndefined();
});
it('still applies an explicit empty array, which means "clear this"', () => {
const parsed = updateProductSchema.parse({ genderTargets: [] });
expect(parsed.genderTargets).toEqual([]);
});
});
+36 -8
View File
@@ -16,10 +16,19 @@ import { offsetPageQuerySchema } from './pagination';
const localeKeySchema = z.enum(LOCALES);
/** At least the default locale must be present, or the product has no name. */
function requiredTranslations<T extends z.ZodTypeAny>(fields: T) {
/**
* At least one locale must be present; the rest are optional.
*
* `z.partialRecord`, NOT `z.record`. In Zod 4 a record keyed by an enum is
* *exhaustive* — `z.record(localeKeySchema, …)` demands every locale and makes
* the refine below unreachable. That silently rejected any product authored in
* one language, which is precisely the workflow the editor advertises ("a
* language with no name is skipped"). The field-level fallback in the database
* exists so partial translations are normal, not exceptional.
*/
function partialTranslations<T extends z.ZodTypeAny>(fields: T) {
return z
.record(localeKeySchema, fields)
.partialRecord(localeKeySchema, fields)
.refine((value) => Object.keys(value).length > 0, 'Provide content for at least one language');
}
@@ -47,7 +56,7 @@ export const productOptionValueInputSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Use a hex colour like #1a2b3c')
.nullish(),
labels: requiredTranslations(z.string().trim().min(1).max(80)),
labels: partialTranslations(z.string().trim().min(1).max(80)),
});
export const productOptionInputSchema = z.object({
@@ -59,14 +68,14 @@ export const productOptionInputSchema = z.object({
.max(40)
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores'),
position: z.coerce.number().int().min(0).default(0),
names: requiredTranslations(z.string().trim().min(1).max(60)),
names: partialTranslations(z.string().trim().min(1).max(60)),
values: z.array(productOptionValueInputSchema).min(1, 'An option needs at least one value'),
});
export const productAttributeInputSchema = z.object({
key: z.string().trim().min(1).max(60),
position: z.coerce.number().int().min(0).default(0),
translations: requiredTranslations(
translations: partialTranslations(
z.object({
label: z.string().trim().min(1).max(120),
value: z.string().trim().min(1).max(500),
@@ -75,7 +84,7 @@ export const productAttributeInputSchema = z.object({
});
export const createProductSchema = z.object({
translations: requiredTranslations(productTranslationSchema),
translations: partialTranslations(productTranslationSchema),
brandId: anyIdSchema.nullish(),
primaryCategoryId: anyIdSchema.nullish(),
genderTargets: z.array(genderTargetSchema).default([]),
@@ -89,7 +98,26 @@ export const createProductSchema = z.object({
basePriceAmount: z.int().min(0).default(0),
});
export const updateProductSchema = createProductSchema.partial().extend({
/**
* Written out rather than derived with `.partial()`.
*
* `.partial()` makes a field optional but leaves its `.default([])` in place,
* so an omitted `genderTargets` arrived as `[]` — and `[]` is truthy, so the
* service wrote it. Renaming a product silently cleared its gender and sport
* targeting, dropped it from every collection and deleted all of its
* attributes. Absent must mean "leave alone"; only an explicit `[]` clears.
*/
export const updateProductSchema = z.object({
translations: partialTranslations(productTranslationSchema).optional(),
brandId: anyIdSchema.nullish(),
primaryCategoryId: anyIdSchema.nullish(),
genderTargets: z.array(genderTargetSchema).optional(),
sportTypes: z.array(sportTypeSchema).optional(),
collectionIds: z.array(anyIdSchema).optional(),
skuPrefix: z.string().trim().max(24).optional(),
options: z.array(productOptionInputSchema).optional(),
attributes: z.array(productAttributeInputSchema).optional(),
basePriceAmount: z.int().min(0).optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
});