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>+ colorGR0018(Sage) + L-size code004. 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 fromprisma/seed/data/sizes.json(size === 'L'→sizeCode '004') — not from the DB. This SKU literal was duplicated in two places (seed-productimage.tsand the Stylehandlers.tsaddStyleProductImage). - Core service:
ProductService.syncImages(sku, prismaInstance?)(src/services/product/product.service.ts:19) — callsshopifyGetProductImageBySku(sku)(src/clients/shopifyClient/shopifyClient.ts, GraphQLproductVariants(first:1, query:"sku:<sku>")), filtersmedia.nodestoMediaContentType.Image, dedupes by URL, then in a$transaction(20s timeout)upsertManys the images (position-ordered) anddeleteManys any rows atposition >= newCount. ThrowsmakeBadRequestErrorif no variant matches the SKU (product.service.ts:23-25). The optionalprismaInstanceparam exists so the standalone seed script'snew PrismaClient()can be threaded through. - AI ordering:
classifyProductImagePosBySku(sku, rules?)(src/utils/classifyProductImagePos.ts) uses OpenAI to classify each image front/back/other and rewritesposition(front→0, back→1, rest after). Called as a separate step aftersyncImages. - Trigger points (4 call sites for
syncImages):- REST API —
POST /api/product/v1/syncImages/:sku(apiKeyAuth, controllersrc/controllers/product/product.controller.ts:23). Validates, callssyncImages, thensetImmediate(classifyProductImagePosBySku). On error callscaptureSyncProductImageFailed(req, err)and rethrows. This is the N8N-driven path. - AdminJS New-Style form —
newActionHandler(src/routers/admin/resources/style/handlers.ts:77-82) runssetImmediate(() => addStyleProductImage(styleNumber) → classify)after creating the style.addStyleProductImage(handlers.ts:176) is fire-and-forget with all errors swallowed twice (its own try/catch + thesetImmediatebody), and its comment claiming "Sentry logging" in the service layer is inaccurate —syncImagesdoes no Sentry logging. So a transient failure leaves the style permanently imageless and invisible. - Manual seed script —
npm run db:seed:productimage→prisma/seed/run-productimage.ts(loads all non-deleted styles) →seedProductImage(prisma, styleNumbers)(prisma/seed/seed-productimage.ts). Per-style try/catch buildssuccessSkus/failedSkus, prints a report, then runs the classify loop over successes. This is the manual recovery path operators run when images are missing. - AdminJS Edit-Style — none.
editActionHandlernever re-syncs images.
- REST API —
- Gap (pre-INFRA-542): the Airtable sync path has no image trigger at all.
WebhooksService.syncAirtableStyle(src/services/webhooks/webhooks.service.ts:20) →StyleModel.upsertByAirtableRecordIdnever 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 onlyreq.params?.sku— so it was usable only from the controller, not background/webhook callers (tags:module=productImage,processName=syncProductImage,eventType=failed). Constants live insrc/constants/sentryTags.ts(SENTRY_SYNC_PRODUCT_IMAGE_EVENT_TYPE.failed).captureProductImagesClassifyDonelogs classify outcomes. - Specs:
product.service.spec.tsexists (mocksshopifyClient,prismaClient.$transaction,productImagemodel — note it still usesit());webhooks.service.spec.tsexists. No spec covers the Stylehandlers.tsimage 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: nullon every variant. Size+Carat families (Ring+Carat, Necklace+Carat, Ring+Carat Two Stone, Earring Carat) do have SKUs, but they're sequential placeholders likeTEST-FJ-0184— unrelated to MES'sstyleNumber+colorCode+sizeCodescheme. Neither family is reachable bysku: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 genericcolor = "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 exactStyle.styleNameEnmatch). For the colorway-split families, the Shopify product title further appends" - " + <attr.color>, and MES'sColor.colorNameEnalready has matching rows (Green Amethyst,White Topaz,Blue Topaz,Diamond, etc.) — so the full lookup is: trytitle:"<styleNameEn>"first (works for Diamond/Carat family), fall back totitle:"<styleNameEn> - <colorNameEn>"using the Work Order's actualcolorId(works for colorway-split families). No Shopify product ID/handle is captured anywhere in MES's Airtable→Style pipeline (AirtableStyleWebhookBodySchemaonly carriesstyleNumber/styleName/sizeRangeRecordId), so title reconstruction is the only available link. - Detecting "is this Style Jewelry" must be done by SizeRange name, not
sizeRangeIdor 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 differentsizeRangeIdandairtableRecordIdvalues 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.nameis the only field confirmed stable across environments — mirrors the existing codebase precedent of matching Milly/Jewelry factories byfactoryNameEnrather thanfactoryId/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/create→workorderService.create(req)(src/services/workorder/workorder.service.ts); and (2) the bulkPOST /shipment/.../create→shipment.controller.ts'screate()→workorderService.batchCreateByTransaction(inside aprisma.$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 onwhere, not oninclude/select. Multiple call sites (skuUtils.ts#parseSkuInfo,style.service.ts,webhooks.service.ts) callfindStyleUnique({ where: { styleNumber } })with noinclude. Addinginclude: { sizeRange: true }to one caller risks a stale/narrower cached object (populated by a different caller with noinclude) silently missing the relation, or vice versa. Do not add a newincludeshape to an existingfindStyleUniquecall site that shares awhereshape with other callers — do a separate, differently-keyed lookup instead (e.g. viaSizeRangeModel, a different cache namespace) when a caller needs an additional relation the existing callers don't. This is whyparseSkuInfo(singular) resolves the SizeRange name via its ownSizeRangeModel.findSizeRangeUnique({ sizeRangeId })call rather than addinginclude: { sizeRange: true }to its existingfindStyleUniquecall. - MES already carries everything needed to resolve Jewelry colorways —
Colortable has real rows for gemstone names (Green AmethystGR0040,White TopazWT0016,Blue TopazBL0048,DiamondWT0015) alongside metal-tone colors (GoldNT0009,White GoldNT0024,Yellow GoldNT0025,Rose GoldPK0026) — 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.ts—JEWELRY_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 bothparseSkuInfo(singular; resolved via a dedicatedSizeRangeModel.findSizeRangeUniquecall, run in parallel with the existing size lookup) andparseSkuInfos(batch; populated from thesizeRangeId2NameMapit already built internally for error messages — no new query needed there).src/clients/shopifyClient/shopifyClient.ts—shopifyGetProductMediaByTitle(title): GraphQLproducts(first:1, query: 'title:"<title>"'), returns the product'smediadirectly (no variant match needed, unlike the SKU-based query).src/services/product/product.service.ts—syncImages's dedupe+upsert/delete logic is extracted into a sharedpersistProductImages(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`(syntheticProductImage.skucache 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 unlessisJewelrySizeRangeName(sizeRangeName)— the single entry point both creation paths call). UnlikesyncStyleImagesBestEffort, this does not runclassifyProductImagePosBySku— Jewelry has exactly one image, so there's no front/back to classify.- Hooked into
workorder.service.ts#create(singular path, fires afterworkOrderModel.createsucceeds) andshipment.controller.ts#create(batch path, fires in the existing post-transaction "don't block the response" section, deduped by`${styleId}-${colorId}`across the wholeskuNumber2SkuInfoMapso 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 batchWorkOrderBatchInfosByPoIdResponseSchemainschemas/workorder/index.ts; the singularWorkOrderResponseSchemauses.passthrough()so it didn't need a change). Computed by a shared_resolveJewelrySizeRangeIds()helper inworkorder.service.ts— oneSizeRangeService.findMany({ where: { name: { in: [...JEWELRY_SIZE_RANGE_NAMES] } } })call per request, returning aSet<sizeRangeId>— called once inmakeWorkOrderData(single WO) and once in_makeWorkOrderDatas(batch, computed outside the per-WO.map()so it's not re-queried per row). Threaded through_createWorkOrderDataByRecords'srecordsparam alongsideenableMeasurements/enableConstructionNotes.src/components/config.ts#makeSkuForGetImages(style, colorCode?, isJewelry?)— gained two optional params. WhenisJewelry && colorCode, builds`${style}${colorCode}000`(must exactly match the backend'sProductService.buildJewelrySkusynthetic key, including the'000'size placeholder — duplicated asjewelrySizeCodeForImagesin this file, mirroring the existingcolorCodeForImages/sizeCodeForImagesDress-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 juststyle.WorkOrderTemplate/utils.tsx#useWorkOrder—getSkuImagesnow takes(sku, colorCode, isJewelry), reading the realcolor.colorCode/isJewelryoff the fetchedWorkOrderData, and requestsfetchSkuImages(sku, isJewelry ? 1 : 2).WorkOrderMaterial.tsx— the materials table's image column was already built on arowSpantrick (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 differentrowSpanmath.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 becausemakeSkuForGetImagesused to ignore color entirely. Now keys by a newmakeSkuForGetImagesForWorkOrder(workOrderData)helper (style + the WO's owncolor.colorCode+isJewelry) — for Dress this still collapses to one shared key per style (sincemakeSkuForGetImagesignorescolorCodeunlessisJewelryis true, so behavior is unchanged), but for Jewelry it correctly fetches a distinct image per (style, color) pair.firstcount checks every WO in the batch (firstData.isJewelry && workOrderDatas.every((wo) => wo.isJewelry)), not justfirstDataalone — falls back to 2 unless the whole batch agrees it's Jewelry (see the "Mixed batch" defensive guard test inpagesUtils.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/enableConstructionNotesdefault totrue/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 retiredenv.FACTORY_CODE_AOLONG_SCARFonto the Aolong factory code by INFRA-633, which folded Aolong Scarf into the Aolong Factory row). The second branch is deliberately not gated behindmilly_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 thepoCategorycheck 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 newelse 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 viamakeWorkOrderData/makeBatchInfosByPoIdassertions inworkorder.service.spec.ts._resolveJewelrySizeRangeIds()(~line 1361) resolvesisJewelryinputs — 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 aWorkOrderDataobject — bothmakeWorkOrderData(single WO) and_makeWorkOrderDatas(batch, called once per WO inside its.map()) build theirrecordsinput and delegate here. Any new field belongs on this sharedrecordsparam + return object, not duplicated at each call site.- Call-site shape: both
makeWorkOrderDataand_makeWorkOrderDatascomputemillyEnabled/jewelrySizeRangeIdsfirst, call_resolveEnablementFlags(batch: once per request, not per-WO —enableMeasurements/enableConstructionNotesare treated as PO-wide since Milly's gate only depends onfactoryCode+poCategory, both singular per PO), then computeisJewelryper-style (batch: per-WO, inside the.map(), since a style'ssizeRangeIdvaries per WO) before calling_createWorkOrderDataByRecords. Any new resolved field whose truth depends onisJewelry(like a jewelry-specific enablement override) therefore cannot piggyback on the once-per-request_resolveEnablementFlagscall 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
enableConstructionNotesorenableJewelryImages(any future flag that hides Construction Notes for a subset of WOs must update both, or images silently stop loading for that subset):WorkOrderTemplate/utils.tsx#useWorkOrder'sgetSkuImagescall, gated byif (responseWorkOrderData.enableConstructionNotes || responseWorkOrderData.enableJewelryImages). FeedsWorkOrderTemplate.tsxandDashboard/WorkOrderModal.tsx(both call theuseWorkOrderhook directly for a single WO).pagesUtils.ts#workOrderDatasFetchImages'sshouldFetchImages = firstData.enableConstructionNotes || workOrderDatas.some((wo) => wo.enableJewelryImages). TheenableConstructionNoteshalf reads onlyfirstData(safe — it's PO-wide), but theenableJewelryImageshalf uses.some()across the whole batch (NOTfirstDataalone) since it depends on per-WOisJewelryand a mixed batch's Jewelry WOs would otherwise get no images iffirstDataitself isn't Jewelry. FeedsWorkOrderList.tsxandBatchDownloadWorkorder.tsx(both fetch aWorkOrderData[]upfront and callworkOrderDatasFetchImagesonce for the whole batch, not through the hook).
- Work Order Template subcomponent pattern:
WorkOrderConstructionNotes.tsxand single-row sections follow a single-<table>-single-<tr>shape with the section label as the first<td>(bold, translated viauseTranslation().translateComponent('<Component>.<key>')) and content in the following<td>(s) — seeWorkOrderConstructionNotes.tsxfor the minimal example.WorkOrderMeasurement/WorkOrderMaterialare the multi-row variants of the same translation convention. None of these subcomponents are registered incomponentLoader.ts— they're plain React components imported by relative path directly intoWorkOrderMainDisplay.tsx(consistent with the project-wide rule thatsrc/components/**runtime imports must be relative, only AdminJS-mounted components go throughcomponentLoader.ts). - Locale files are flat, not directory-per-locale:
src/locales/en.jsonandsrc/locales/zh_CN.json(notsrc/locales/en/*.jsonas 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/WorkOrderTemplateall 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 viaisFeatureEnabledalongsidemillyEnabledin bothmakeWorkOrderDataand_makeWorkOrderDatas. - New
WorkOrderData.enableJewelryImages: booleanfield — true only whenjewelry_enabledis on andisJewelryis true (never derived fromPurchaseOrder.category, matching the existingisJewelryprecedent, so a Dress-category style is unaffected by the flag regardless of state). Added toworkorder.types.d.tsandWorkOrderBatchInfosByPoIdResponseSchema(the singularWorkOrderResponseSchemaneeds no change —.passthrough()). - New module-private
_applyJewelryImagesOverride(flags, { jewelryEnabled, isJewelry })inworkorder.service.ts, deliberately kept separate from_resolveEnablementFlagsrather than folding jewelry inputs into it:_resolveEnablementFlags(Milly-only) is still called once-per-request in the batch path, while_applyJewelryImagesOverrideis layered on top — once-per-request in the single-WO path (isJewelryknown upfront), but once per WO inside the batch.map()(sinceisJewelryvaries per style within a batch). It returns{ enableMeasurements, enableConstructionNotes, enableJewelryImages }, forcing the first two tofalsewheneverenableJewelryImagesistrue. This two-function split is the reason a mixed Dress+Jewelry batch works correctly: the batch-wide data-loading gate (whether to bother queryingGarmentMeasurement/MaterialConstruction/Measurement/Materialat 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.tsxcomponent (src/components/pages/WorkOrderTemplate/) — single-row-table shape per the subcomponent pattern above: label cell ("Images",WorkOrderImages.imageskey) |${styleName} - ${colorName}text (viagetLocalizedValue, same format INFRA-590 uses for the Shopify title lookup) | the synced photo or aWorkOrderImages.noImagefallback. Wired intoWorkOrderMainDisplay.tsxas a third gated section:{data.enableJewelryImages && <WorkOrderImages .../>}, alongside (not replacing) the existingenableMeasurements/enableConstructionNotesbranches — no extra hiding logic needed there since the backend already zeroes those two out wheneverenableJewelryImagesis 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 theuseWorkOrderone — the batch-printpagesUtils.tsone has the identical structural dependency onenableConstructionNotesand 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 (5stestTimeout) intermittently failed with wildly inflated reported durations (400s+) when run alongside ~8 other files, but passed in under 200ms when run alone or withvitest 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=truemust 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 defaultnpx 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: trueis set globally (vitest.config.ts), meaning Vitest calls the equivalent ofvi.resetAllMocks()before every test — this clears not just call history but also anymockImplementation/mockReturnValueset on avi.fn(). Avi.mock('some/module', () => ({ foo: vi.fn().mockReturnValue(defaultValue) }))pattern therefore does not givefooa stable default across tests — the default gets wiped before the first test even runs, and every test must re-establish behavior itself (in its ownbeforeEach/test body) viavi.mocked(foo).mockReturnValue(...). The established workaround for a shared, stable default (seeshipment.controller.spec.ts'sparseSkuInfosmock) is to make the mock export a plain function, not avi.fn()— a manually-providedvi.mock()factory's plain function exports are not tracked by Vitest's mock registry and are therefore immune tomockReset/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.tscallsshopifyApi({...})at module scope, which throws if the test'svi.mock('utils/envConfig', ...)replacement lacks realSHOPIFY_*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 reachesshopifyClient.tsunder a stripped-down fakeenvConfigmock (as of INFRA-590:purchaseorder.router.spec.ts,shipment.router.spec.ts,shippinglabel.router.spec.ts,workorder.router.spec.ts— all reach it viaservices/workorderorservices/shipmentimportingservices/product) must provide an explicit factory —vi.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 jsdomdocblock as its first line to userenderHook/React Testing Library.vitest.config.ts'senvironmentMatchGlobsonly givesjsdomto*.spec.tsx; a.tsfile (no JSX, but still testing a React hook — e.g.WorkOrderTemplate/utils.spec.ts#useWorkOrder) defaults to thenodeenvironment andrenderHookfails withReferenceError: document is not defined.Timeline/hooks/useTimelineParams.spec.tsalready established this pattern; reuse it rather than renaming the file to.tsx. - The global
models/mock/index.tsSizeRange.findManymock only filtered bywhere.sizeRangeId.in, silently ignoring any otherwhereshape (returning all fixture rows unfiltered). INFRA-590's_resolveJewelrySizeRangeIds()queries bywhere: { name: { in: [...] } }instead — fixed the shared mock to also filter onwhere.name.inso 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 againstSizeRange/Style/etc. gets added elsewhere — the shared mocks inmodels/mock/*.tsonly support thewhereshapes their existing callers have needed so far.