Skip to main content

Product Image Sync

Shopify -> ProductImage sync, Jewelry title-lookup path, WorkOrder image display, enablement flags.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

Product Image Sync (product domain)

Pulls Shopify product media into the local ProductImage table and orders them via AI classification. There is no Shopify webhook — sync is pull-based, keyed by a canonical SKU built from the style number plus a hardcoded color/size.

  • Canonical SKU: <styleNumber> + color GR0018 (Sage) + L-size code 004. The color is hardcoded as the standard internal reference colorway; size L is assumed to exist for all styles. The L-size code is looked up from prisma/seed/data/sizes.json (size === 'L'sizeCode '004') — not from the DB. This SKU literal was duplicated in two places (seed-productimage.ts and the Style handlers.ts addStyleProductImage).
  • Core service: ProductService.syncImages(sku, prismaInstance?) (src/services/product/product.service.ts:19) — calls shopifyGetProductImageBySku(sku) (src/clients/shopifyClient/shopifyClient.ts, GraphQL productVariants(first:1, query:"sku:<sku>")), filters media.nodes to MediaContentType.Image, dedupes by URL, then in a $transaction (20s timeout) upsertManys the images (position-ordered) and deleteManys any rows at position >= newCount. Throws makeBadRequestError if no variant matches the SKU (product.service.ts:23-25). The optional prismaInstance param exists so the standalone seed script's new PrismaClient() can be threaded through.
  • AI ordering: classifyProductImagePosBySku(sku, rules?) (src/utils/classifyProductImagePos.ts) uses OpenAI to classify each image front/back/other and rewrites position (front→0, back→1, rest after). Called as a separate step after syncImages.
  • Trigger points (4 call sites for syncImages):
    1. REST APIPOST /api/product/v1/syncImages/:sku (apiKeyAuth, controller src/controllers/product/product.controller.ts:23). Validates, calls syncImages, then setImmediate(classifyProductImagePosBySku). On error calls captureSyncProductImageFailed(req, err) and rethrows. This is the N8N-driven path.
    2. AdminJS New-Style formnewActionHandler (src/routers/admin/resources/style/handlers.ts:77-82) runs setImmediate(() => addStyleProductImage(styleNumber) → classify) after creating the style. addStyleProductImage (handlers.ts:176) is fire-and-forget with all errors swallowed twice (its own try/catch + the setImmediate body), and its comment claiming "Sentry logging" in the service layer is inaccuratesyncImages does no Sentry logging. So a transient failure leaves the style permanently imageless and invisible.
    3. Manual seed scriptnpm run db:seed:productimageprisma/seed/run-productimage.ts (loads all non-deleted styles) → seedProductImage(prisma, styleNumbers) (prisma/seed/seed-productimage.ts). Per-style try/catch builds successSkus/failedSkus, prints a report, then runs the classify loop over successes. This is the manual recovery path operators run when images are missing.
    4. AdminJS Edit-Stylenone. editActionHandler never re-syncs images.
  • Gap (pre-INFRA-542): the Airtable sync path has no image trigger at all. WebhooksService.syncAirtableStyle (src/services/webhooks/webhooks.service.ts:20) → StyleModel.upsertByAirtableRecordId never touches product images. Since Airtable is the normal inbound path for styles, styles arriving that way silently get no images until someone runs the seed script.
  • Sentry capture: captureSyncProductImageFailed(req, error) (src/utils/sentryUtils.ts:396) reads only req.params?.sku — so it was usable only from the controller, not background/webhook callers (tags: module=productImage, processName=syncProductImage, eventType=failed). Constants live in src/constants/sentryTags.ts (SENTRY_SYNC_PRODUCT_IMAGE_EVENT_TYPE.failed). captureProductImagesClassifyDone logs classify outcomes.
  • Specs: product.service.spec.ts exists (mocks shopifyClient, prismaClient.$transaction, productImage model — note it still uses it()); webhooks.service.spec.ts exists. No spec covers the Style handlers.ts image trigger.

