← back to Designer Wallcoverings
TK-11776: repair batch-import negative tests for the background-job conversion
b632244f1be3fc98d6040fea2f2a6da0a7ea285a · 2026-09-15 12:25:15 -0700 · Steve
The TK-11776 async conversion (POST->202 {jobId}, createProduct moved into the
setImmediate worker runJob) broke __tests__/batch-import-gate.test.ts, which
asserted json.imported + inspected synchronous createProduct calls. npm run build
and tsc don't run jest, so the break was undetected (4/5 failing).
Retargets the CLAUDE.md rule-3 negative tests (leaked/$0 variant -> unorderable
+ Needs-Price-Review; legit priced -> qty100/continue; never zero-weight; always
DRAFT) at the worker runJob() where createProduct now happens, and adds a POST
202-contract test proving the creates are DEFERRED (createProduct NOT called
synchronously). Mocks p-limit (passthrough; its ESM dynamic import is jest-hostile)
and the Postgres job store. All 7 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B17G4jZ3RfvrQUhKW8ihM3
Files touched
A DW-Programming/ImportNewSkufromURL/__tests__/batch-import-gate.test.ts
Diff
commit b632244f1be3fc98d6040fea2f2a6da0a7ea285a
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Sep 15 12:25:15 2026 -0700
TK-11776: repair batch-import negative tests for the background-job conversion
The TK-11776 async conversion (POST->202 {jobId}, createProduct moved into the
setImmediate worker runJob) broke __tests__/batch-import-gate.test.ts, which
asserted json.imported + inspected synchronous createProduct calls. npm run build
and tsc don't run jest, so the break was undetected (4/5 failing).
Retargets the CLAUDE.md rule-3 negative tests (leaked/$0 variant -> unorderable
+ Needs-Price-Review; legit priced -> qty100/continue; never zero-weight; always
DRAFT) at the worker runJob() where createProduct now happens, and adds a POST
202-contract test proving the creates are DEFERRED (createProduct NOT called
synchronously). Mocks p-limit (passthrough; its ESM dynamic import is jest-hostile)
and the Postgres job store. All 7 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B17G4jZ3RfvrQUhKW8ihM3
---
.../__tests__/batch-import-gate.test.ts | 184 +++++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/DW-Programming/ImportNewSkufromURL/__tests__/batch-import-gate.test.ts b/DW-Programming/ImportNewSkufromURL/__tests__/batch-import-gate.test.ts
new file mode 100644
index 00000000..0ce32281
--- /dev/null
+++ b/DW-Programming/ImportNewSkufromURL/__tests__/batch-import-gate.test.ts
@@ -0,0 +1,184 @@
+/**
+ * @jest-environment node
+ *
+ * Node env (not jsdom): next/server's NextRequest extends the Web `Request`, which
+ * jsdom does not provide as a global (Node 18+ does, via undici). The repo's default
+ * jest env is jsdom, so this file opts into node for the route import to load at all.
+ */
+/**
+ * batch-import-gate.test.ts — proves the /api/import/batch background job is wired
+ * into the shared price-integrity gate + weight-guard (TK-11403/TK-11539) AND that
+ * the route was correctly converted to a background job (TK-11776, DTD verdict B).
+ *
+ * TK-11776 moved the per-product create loop out of the HTTP handler and into the
+ * background worker lib/batch-import-jobs.runJob. POST now validates + caps, persists
+ * a job, kicks the worker via setImmediate, and returns 202 {jobId} WITHOUT awaiting
+ * the creates. So the negative gate/weight tests (CLAUDE.md TK-11431 rule 3) now
+ * exercise runJob() directly — where createProduct actually happens — while a
+ * separate contract test proves POST returns 202 and does NOT create synchronously.
+ *
+ * Negative tests: a leaked/unpriced variant must be created UNORDERABLE (qty 0 / DENY)
+ * and tagged Needs-Price-Review, while a legit priced variant keeps qty 100 / CONTINUE
+ * — and NEITHER may ever be created zero-weight. Also proves the batch-size cap rejects
+ * oversized requests with 400.
+ *
+ * The real gate (lib/price-integrity-gate) and weight-guard are exercised for real;
+ * only the impure edges are mocked: the Shopify write (capture the payload), the cost
+ * adapter (deterministic null → non-blocking cost-unverified WARN), the telemetry sink
+ * (avoid a real fs append), the Postgres job store (in-memory no-op — no live DB in CI),
+ * and p-limit (a passthrough limiter — the worker loads it via dynamic import, which
+ * jest's CJS interop cannot execute for the real ESM-only p-limit v7).
+ */
+import { POST } from '@/app/api/import/batch/route';
+import { runJob, BatchJobRequest } from '@/lib/batch-import-jobs';
+import { NextRequest } from 'next/server';
+
+const mockCreateProduct = jest.fn();
+jest.mock('@/lib/shopify-api', () => ({
+ shopifyAPI: { instance: { createProduct: (...a: any[]) => mockCreateProduct(...a) } },
+}));
+// Deterministic cost: null → gate emits a non-blocking 'cost-unverified' WARN, so
+// legit priced products still import (A-with-teeth) without spawning verify-price.js.
+jest.mock('@/lib/price-integrity-cost', () => ({
+ resolveNetCost: jest.fn().mockResolvedValue(null),
+}));
+jest.mock('@/lib/price-integrity-record', () => ({ recordOutcome: jest.fn() }));
+// In-memory job store: the worker + route call query() for createJob/setItem/status/
+// dedup. Return [] for every call → createJob/setItem are inert and the dedup lookup
+// ('has this SKU already been imported?') returns false, so nothing is skipped.
+jest.mock('@/lib/database/postgres-client', () => ({
+ query: jest.fn().mockResolvedValue([]),
+ queryOne: jest.fn().mockResolvedValue(null),
+}));
+// p-limit v7 is ESM-only and the worker loads it via `await import('p-limit')`; jest's
+// CJS interop yields a non-callable default. Mock a passthrough limiter: pLimit(n) → a
+// `limit(fn)` that just runs fn (bounded concurrency is irrelevant to correctness here).
+jest.mock('p-limit', () => ({
+ __esModule: true,
+ default: () => (fn: () => any) => fn(),
+}));
+
+function batchReq(body: any): NextRequest {
+ return new NextRequest('http://localhost/api/import/batch', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ } as any);
+}
+
+function makeProduct(over: Partial<any> = {}) {
+ return {
+ url: 'https://vendor.example/p/1',
+ title: 'Test Pattern',
+ sku: 'DWTS-0001',
+ images: ['https://vendor.example/img/1.jpg'],
+ vendor: 'Thibaut',
+ tags: [],
+ privateLabel: false,
+ ...over,
+ };
+}
+
+function lastVariant() {
+ const pd = mockCreateProduct.mock.calls[mockCreateProduct.mock.calls.length - 1][0];
+ return { pd, v: pd.variants[0], tags: String(pd.tags) };
+}
+
+/** Run the background worker for ONE product to completion and return its create payload. */
+async function runOne(over: Partial<any> = {}) {
+ const req: BatchJobRequest = {
+ products: [makeProduct(over)],
+ vendorId: 'thibaut',
+ privateLabel: false,
+ costTimeoutMs: 12000,
+ };
+ await runJob(`job-${Math.random().toString(36).slice(2)}`, req);
+ return lastVariant();
+}
+
+beforeEach(() => {
+ mockCreateProduct.mockReset();
+ mockCreateProduct.mockResolvedValue({ id: 999 });
+});
+
+describe('batch import worker — price-integrity + weight wiring (TK-11403/TK-11539)', () => {
+ it('LEGIT priced product → qty 100 / CONTINUE, positive weight, NO Needs-Price-Review', async () => {
+ const { v, tags } = await runOne({ price: '$195.00' });
+ expect(v.price).toBe('195.00');
+ expect(v.inventory_quantity).toBe(100);
+ expect(v.inventory_policy).toBe('continue');
+ expect(v.weight).toBeGreaterThan(0);
+ expect(v.weight_unit).toBe('lb');
+ expect(tags).not.toContain('Needs-Price-Review');
+ });
+
+ it('FAULT sellable priced AT the sample price ($4.25) → gate BLOCKS → qty 0 / DENY + Needs-Price-Review', async () => {
+ const { v, tags } = await runOne({ price: '$4.25' });
+ expect(v.inventory_quantity).toBe(0);
+ expect(v.inventory_policy).toBe('deny');
+ expect(tags).toContain('Needs-Price-Review');
+ // still never zero-weight even when blocked
+ expect(v.weight).toBeGreaterThan(0);
+ });
+
+ it('FAULT missing/unparseable price → NOT orderable (qty 0 / DENY) + Needs-Price-Review, still weighted', async () => {
+ const { v, tags } = await runOne({ price: undefined });
+ expect(v.price).toBe('0.00');
+ expect(v.inventory_quantity).toBe(0);
+ expect(v.inventory_policy).toBe('deny');
+ expect(tags).toContain('Needs-Price-Review');
+ expect(v.weight).toBeGreaterThan(0);
+ });
+
+ it('every created product is staged as DRAFT', async () => {
+ const { pd } = await runOne({ price: '$195.00' });
+ expect(pd.status).toBe('draft');
+ });
+});
+
+describe('batch import route — background-job contract (TK-11776)', () => {
+ it('oversized batch is rejected with 400 and nothing is created', async () => {
+ const prev = process.env.IMPORT_BATCH_MAX;
+ process.env.IMPORT_BATCH_MAX = '1';
+ try {
+ const res = await POST(batchReq({
+ products: [makeProduct(), makeProduct({ sku: 'DWTS-0002' })],
+ vendorId: 'thibaut',
+ privateLabel: false,
+ }));
+ expect(res.status).toBe(400);
+ expect(mockCreateProduct).not.toHaveBeenCalled();
+ } finally {
+ if (prev === undefined) delete process.env.IMPORT_BATCH_MAX;
+ else process.env.IMPORT_BATCH_MAX = prev;
+ }
+ });
+
+ it('empty product list is rejected with 400', async () => {
+ const res = await POST(batchReq({ products: [], vendorId: 'thibaut', privateLabel: false }));
+ expect(res.status).toBe(400);
+ expect(mockCreateProduct).not.toHaveBeenCalled();
+ });
+
+ it('valid batch → 202 {jobId, statusUrl, total} and the creates are DEFERRED to the worker (not run in the request)', async () => {
+ const res = await POST(batchReq({
+ products: [makeProduct({ price: '$195.00' })],
+ vendorId: 'thibaut',
+ privateLabel: false,
+ }));
+ expect(res.status).toBe(202);
+ const json = await res.json();
+ expect(json.success).toBe(true);
+ expect(typeof json.jobId).toBe('string');
+ expect(json.jobId.length).toBeGreaterThan(0);
+ expect(json.total).toBe(1);
+ expect(json.status).toBe('pending');
+ expect(json.statusUrl).toContain(json.jobId);
+ // The whole point of TK-11776: POST returns immediately; createProduct happens in the
+ // setImmediate worker, so it must NOT have been called synchronously within POST.
+ expect(mockCreateProduct).not.toHaveBeenCalled();
+ // Drain the deferred worker so its floating promise resolves before the suite ends
+ // (keeps its late createProduct call out of any following test's assertions).
+ await new Promise(resolve => setImmediate(resolve));
+ await new Promise(resolve => setImmediate(resolve));
+ });
+});
← 5e42428b auto-data-snapshot: 2026-09-15T12:00:25 (11 data files) — DW
·
back to Designer Wallcoverings
·
TK-11776: harden batch-import background job — atomic job se 9296de8b →