Fix API Error before M5
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user