Jewelry-Category Image Sync Gotchas (INFRA-590 investigation)

The Dress-only canonical-SKU sync (above) cannot work for Jewelry styles at all — this was verified against the live birdy-grey-test-store.myshopify.com Shopify test store via the claude.ai Shopify MCP connector and cross-checked against the local DB, not assumed:

  • No usable SKU exists on Jewelry variants. Simple Size-only families (Ring Size, Necklace Size, Bracelet Size — no Carat) have sku: null on every variant. Size+Carat families (Ring+Carat, Necklace+Carat, Ring+Carat Two Stone, Earring Carat) do have SKUs, but they're sequential placeholders like TEST-FJ-0184 — unrelated to MES's styleNumber+colorCode+sizeCode scheme. Neither family is reachable by sku: query, regardless of which color/size literal is chosen.
  • Color is a fixed product-level attribute for Jewelry, not a variant axis. Unlike Dress, a Jewelry style with multiple gemstone colorways (e.g. "East West Oval Cut 14K Gold Fill Ring") is split into multiple separate Shopify products — one per colorway (... - Green Amethyst, ... - White Topaz, ... - Blue Topaz), each with its own single image. Diamond/Carat-family products have a generic color = "Diamond" and are 1:1 with one Shopify product (no colorway split).
  • Each Jewelry product has exactly 1 image total (not per-variant) — this is what makes "show 1 image on the Work Order for Jewelry" both correct and necessary; showing a 2nd (front/back-style) image slot doesn't apply to Jewelry at all.
  • The reliable link back to MES is the product title, not SKU. Verified formula, checked against all 46 Jewelry-scoped styles in the local DB against live Shopify data: Style.styleNameEn == <Shopify attr.style metafield> + " " + <attr.fabric> + " " + <attr.type> (e.g. "Bezel Diamond" + " " + "14K Solid Yellow Gold" + " " + "Necklace" = "Bezel Diamond 14K Solid Yellow Gold Necklace", an exact Style.styleNameEn match). For the colorway-split families, the Shopify product title further appends " - " + <attr.color>, and MES's Color.colorNameEn already has matching rows (Green Amethyst, White Topaz, Blue Topaz, Diamond, etc.) — so the full lookup is: try title:"<styleNameEn>" first (works for Diamond/Carat family), fall back to title:"<styleNameEn> - <colorNameEn>" using the Work Order's actual colorId (works for colorway-split families). No Shopify product ID/handle is captured anywhere in MES's Airtable→Style pipeline (AirtableStyleWebhookBodySchema only carries styleNumber/styleName/sizeRangeRecordId), so title reconstruction is the only available link.
  • Detecting "is this Style Jewelry" must be done by SizeRange name, not sizeRangeId or Airtable Record ID. Confirmed empirically: the same-named Jewelry SizeRanges (13. NECKLACE SIZE, 14. BRACELET SIZE, 15. RING SIZE, 17. RING + CARAT SIZE, 18. NECKLACE + CARAT SIZE, 19. RING + CARAT SIZE TWO STONE, 20. EARRING CARAT SIZE) have different sizeRangeId and airtableRecordId values in the local dev DB vs. staging (different Airtable bases per environment, same shape as the documented Test-Base-to-Test-Base-V2 migration risk under Operational Scripts). SizeRange.name is the only field confirmed stable across environments — mirrors the existing codebase precedent of matching Milly/Jewelry factories by factoryNameEn rather than factoryId/factoryCode.
  • Two independent WorkOrder-creation code paths both need any Jewelry-sync hook, since either can be the entry point: (1) the singular POST /workorder/v1/createworkorderService.create(req) (src/services/workorder/workorder.service.ts); and (2) the bulk POST /shipment/.../createshipment.controller.ts's create()workorderService.batchCreateByTransaction (inside a prisma.$transaction, so any external Shopify call must happen after the transaction commits). Both are now wired (see Implementation below).
  • Cache-key gotcha on StyleModel.findStyleUnique (src/models/style/style.model.ts): the in-memory cache key is `style:${JSON.stringify(params.where)}` — keyed only on where, not on include/select. Multiple call sites (skuUtils.ts#parseSkuInfo, style.service.ts, webhooks.service.ts) call findStyleUnique({ where: { styleNumber } }) with no include. Adding include: { sizeRange: true } to one caller risks a stale/narrower cached object (populated by a different caller with no include) silently missing the relation, or vice versa. Do not add a new include shape to an existing findStyleUnique call site that shares a where shape with other callers — do a separate, differently-keyed lookup instead (e.g. via SizeRangeModel, a different cache namespace) when a caller needs an additional relation the existing callers don't. This is why parseSkuInfo (singular) resolves the SizeRange name via its own SizeRangeModel.findSizeRangeUnique({ sizeRangeId }) call rather than adding include: { sizeRange: true } to its existing findStyleUnique call.
  • MES already carries everything needed to resolve Jewelry colorwaysColor table has real rows for gemstone names (Green Amethyst GR0040, White Topaz WT0016, Blue Topaz BL0048, Diamond WT0015) alongside metal-tone colors (Gold NT0009, White Gold NT0024, Yellow Gold NT0025, Rose Gold PK0026) — no new reference data needed, only new code paths to use it.

Implementation (sync-side, INFRA-590 first half — the Work Order display-side "show 1 image for Jewelry" is a separate, not-yet-done follow-up):

  • src/constants/jewelrySizeRanges.tsJEWELRY_SIZE_RANGE_NAMES (Set of the 7 names) + isJewelrySizeRangeName(name). Single source of truth for Jewelry detection, matched by name per the gotcha above.
  • SkuInfo.style.sizeRangeName: string | null (src/utils/skuUtils.ts) — added to both parseSkuInfo (singular; resolved via a dedicated SizeRangeModel.findSizeRangeUnique call, run in parallel with the existing size lookup) and parseSkuInfos (batch; populated from the sizeRangeId2NameMap it already built internally for error messages — no new query needed there).
  • src/clients/shopifyClient/shopifyClient.tsshopifyGetProductMediaByTitle(title): GraphQL products(first:1, query: 'title:"<title>"'), returns the product's media directly (no variant match needed, unlike the SKU-based query).
  • src/services/product/product.service.tssyncImages's dedupe+upsert/delete logic is extracted into a shared persistProductImages(sku, medias, meta, prismaInstance?) helper, reused by both the SKU-based (Dress/Scarf) and title-based (Jewelry) paths. New: buildJewelrySku(styleNumber, colorCode)`${styleNumber}${colorCode}000` (synthetic ProductImage.sku cache key — Jewelry has no real Shopify SKU, and images don't vary by size, so '000' is a fixed placeholder keeping the same 5+6+3 shape the rest of the codebase assumes); syncJewelryStyleColorImages(sku, styleNameEn, colorNameEn, prismaInstance?) (title lookup with the plain→color-suffixed fallback); triggerJewelryImageSyncIfApplicable({ styleNumber, styleNameEn, sizeRangeName, colorCode, colorNameEn }) (best-effort, Sentry-captured, no-ops unless isJewelrySizeRangeName(sizeRangeName) — the single entry point both creation paths call). Unlike syncStyleImagesBestEffort, this does not run classifyProductImagePosBySku — Jewelry has exactly one image, so there's no front/back to classify.
  • Hooked into workorder.service.ts#create (singular path, fires after workOrderModel.create succeeds) and shipment.controller.ts#create (batch path, fires in the existing post-transaction "don't block the response" section, deduped by `${styleId}-${colorId}` across the whole skuNumber2SkuInfoMap so a repeated SKU across quantities/shipments in one batch doesn't re-sync the same Shopify product multiple times).

