[object Object]

← 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

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 →