From 60ffe2a079fca76a5017bd0f7d7a1896879115db Mon Sep 17 00:00:00 2001 From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:34:33 +0100 Subject: [PATCH] fix(products): variant SKU collisions & save-flow error UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (from production requestId aa6d6370): POST /api/products/:id/variants crashed with a raw D1 UNIQUE constraint on product_variants.sku → HTTP 500 → generic toast. No test anywhere covered variant SKU uniqueness — mock tests cannot hit real SQL constraints, and the prior e2e suites covered orders/stock/money only. Server - variants/queries.ts now wraps shared createVariant/updateVariant with a SKU availability pre-check (and a defensive unique-violation mapping for the check→write race). A collision returns 409 DUPLICATE_SKU carrying the offending SKU — the same friendly treatment product creation already had. - Regression-locked on real D1 (variants.sku-e2e): create-dup, update-dup, self-rename OK, fresh SKU OK + opening-stock ledger intact. The exact failing HTTP call now returns 409 with the SKU. - variants/CONTEXT.md: "SKU collisions crash late" is no longer true. Dashboard - productErrorMessage surfaces real API detail instead of collapsing everything to "Something went wrong": DUPLICATE_SKU names the SKU, VALIDATION_FAILED lists every failing field/path, 500s carry the requestId so support can correlate with server logs. - ProductForm save flow: failures name the failing step ("Variant أبيض / 128GB could not be saved — …") instead of one generic blob. - Double-create retry trap closed: when a new product saves but a variant/image step fails, the form redirects to the edit page with a toast — retry becomes an update, never a duplicate product. - Client validation hardened: price must be a whole number; duplicate SKUs across variant rows are caught before any request is sent. - 7 new locale keys × ar/en/fr (i18n parity guard passes). Verified: cod-server 118 files / 1709 tests green + tsc clean; cod-client-astro 142 tests + typecheck + i18n parity green; deployed and smoke-verified (the original 500 endpoint now 409). --- cod-client-astro/locales/ar/products.json | 7 + cod-client-astro/locales/en/products.json | 7 + cod-client-astro/locales/fr/products.json | 7 + .../products/components/ProductForm.tsx | 127 ++++++++++----- .../src/features/products/model.ts | 47 +++++- cod-server/src/endpoints/variants/CONTEXT.md | 2 +- cod-server/src/endpoints/variants/queries.ts | 75 +++++++++ .../variants/variants.sku-e2e.test.ts | 153 ++++++++++++++++++ .../src/endpoints/variants/variants.test.ts | 10 +- 9 files changed, 386 insertions(+), 49 deletions(-) create mode 100644 cod-server/src/endpoints/variants/variants.sku-e2e.test.ts diff --git a/cod-client-astro/locales/ar/products.json b/cod-client-astro/locales/ar/products.json index fa7a307..dde21a6 100644 --- a/cod-client-astro/locales/ar/products.json +++ b/cod-client-astro/locales/ar/products.json @@ -10,6 +10,9 @@ "error_delete_has_orders": "لا يمكن حذف منتج لديه سجل طلبات", "error_duplicate_sku": "هذا الرمز مستخدم بالفعل", "error_insufficient_stock": "لا يمكن أن يقل المخزون عن الصفر", + "error_validation": "بعض الحقول غير صالحة", + "error_unexpected_id": "حدث خطأ غير متوقع. حاول مرة أخرى — وإذا استمر، أذكر الرقم {id} للدعم.", + "error_duplicate_sku_named": "رمز SKU {sku} مستخدم بالفعل في متغير آخر", "search_placeholder": "بحث بالاسم أو الرمز...", "table": { "name": "المنتج", @@ -118,6 +121,10 @@ "error_not_found": "المنتج غير موجود", "error_load_failed": "فشل تحميل المنتج", "error_name_price_required": "الاسم والسعر حقلان مطلوبان", + "error_price_integer": "يجب أن يكون السعر رقمًا صحيحًا", + "error_duplicate_variant_sku": "صفّان من المتغيرات يستخدمان نفس SKU", + "error_variant_row": "تعذر حفظ المتغير {variant}", + "partial_save_redirect": "تم حفظ المنتج، لكن فشل أحد المتغيرات: {message}. جارٍ فتح المحرر لتصحيحه.", "error_save_failed": "فشل حفظ المنتج", "error_required_variant_sku": "كل متغير يتطلب رمزاً", "base_price_hint": "السعر الأساسي — يمكن لكل متغير تجاوزه أدناه", diff --git a/cod-client-astro/locales/en/products.json b/cod-client-astro/locales/en/products.json index ae574ea..4ffbf15 100644 --- a/cod-client-astro/locales/en/products.json +++ b/cod-client-astro/locales/en/products.json @@ -10,6 +10,9 @@ "error_delete_has_orders": "Cannot delete a product that has order history", "error_duplicate_sku": "This SKU is already in use", "error_insufficient_stock": "Stock cannot go below zero", + "error_validation": "Some fields are invalid", + "error_unexpected_id": "An unexpected error occurred. Please try again — if it persists, quote reference {id} to support.", + "error_duplicate_sku_named": "SKU {sku} is already used by another variant", "search_placeholder": "Search by name, SKU...", "table": { "name": "Product", @@ -118,6 +121,10 @@ "error_not_found": "Product not found", "error_load_failed": "Failed to load product", "error_name_price_required": "Name and price are required", + "error_price_integer": "Price must be a whole number", + "error_duplicate_variant_sku": "Two variant rows use the same SKU", + "error_variant_row": "Variant {variant} could not be saved", + "partial_save_redirect": "Product saved, but a variant failed: {message}. Opening the editor so you can fix it.", "error_save_failed": "Failed to save product", "error_required_variant_sku": "Every variant needs a SKU", "base_price_hint": "Base price — each variant can override this below", diff --git a/cod-client-astro/locales/fr/products.json b/cod-client-astro/locales/fr/products.json index 6220f5f..cb4961e 100644 --- a/cod-client-astro/locales/fr/products.json +++ b/cod-client-astro/locales/fr/products.json @@ -10,6 +10,9 @@ "error_delete_has_orders": "Impossible de supprimer un produit ayant un historique de commandes", "error_duplicate_sku": "Ce SKU est déjà utilisé", "error_insufficient_stock": "Le stock ne peut pas passer sous zéro", + "error_validation": "Certains champs sont invalides", + "error_unexpected_id": "Une erreur inattendue s'est produite. Réessayez — si elle persiste, communiquez la référence {id} au support.", + "error_duplicate_sku_named": "Le SKU {sku} est déjà utilisé par une autre variante", "search_placeholder": "Rechercher par nom, SKU...", "table": { "name": "Produit", @@ -118,6 +121,10 @@ "error_not_found": "Produit non trouvé", "error_load_failed": "Échec du chargement du produit", "error_name_price_required": "Le nom et le prix sont requis", + "error_price_integer": "Le prix doit être un nombre entier", + "error_duplicate_variant_sku": "Deux lignes de variante utilisent le même SKU", + "error_variant_row": "La variante {variant} n'a pas pu être enregistrée", + "partial_save_redirect": "Produit enregistré, mais une variante a échoué : {message}. Ouverture de l'éditeur pour corriger.", "error_save_failed": "Échec de l'enregistrement du produit", "error_required_variant_sku": "Chaque variante nécessite un SKU", "base_price_hint": "Prix de base — chaque variante peut l'écraser ci-dessous", diff --git a/cod-client-astro/src/features/products/components/ProductForm.tsx b/cod-client-astro/src/features/products/components/ProductForm.tsx index 3307fb9..8e7a661 100644 --- a/cod-client-astro/src/features/products/components/ProductForm.tsx +++ b/cod-client-astro/src/features/products/components/ProductForm.tsx @@ -27,6 +27,7 @@ import { generateCombinations, productErrorMessage, toSlug, + variantLabel, } from "@/features/products/model"; import type { ProductImage, @@ -232,8 +233,17 @@ export function ProductForm({ productId }: { productId?: string }) { if (!name.trim()) next.name = t("form.error_name_price_required"); if (!price || Number(price) < 0) next.price = t("form.error_name_price_required"); + else if (!Number.isInteger(Number(price))) + next.price = t("form.error_price_integer"); if (hasVariantsSwitch && variantRows.some((row) => !row.sku.trim())) next.variantRowsSku = t("form.error_required_variant_sku"); + if (hasVariantsSwitch) { + const skus = variantRows + .map((row) => row.sku.trim()) + .filter(Boolean); + if (new Set(skus).size !== skus.length) + next.variantRowsSkuDuplicate = t("form.error_duplicate_variant_sku"); + } setErrors(next); return Object.keys(next).length === 0; } @@ -242,7 +252,7 @@ export function ProductForm({ productId }: { productId?: string }) { if (!validate()) return; setBusy(true); setMessage(null); - try { + { const apiOptions = apiVariantOptions(variantOptions); const hasVariants = apiOptions.length > 0 && variantRows.length > 0; const variantsToDelete: string[] = []; @@ -302,51 +312,82 @@ export function ProductForm({ productId }: { productId?: string }) { variantOptions: hasVariants ? apiOptions : null, }; - let savedId: string; - if (editing && productId) { - await updateProduct(productId, data as never); - savedId = productId; - } else { - savedId = (await createProduct(data as never)).data.id; - } + // Track how far the multi-step save got so the failure message can say + // WHAT failed and the retry can never duplicate the product. + let savedId: string | null = null; + let failedStep: "variants" | "images" | null = null; + let failedVariantLabel: string | null = null; + try { + if (editing && productId) { + await updateProduct(productId, data as never); + savedId = productId; + } else { + savedId = (await createProduct(data as never)).data.id; + } - if (hasVariants) { - for (const row of variantRows) { - if (row.existingId && orphanedIds.has(row.existingId)) continue; - const variantData = { - variations: row.variations, - price: Math.round(Number(row.price) || 0), - sku: row.sku.trim(), - inventory: Number(row.inventory) || 0, - lowStockThreshold: Number(row.lowStockThreshold) || 5, - active: row.active, - imageId: row.imageId ?? null, - }; - if (row.existingId) - await updateVariant(savedId, row.existingId, variantData as never); - else await createVariant(savedId, variantData as never); + if (hasVariants) { + failedStep = "variants"; + for (const row of variantRows) { + if (row.existingId && orphanedIds.has(row.existingId)) continue; + const variantData = { + variations: row.variations, + price: Math.round(Number(row.price) || 0), + sku: row.sku.trim(), + inventory: Number(row.inventory) || 0, + lowStockThreshold: Number(row.lowStockThreshold) || 5, + active: row.active, + imageId: row.imageId ?? null, + }; + failedVariantLabel = variantLabel(row.variations) || row.sku.trim(); + if (row.existingId) + await updateVariant(savedId, row.existingId, variantData as never); + else await createVariant(savedId, variantData as never); + } + failedVariantLabel = null; } + failedStep = "images"; + for (const variantId of variantsToDelete) { + await deleteVariant(savedId, variantId); + } + for (const imageId of deletedImageIds) { + await deleteProductImage(savedId, imageId); + } + for (let i = 0; i < pendingImages.length; i++) { + await saveProductImage(savedId, { + key: pendingImages[i].key, + src: pendingImages[i].url, + position: existingImages.length - deletedImageIds.length + i + 1, + }); + } + failedStep = null; + notify.flashSuccess(t(editing ? "form.success_edit" : "form.success_add")); + window.location.assign("/products"); + return; + } catch (cause) { + const detail = productErrorMessage(cause, t); + let message = detail; + if (failedStep === "variants" && failedVariantLabel) { + message = `${t("form.error_variant_row") + .replace("{variant}", failedVariantLabel)} — ${detail}`; + } + + // Partial-save trap: the product row EXISTS (created or updated) but + // a later step failed. Retrying "create" would duplicate the product — + // send the merchant to the editor instead, where retry = update. + if (savedId && !editing) { + notify.error( + t("form.partial_save_redirect").replace("{message}", detail), + ); + window.location.assign( + `/products/${encodeURIComponent(savedId)}/edit`, + ); + return; + } + + setMessage(message); + notify.error(message); + setBusy(false); } - for (const variantId of variantsToDelete) { - await deleteVariant(savedId, variantId); - } - for (const imageId of deletedImageIds) { - await deleteProductImage(savedId, imageId); - } - for (let i = 0; i < pendingImages.length; i++) { - await saveProductImage(savedId, { - key: pendingImages[i].key, - src: pendingImages[i].url, - position: existingImages.length - deletedImageIds.length + i + 1, - }); - } - notify.flashSuccess(t(editing ? "form.success_edit" : "form.success_add")); - window.location.assign("/products"); - } catch (cause) { - const message = productErrorMessage(cause, t); - setMessage(message); - notify.error(message); - setBusy(false); } } diff --git a/cod-client-astro/src/features/products/model.ts b/cod-client-astro/src/features/products/model.ts index f18b478..d6b1a41 100644 --- a/cod-client-astro/src/features/products/model.ts +++ b/cod-client-astro/src/features/products/model.ts @@ -76,12 +76,55 @@ export function parseProductRoute(pathname: string): ProductRoute { } } +interface ApiLikeError { + code?: string; + status?: number; + context?: Record; +} + +function asApiError(cause: unknown): ApiLikeError | null { + if (!cause || typeof cause !== "object") return null; + const candidate = cause as ApiLikeError; + return typeof candidate.code === "string" ? candidate : null; +} + export function productErrorMessage(cause: unknown, t: (key: string) => string) { - const code = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : ""; + const api = asApiError(cause); + const code = api?.code ?? ""; + const context = api?.context; + if (code === "PRODUCT_NOT_FOUND") return t("error_not_found"); if (code === "PRODUCT_HAS_ORDERS") return t("error_delete_has_orders"); - if (code === "DUPLICATE_SKU") return t("error_duplicate_sku"); if (code === "INSUFFICIENT_STOCK") return t("error_insufficient_stock"); + + if (code === "DUPLICATE_SKU") { + const sku = typeof context?.sku === "string" ? context.sku : null; + return sku + ? t("error_duplicate_sku_named").replace("{sku}", sku) + : t("error_duplicate_sku"); + } + + if (code === "VALIDATION_FAILED") { + const fields = Array.isArray(context?.fields) ? context!.fields : []; + const lines = fields + .map((field) => { + const entry = field as { path?: string; message?: string }; + return entry.path ? `${entry.path}: ${entry.message ?? ""}` : entry.message ?? ""; + }) + .filter(Boolean); + return lines.length + ? `${t("error_validation")} — ${lines.join(" · ")}` + : t("error_validation"); + } + + if (code === "INTERNAL_SERVER_ERROR" || (api?.status ?? 0) >= 500) { + const requestId = + typeof context?.requestId === "string" ? context.requestId : null; + return requestId + ? t("error_unexpected_id").replace("{id}", requestId) + : t("error_generic"); + } + return t("error_generic"); } diff --git a/cod-server/src/endpoints/variants/CONTEXT.md b/cod-server/src/endpoints/variants/CONTEXT.md index 79572f9..1d7e32b 100644 --- a/cod-server/src/endpoints/variants/CONTEXT.md +++ b/cod-server/src/endpoints/variants/CONTEXT.md @@ -59,7 +59,7 @@ Terms owned by neighboring contexts — use them, don't redefine them here: **No blueprint police**: Creating `{"Material": "Gold"}` on a product whose declared options only list Color succeeds silently — drift between options and real variants is possible. -**SKU collisions crash late**: Duplicate SKUs surface as raw database constraint errors rather than friendly conflicts, because uniqueness is delegated entirely to the schema. +**SKU collisions fail friendly**: Duplicate SKUs are caught by a pre-check (and a defensive constraint mapping for races) and rejected as a 409 Conflict carrying the offending SKU — the same treatment product creation gets. **Parent existence is assumed**: Creating a variant against a nonexistent product fails at the foreign-key level, not with a clean not-found message. diff --git a/cod-server/src/endpoints/variants/queries.ts b/cod-server/src/endpoints/variants/queries.ts index f2cf1d0..cae40fb 100644 --- a/cod-server/src/endpoints/variants/queries.ts +++ b/cod-server/src/endpoints/variants/queries.ts @@ -1,5 +1,80 @@ /** * Re-exported from cod-shared/queries/variants so the dashboard can consume * the same read functions directly from D1. + * + * createVariant/updateVariant wrappers stay here because they raise + * BusinessLogicError: the SKU uniqueness pre-check turns a raw DB constraint + * crash (HTTP 500) into a friendly 409 DUPLICATE_SKU carrying the SKU. */ +import { eq, and, ne } from "drizzle-orm"; +import { productVariants } from "@/db/schema"; +import type { AppDb } from "@/db"; +import { ConflictError } from "@/lib/errors/classes"; +import { ERROR_CODES } from "../../../../cod-shared/errors/codes"; +import * as shared from "../../../../cod-shared/queries/variants"; + export * from "../../../../cod-shared/queries/variants"; + +type CreateVariantData = Parameters[2]; +type UpdateVariantData = Parameters[2]; + +function isSkuUniqueViolation(err: unknown): boolean { + return ( + err instanceof Error && + /UNIQUE constraint failed: product_variants\.sku/i.test(err.message) + ); +} + +function duplicateSkuError(sku: string) { + return new ConflictError( + `SKU "${sku}" is already used by another variant`, + ERROR_CODES.DUPLICATE_SKU, + { sku }, + ); +} + +async function assertSkuAvailable( + db: AppDb, + sku: string, + excludeVariantId: string | null, +) { + const clash = await db + .select({ id: productVariants.id, productId: productVariants.productId }) + .from(productVariants) + .where( + excludeVariantId + ? and(eq(productVariants.sku, sku), ne(productVariants.id, excludeVariantId)) + : eq(productVariants.sku, sku), + ) + .get(); + if (clash) throw duplicateSkuError(sku); +} + +export async function createVariant( + db: AppDb, + productId: string, + data: CreateVariantData, +) { + if (data.sku) await assertSkuAvailable(db, data.sku, null); + try { + return await shared.createVariant(db, productId, data); + } catch (err) { + // Race: a concurrent writer took the SKU between check and insert. + if (isSkuUniqueViolation(err)) throw duplicateSkuError(data.sku); + throw err; + } +} + +export async function updateVariant( + db: AppDb, + variantId: string, + data: UpdateVariantData, +) { + if (data.sku) await assertSkuAvailable(db, data.sku, variantId); + try { + return await shared.updateVariant(db, variantId, data); + } catch (err) { + if (data.sku && isSkuUniqueViolation(err)) throw duplicateSkuError(data.sku); + throw err; + } +} diff --git a/cod-server/src/endpoints/variants/variants.sku-e2e.test.ts b/cod-server/src/endpoints/variants/variants.sku-e2e.test.ts new file mode 100644 index 0000000..82c8466 --- /dev/null +++ b/cod-server/src/endpoints/variants/variants.sku-e2e.test.ts @@ -0,0 +1,153 @@ +/** + * Variant SKU uniqueness — real D1 (regression for the raw-500 crash) + * + * The dashboard product editor sends variant creates/updates in a save + * sequence. A SKU that collides with an existing variant previously crashed + * at the DB level (UNIQUE constraint failed → HTTP 500 → generic toast). + * The wrapped queries in ./queries now turn that into a friendly + * BusinessLogicError(DUPLICATE_SKU) carrying the SKU. + * + * This is the exact production failure from requestId + * aa6d6370-2ceb-4714-913e-11610dd5adbe (POST /api/products/:id/variants). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; +import { createVariant, updateVariant } from "./queries"; +import { ConflictError } from "@/lib/errors/classes"; +import { ERROR_CODES } from "../../../../cod-shared/errors/codes"; + +let db: AppDb; +let rawD1: D1Database; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + rawD1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const prepared: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) prepared.push(rawD1.prepare(statement)); + } + for (let i = 0; i < prepared.length; i += 50) { + await rawD1.batch(prepared.slice(i, i + 50)); + } + db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +let seq = 0; +const NOW = () => new Date().toISOString(); + +async function seedProduct() { + const id = `prod-sku-${++seq}`; + await db.insert(schema.products).values({ + id, name: `SKU Product ${seq}`, handle: `sku-product-${seq}`, price: 1000, + hasVariants: true, inventory: 0, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: true, storeFeatured: false, + createdAt: NOW(), updatedAt: NOW(), + }); + return id; +} + +async function seedVariant(productId: string, sku: string) { + const id = `var-sku-${++seq}`; + await db.insert(schema.productVariants).values({ + id, productId, variations: JSON.stringify({ اللون: "أسود" }), currency: "DZD", + price: 1200, sku, inventory: 5, isDefault: true, active: true, + position: 1, createdAt: NOW(), updatedAt: NOW(), + }); + return id; +} + +function variantData(sku: string) { + return { + variations: { اللون: "أحمر" }, + price: 1300, + sku, + inventory: 3, + isDefault: false, + active: true, + position: 2, + }; +} + +describe("variant SKU uniqueness — friendly 409 instead of raw 500", () => { + it("createVariant with a taken SKU throws BusinessLogicError(DUPLICATE_SKU) with the SKU", async () => { + const productId = await seedProduct(); + await seedVariant(productId, "SKU-TAKEN-1"); + + let caught: unknown; + try { + await createVariant(db, productId, variantData("SKU-TAKEN-1")); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ConflictError); + const err = caught as ConflictError; + expect(err.code).toBe(ERROR_CODES.DUPLICATE_SKU); + expect(err.statusCode).toBe(409); + expect(err.context).toMatchObject({ sku: "SKU-TAKEN-1" }); + // Nothing was written — the batch never ran + const rows = await db.select().from(schema.productVariants) + .where((await import("drizzle-orm")).eq(schema.productVariants.productId, productId)).all(); + expect(rows).toHaveLength(1); + }); + + it("updateVariant renaming to a taken SKU throws the same friendly error", async () => { + const productId = await seedProduct(); + const v1 = await seedVariant(productId, "SKU-A"); + const v2 = await seedVariant(productId, "SKU-B"); + + let caught: unknown; + try { + await updateVariant(db, v2, { sku: "SKU-A" }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ConflictError); + expect((caught as ConflictError).code).toBe(ERROR_CODES.DUPLICATE_SKU); + expect((caught as ConflictError).context).toMatchObject({ sku: "SKU-A" }); + // v2 kept its original SKU + const row = await db.select().from(schema.productVariants) + .where((await import("drizzle-orm")).eq(schema.productVariants.id, v2)).get(); + expect(row?.sku).toBe("SKU-B"); + }); + + it("updateVariant keeping its own SKU is allowed (no self-collision)", async () => { + const productId = await seedProduct(); + const v1 = await seedVariant(productId, "SKU-KEEP"); + + const result = await updateVariant(db, v1, { sku: "SKU-KEEP", price: 1500 }); + expect(result?.price).toBe(1500); + }); + + it("createVariant with a fresh SKU still succeeds (opening stock ledger intact)", async () => { + const productId = await seedProduct(); + + const result = await createVariant(db, productId, variantData("SKU-FRESH-1")); + expect(result?.sku).toBe("SKU-FRESH-1"); + const movements = await db.select().from(schema.stockMovements) + .where((await import("drizzle-orm")).eq(schema.stockMovements.productId, productId)).all(); + expect(movements).toHaveLength(1); // opening stock logged + }); +}); diff --git a/cod-server/src/endpoints/variants/variants.test.ts b/cod-server/src/endpoints/variants/variants.test.ts index 1abda64..ed657f5 100644 --- a/cod-server/src/endpoints/variants/variants.test.ts +++ b/cod-server/src/endpoints/variants/variants.test.ts @@ -144,9 +144,10 @@ describe("getVariantById", () => { describe("createVariant", () => { it("creates and returns the new variant", async () => { - // batch INSERT (run — no queue consumption) → trackInventory check (f) - // → getVariantById → productVariants.get() + // SKU-clash pre-check (f(null) — no clash) → batch INSERT (run — no + // queue consumption) → trackInventory check (f) → getVariantById (f) const db = makeMockDb([ + f(null), f({ track_inventory: 1 }), f(variantRow({ product_id: "prod_1" })), ]); @@ -164,7 +165,10 @@ describe("createVariant", () => { }); it("serializes variations to JSON for storage", async () => { - const db = makeMockDb([f(variantRow({ variations: '{"Size":"S"}' }))]); + const db = makeMockDb([ + f(null), + f(variantRow({ variations: '{"Size":"S"}' })), + ]); const result = await createVariant(db as any, "prod_1", { variations: { Size: "S" }, price: 1200,