Implementation (display-side, INFRA-590 second half — "show 1 image on the Work Order for Jewelry"):

  • WorkOrderData.isJewelry: boolean (schemas/workorder/workorder.types.d.ts + the batch WorkOrderBatchInfosByPoIdResponseSchema in schemas/workorder/index.ts; the singular WorkOrderResponseSchema uses .passthrough() so it didn't need a change). Computed by a shared _resolveJewelrySizeRangeIds() helper in workorder.service.ts — one SizeRangeService.findMany({ where: { name: { in: [...JEWELRY_SIZE_RANGE_NAMES] } } }) call per request, returning a Set<sizeRangeId> — called once in makeWorkOrderData (single WO) and once in _makeWorkOrderDatas (batch, computed outside the per-WO .map() so it's not re-queried per row). Threaded through _createWorkOrderDataByRecords's records param alongside enableMeasurements/enableConstructionNotes.
  • src/components/config.ts#makeSkuForGetImages(style, colorCode?, isJewelry?) — gained two optional params. When isJewelry && colorCode, builds `${style}${colorCode}000` (must exactly match the backend's ProductService.buildJewelrySku synthetic key, including the '000' size placeholder — duplicated as jewelrySizeCodeForImages in this file, mirroring the existing colorCodeForImages/sizeCodeForImages Dress-default duplication between frontend and backend). Falls back to the Dress default in every other case, so this is fully backward-compatible when called with just style.
  • WorkOrderTemplate/utils.tsx#useWorkOrdergetSkuImages now takes (sku, colorCode, isJewelry), reading the real color.colorCode/isJewelry off the fetched WorkOrderData, and requests fetchSkuImages(sku, isJewelry ? 1 : 2).
  • WorkOrderMaterial.tsx — the materials table's image column was already built on a rowSpan trick (halfRows = Math.ceil(totalRows/2); first image cell spans rows [0, halfRows), second spans [halfRows, totalRows)). Jewelry reuses the exact same mechanism: halfRows = isJewelry ? totalRows : Math.ceil(totalRows/2) makes the first image cell span the entire table, and the second cell's render condition (isSecondImageRow = !isJewelry && rowIndex === halfRows) is gated off entirely — no new layout code, just different rowSpan math.
  • pagesUtils.ts#workOrderDatasFetchImages (the batch/multi-WO print & export flow) previously deduped fetch keys by style only (item.sku.substring(0,5)), which happened to be correct for Dress only because makeSkuForGetImages used to ignore color entirely. Now keys by a new makeSkuForGetImagesForWorkOrder(workOrderData) helper (style + the WO's own color.colorCode + isJewelry) — for Dress this still collapses to one shared key per style (since makeSkuForGetImages ignores colorCode unless isJewelry is true, so behavior is unchanged), but for Jewelry it correctly fetches a distinct image per (style, color) pair. first count checks every WO in the batch (firstData.isJewelry && workOrderDatas.every((wo) => wo.isJewelry)), not just firstData alone — falls back to 2 unless the whole batch agrees it's Jewelry (see the "Mixed batch" defensive guard test in pagesUtils.spec.ts).

Enablement-flag resolution and the WorkOrderMainDisplay render-gating convention. enableMeasurements/enableConstructionNotes/isJewelry are the three booleans on WorkOrderData that WorkOrderMainDisplay.tsx (src/components/pages/WorkOrderTemplate/) uses to decide what to render — {data.enableMeasurements && <WorkOrderMeasurement .../>}, {data.enableConstructionNotes && (<><WorkOrderConstructionNotes/><WorkOrderMaterial isJewelry={data.isJewelry} .../></>)}. All three are resolved server-side only — the frontend never re-derives them from a raw feature-flag or category check, it just branches on the pre-resolved fields. The resolution machinery, in src/services/workorder/workorder.service.ts:

  • _resolveEnablementFlags({ millyEnabled, factoryCode, poCategory }) (module-private, ~line 1359) is a pure function: enableMeasurements/enableConstructionNotes default to true/true, and are overridden by an if/else-if chain of per-factory sibling branches: (1) millyEnabled && factoryCode === env.FACTORY_CODE_MILLY → Scarf gets measurements-only, anything else gets both off; (2) poCategory === PO_CATEGORIES.SCARF && factoryCode === env.FACTORY_CODE_AOLONG → measurements-only, matching Milly Scarf (INFRA-613; re-keyed from the retired env.FACTORY_CODE_AOLONG_SCARF onto the Aolong factory code by INFRA-633, which folded Aolong Scarf into the Aolong Factory row). The second branch is deliberately not gated behind milly_enabled — that flag is Milly-specific, so no new feature flag was warranted. Note this branch is now live for real Aolong traffic rather than inert: it is the poCategory check alone that keeps Aolong dress work orders on the both-enabled default, so the two conditions are load-bearing together in a way they were not when the factory code was scarf-exclusive. Adding a third factory with the same rendering should follow the same shape (a new else if) rather than generalizing the Milly branch — keeping Milly's behavior provably untouched has been the priority each time. Not unit-tested directly (not exported) — covered indirectly via makeWorkOrderData/makeBatchInfosByPoId assertions in workorder.service.spec.ts.
  • _resolveJewelrySizeRangeIds() (~line 1361) resolves isJewelry inputs — see the Jewelry gotchas above.
  • _createWorkOrderDataByRecords({ records: { wO, pO, style, color, size, shipment, enableMeasurements, enableConstructionNotes, isJewelry }, lang }) (~line 1154) is the single shared constructor for a WorkOrderData object — both makeWorkOrderData (single WO) and _makeWorkOrderDatas (batch, called once per WO inside its .map()) build their records input and delegate here. Any new field belongs on this shared records param + return object, not duplicated at each call site.
  • Call-site shape: both makeWorkOrderData and _makeWorkOrderDatas compute millyEnabled/jewelrySizeRangeIds first, call _resolveEnablementFlags (batch: once per request, not per-WO — enableMeasurements/enableConstructionNotes are treated as PO-wide since Milly's gate only depends on factoryCode+poCategory, both singular per PO), then compute isJewelry per-style (batch: per-WO, inside the .map(), since a style's sizeRangeId varies per WO) before calling _createWorkOrderDataByRecords. Any new resolved field whose truth depends on isJewelry (like a jewelry-specific enablement override) therefore cannot piggyback on the once-per-request _resolveEnablementFlags call in the batch path — it needs its own per-WO override step inside the map, applied after the shared once-per-request flags are computed, so a mixed-category batch's non-Jewelry WOs keep their normal Milly-derived flags.
  • Two independent image-fetch trigger points, both gated on enableConstructionNotes or enableJewelryImages (any future flag that hides Construction Notes for a subset of WOs must update both, or images silently stop loading for that subset):
    1. WorkOrderTemplate/utils.tsx#useWorkOrder's getSkuImages call, gated by if (responseWorkOrderData.enableConstructionNotes || responseWorkOrderData.enableJewelryImages). Feeds WorkOrderTemplate.tsx and Dashboard/WorkOrderModal.tsx (both call the useWorkOrder hook directly for a single WO).
    2. pagesUtils.ts#workOrderDatasFetchImages's shouldFetchImages = firstData.enableConstructionNotes || workOrderDatas.some((wo) => wo.enableJewelryImages). The enableConstructionNotes half reads only firstData (safe — it's PO-wide), but the enableJewelryImages half uses .some() across the whole batch (NOT firstData alone) since it depends on per-WO isJewelry and a mixed batch's Jewelry WOs would otherwise get no images if firstData itself isn't Jewelry. Feeds WorkOrderList.tsx and BatchDownloadWorkorder.tsx (both fetch a WorkOrderData[] upfront and call workOrderDatasFetchImages once for the whole batch, not through the hook).
  • Work Order Template subcomponent pattern: WorkOrderConstructionNotes.tsx and single-row sections follow a single-<table>-single-<tr> shape with the section label as the first <td> (bold, translated via useTranslation().translateComponent('<Component>.<key>')) and content in the following <td>(s) — see WorkOrderConstructionNotes.tsx for the minimal example. WorkOrderMeasurement/WorkOrderMaterial are the multi-row variants of the same translation convention. None of these subcomponents are registered in componentLoader.ts — they're plain React components imported by relative path directly into WorkOrderMainDisplay.tsx (consistent with the project-wide rule that src/components/** runtime imports must be relative, only AdminJS-mounted components go through componentLoader.ts).
  • Locale files are flat, not directory-per-locale: src/locales/en.json and src/locales/zh_CN.json (not src/locales/en/*.json as one might expect) — both files mirror the same key structure at the same line numbers, so a new component's translation keys should be inserted at the equivalent alphabetical position in both files together (e.g. WorkOrderConstructionNotes/WorkOrderHeader/WorkOrderImages/WorkOrderMeasurement/WorkOrderTemplate all sit at identical line numbers in both files today).

Implementation (INFRA-593 — the Work Order Template display-side follow-up to INFRA-590/591, "Images section behind jewelry_enabled"):

  • New feature flag FEATURE_FLAG_KEYS.jewelryEnabled = 'jewelry_enabled' (src/constants/featureFlagKeys.ts), read via isFeatureEnabled alongside millyEnabled in both makeWorkOrderData and _makeWorkOrderDatas.
  • New WorkOrderData.enableJewelryImages: boolean field — true only when jewelry_enabled is on and isJewelry is true (never derived from PurchaseOrder.category, matching the existing isJewelry precedent, so a Dress-category style is unaffected by the flag regardless of state). Added to workorder.types.d.ts and WorkOrderBatchInfosByPoIdResponseSchema (the singular WorkOrderResponseSchema needs no change — .passthrough()).
  • New module-private _applyJewelryImagesOverride(flags, { jewelryEnabled, isJewelry }) in workorder.service.ts, deliberately kept separate from _resolveEnablementFlags rather than folding jewelry inputs into it: _resolveEnablementFlags (Milly-only) is still called once-per-request in the batch path, while _applyJewelryImagesOverride is layered on top — once-per-request in the single-WO path (isJewelry known upfront), but once per WO inside the batch .map() (since isJewelry varies per style within a batch). It returns { enableMeasurements, enableConstructionNotes, enableJewelryImages }, forcing the first two to false whenever enableJewelryImages is true. This two-function split is the reason a mixed Dress+Jewelry batch works correctly: the batch-wide data-loading gate (whether to bother querying GarmentMeasurement/MaterialConstruction/Measurement/Material at all) stays keyed off the Milly-only result, while only the per-WO display flags get the jewelry override — so a mixed batch's non-Jewelry WOs still get their materials/measurements data loaded even though some Jewelry WOs in the same batch null theirs out.
  • New WorkOrderImages.tsx component (src/components/pages/WorkOrderTemplate/) — single-row-table shape per the subcomponent pattern above: label cell ("Images", WorkOrderImages.images key) | ${styleName} - ${colorName} text (via getLocalizedValue, same format INFRA-590 uses for the Shopify title lookup) | the synced photo or a WorkOrderImages.noImage fallback. Wired into WorkOrderMainDisplay.tsx as a third gated section: {data.enableJewelryImages && <WorkOrderImages .../>}, alongside (not replacing) the existing enableMeasurements/enableConstructionNotes branches — no extra hiding logic needed there since the backend already zeroes those two out whenever enableJewelryImages is true.
  • Both image-fetch trigger points (above) updated in the same change to also check enableJewelryImages, since the ticket that introduced this flag only initially called out the useWorkOrder one — the batch-print pagesUtils.ts one has the identical structural dependency on enableConstructionNotes and would have silently stopped fetching images for Jewelry-flagged batches otherwise.

Vitest gotchas hit while adding test coverage for the above (generic to this codebase's test setup, not Jewelry-specific — worth knowing before writing any new spec):

  • Running many spec files concurrently in this sandboxed dev environment can produce spurious test timeouts unrelated to any code change — e.g. useWorkOrder's image-fetch tests (5s testTimeout) intermittently failed with wildly inflated reported durations (400s+) when run alongside ~8 other files, but passed in under 200ms when run alone or with vitest run <file> --pool=forks --poolOptions.forks.singleFork=true. Before concluding a timeout is a real regression, re-run just the failing file in isolation. However, --pool=forks --singleFork=true must never be used for a full-suite run — forcing every spec file into one process removes the per-file isolation Vitest normally provides (each file/worker gets a fresh module registry), and unrelated files' mocks/global state (e.g. Select.spec.tsx, Filter.spec.tsx, ActionHeader.spec.tsx — nothing to do with WorkOrder) started failing when the entire ~140-file suite was forced through one fork. The plain default npx vitest run (no pool override — same as CI) is the only trustworthy way to validate the full suite; reach for the single-fork flag only to de-flake one already-isolated file or small directory.
  • mockReset: true is set globally (vitest.config.ts), meaning Vitest calls the equivalent of vi.resetAllMocks() before every test — this clears not just call history but also any mockImplementation/mockReturnValue set on a vi.fn(). A vi.mock('some/module', () => ({ foo: vi.fn().mockReturnValue(defaultValue) })) pattern therefore does not give foo a stable default across tests — the default gets wiped before the first test even runs, and every test must re-establish behavior itself (in its own beforeEach/test body) via vi.mocked(foo).mockReturnValue(...). The established workaround for a shared, stable default (see shipment.controller.spec.ts's parseSkuInfos mock) is to make the mock export a plain function, not a vi.fn() — a manually-provided vi.mock() factory's plain function exports are not tracked by Vitest's mock registry and are therefore immune to mockReset/clearAllMocks.
  • vi.mock('some/module') with no factory (auto-mock) still evaluates the real module once, to introspect its real exports before replacing them with mocks. If the real module has import-time side effects that throw under a test's mocked environment (e.g. clients/shopifyClient/shopifyClient.ts calls shopifyApi({...}) at module scope, which throws if the test's vi.mock('utils/envConfig', ...) replacement lacks real SHOPIFY_* values), auto-mocking does not protect against that — the real top-level code still runs during the introspection pass. Any spec whose import chain transitively reaches shopifyClient.ts under a stripped-down fake envConfig mock (as of INFRA-590: purchaseorder.router.spec.ts, shipment.router.spec.ts, shippinglabel.router.spec.ts, workorder.router.spec.ts — all reach it via services/workorder or services/shipment importing services/product) must provide an explicit factoryvi.mock('clients/shopifyClient/shopifyClient', () => ({ shopifyGetProductImageBySku: vi.fn(), shopifyGetProductMediaByTitle: vi.fn() })) — to skip real-module evaluation entirely.
  • A .spec.ts (not .spec.tsx) file needs a // @vitest-environment jsdom docblock as its first line to use renderHook/React Testing Library. vitest.config.ts's environmentMatchGlobs only gives jsdom to *.spec.tsx; a .ts file (no JSX, but still testing a React hook — e.g. WorkOrderTemplate/utils.spec.ts#useWorkOrder) defaults to the node environment and renderHook fails with ReferenceError: document is not defined. Timeline/hooks/useTimelineParams.spec.ts already established this pattern; reuse it rather than renaming the file to .tsx.
  • The global models/mock/index.ts SizeRange.findMany mock only filtered by where.sizeRangeId.in, silently ignoring any other where shape (returning all fixture rows unfiltered). INFRA-590's _resolveJewelrySizeRangeIds() queries by where: { name: { in: [...] } } instead — fixed the shared mock to also filter on where.name.in so specs get a correct (usually empty) result instead of a false-positive "everything matches." Worth checking this mock's filter branches stay in sync if a new query shape against SizeRange/Style/etc. gets added elsewhere — the shared mocks in models/mock/*.ts only support the where shapes their existing callers have needed so far.