613
613parts an APC associate store
VITEST E2E SUITE · v0.4.0
Vitest · End-to-end integration tests

22 tests. one command.
against the real worker.

A complete Vitest suite that exercises every endpoint and the full nightly cron pipeline against a local wrangler dev server. Seeds R2 automatically before tests run, asserts on the actual ManyChat response shape, and verifies the catalog generator produces the expected outputs.

Framework
Vitest
Tests
22 cases
Suite
~4 sec
Coverage
3 endpoints
Setup
Auto-seed R2
Cost
$0
How to run

two terminals. about ten seconds.

Terminal 1 runs wrangler dev --local (port 8787). Terminal 2 runs npm test. The Vitest globalSetup hook verifies the dev server is up, then uploads all 7 seed files to local R2 before any test runs.

terminal 1 · dev server
$ cd worker $ npm install $ npm run dev ⛅️ wrangler 3.50.0 -------------------- ⎔ Starting local server... [wrangler:inf] Ready on http://127.0.0.1:8787 [wrangler:inf] Listening for requests...
terminal 2 · test runner
$ cd worker $ export CRON_TRIGGER_TOKEN=test-token $ npm test [setup] Checking 127.0.0.1:8787... [setup] ✓ wrangler dev is up [setup] Seeding R2 (7 files)... [setup] ✓ catalog-search-latest.json [setup] ✓ packages-latest.json [setup] ✓ ... (5 more) [setup] Ready. Running tests... ✓ er_search · 9 tests passed ✓ er_fulfillment · 6 tests passed ✓ cron pipeline · 3 tests passed ✓ health + routing · 4 tests passed Test Files 1 passed (1) Tests 22 passed (22) Duration 3.84s
The 22 test cases

every endpoint. every edge case.

Tests are grouped by endpoint and exercise both happy paths and edge cases. Search tests verify scoring + carousel shape. Fulfillment tests exercise all 4 lane branches. Cron tests verify the regeneration pipeline produces correct catalog and package outputs.

er_search · catalog query

9 tests POST /api/v1/er-search
01

finds ACDelco oil filter for "oil filter for my civic" with civic VIN

Asserts cf_top_match_sku=ACD-PF64, cf_top_match_price=14.99, and SKU appears in cf_match_results.

02

returns exact MPN match "PF64" as top result

Verifies the exact-MPN +1500 score boost — bare MPN should beat semantic matches.

03

finds F-150 brake pads for "brake pads f-150" with VIN

Verifies multi-token matching (brake + pads + f-150) plus fitment boost (+250 for vehicle match).

04

returns winter tires for "winter tires civic"

Both Michelin X-Ice and Bridgestone Blizzak in 215/50R17 should appear.

05

finds DAI Mission for brand+line query

Asserts brand match (+500) + line token in name = high score.

06

finds AMSOIL motor oil for "AMSOIL 5w30"

Brand exact match + tag match. Top result should be the AMSOIL OE 5W-30 quart bottle.

07

returns empty result with friendly message for gibberish

Asserts cf_match_count=0 and bot says "couldn't find a match" — no 400 status.

08

rejects empty/short query gracefully (no 400, friendly message)

POST with empty body should still return 200 + helpful prompt. ManyChat must never see non-200.

09

builds a Generic Template carousel with 3 cards

Verifies card schema: title, subtitle, image_url, 3 buttons (Buy/Specs/Compare), and "fits your car ✓" indicator when vehicle is provided.

er_fulfillment · lane logic

6 tests POST /api/v1/er-fulfillment
10

in-zone + small order: 3 lanes, default pickup_belleville, fee $15

K8N 4Z2 + $249 cart + tire=false. Lanes: pickup_belleville, pickup_kingston, drop_paid. Cart total with fee = $264.

11

in-zone + cart >= $300: free drop default, no paid drop offered

K8N 4Z2 + $419 cart. drop_free shows; drop_paid is hidden. Default = drop_free. Fee = $0.

12

tire+rim package: free drop default regardless of cart total

K7L 2R3 + $1,489 + tire=true. Branch routing kicks in (Kingston). drop_free is default.

13

out-of-zone: pickup-only, 2 lanes, fee $0

M5V 2T6 (Toronto). Only pickup lanes shown. cf_in_zone=false, cf_branch_origin="".

14

normalizes postal code with various whitespace + casing variants

Tests "K8N 4Z2", "k8n4z2", "K8N4Z2", " K8N 4Z2 " — all return identical results. Verifies normalization is robust.

15

rejects missing postal code with friendly message

Empty cf_postal returns 200 + bot says "postal code is required" — graceful UX.

cron pipeline · catalog regeneration

3 tests POST /cron/run
16

rejects unauthenticated cron trigger with 401

POST without Authorization header should return 401. Manual cron trigger is bearer-protected.

17

rejects cron trigger with wrong bearer token

POST with Bearer wrong-token should return 401. Constant-time comparison via env match.

18

regenerates catalog and packages from seed inputs

Full pipeline: trigger cron → wait 7s → read R2 → verify catalog has 34 SKUs with stock + fits joined, packages have correct schema, and CX-5/Outback are correctly skipped (no compatible rims/tires). Only runs if CRON_TRIGGER_TOKEN is set.

health + routing

4 tests GET / POST · misc paths
19

GET /health returns "ok"

Liveness check used by Cloudflare uptime monitoring.

20

GET on a POST-only endpoint returns 405

Verifies Allow: POST response header is set correctly.

21

unknown path returns 404

POST to /api/v1/er-nonsense falls through the switch to 404.

22

malformed JSON body returns 400

The router catches JSON parse errors before they reach handlers.

Project structure

six new files. zero dependencies on the bot code.

All test infrastructure lives in test/. Helpers are isolated in test/helpers/ so the test file itself stays readable. Test data is the seed JSON/CSV from the previous deliverable — no duplication.

worker/ ├── package.json # EDIT · added test scripts + @types/node ├── tsconfig.json # EDIT · includes test/**/*.ts ├── vitest.config.ts NEW# Globals, timeouts, globalSetup hook └── test/ ├── e2e.test.ts NEW# 22 test cases (~327 lines) ├── helpers/ │ ├── global-setup.ts NEW# Verify dev server + seed R2 │ ├── api-client.ts NEW# fetch wrappers + ManyChat types │ ├── r2-utils.ts NEW# Read R2 via wrangler CLI │ ├── assertions.ts NEW# Extract shapes from responses │ └── seed.mjs NEW# Standalone R2 seeder (npm run test:seed) └── data/ # Existing seed files from prior deliverable ├── catalog-search-latest.json ├── packages-latest.json ├── dai-master-sample.csv ├── parts-latest.json ├── stock-latest.json ├── fitment-latest.json ├── vehicles-latest.json └── README.md
5 new test files 3 edits ~700 lines added 1 new dep · @types/node
Test architecture

four small helpers do all the heavy lifting.

global-setup.ts

Runs once before any test. Pings http://127.0.0.1:8787/health with retries — fails fast with a clear "start npm run dev" message if dev server isn't up. Then uploads all 7 seed files via wrangler r2 object put --local.

Optionally calls /cron/run to invalidate KV cache if CRON_TRIGGER_TOKEN is set.

api-client.ts

Two functions: postJSON(path, body) for ManyChat External Requests, and postCron(token) for the manual cron trigger. Both throw on 5xx but return successfully on 4xx (so tests can assert on graceful error responses).

Includes ManyChat response types so test assertions stay strongly-typed.

r2-utils.ts

Wraps wrangler r2 object get --pipe --local for reading R2 objects mid-test. Also has r2KeyExists() and deleteR2Object() for tests that need to verify clean-slate behavior.

Uses execSync — synchronous on purpose, since Vitest awaits already.

assertions.ts

Five small extractors: getSearchSKUs, getCarouselElements, getAllText, getFulfillmentLanes, getQRLabels. Each pulls a specific shape out of the ManyChat response so test assertions stay readable.

The shape-knowledge lives here — when ManyChat's response format evolves, only this file needs updating.

Sample test · the e2e file

a fulfillment test, end to end.

Each test is ~10 lines: build the request, send it, extract the shape, assert. The helpers handle network errors, response parsing, and ManyChat type knowledge.

test/e2e.test.ts
excerpt · fulfillment lane test
import { describe, it, expect } from 'vitest';
import { postJSON } from './helpers/api-client';
import { getFulfillmentLanes, getQRLabels } from './helpers/assertions';

describe('er_fulfillment · lane logic', () => {
  it('in-zone + small order: pickup + paid drop · default pickup_belleville · fee $15', async () => {
    const r = await postJSON('/api/v1/er-fulfillment', {
      cf_postal: 'K8N 4Z2',
      cf_cart_total: 249,
      cf_is_tire_package: false,
    });

    const lanes = getFulfillmentLanes(r);
    expect(lanes).toEqual(['pickup_belleville', 'pickup_kingston', 'drop_paid']);
    expect(r.set_field_value?.cf_fulfillment_default).toBe('pickup_belleville');
    expect(r.set_field_value?.cf_delivery_fee).toBe(15);
    expect(r.set_field_value?.cf_in_zone).toBe(true);
    expect(r.set_field_value?.cf_branch_origin).toBe('belleville');
    expect(r.set_field_value?.cf_cart_total_with_fee).toBe(264);

    const labels = getQRLabels(r);
    expect(labels).toContain('Drop off · $15');
  });

  it('tire+rim package: free drop default regardless of cart total', async () => {
    const r = await postJSON('/api/v1/er-fulfillment', {
      cf_postal: 'K7L 2R3',
      cf_cart_total: 1489,
      cf_is_tire_package: true,
    });

    const lanes = getFulfillmentLanes(r);
    expect(lanes).toContain('drop_free');
    expect(lanes).not.toContain('drop_paid');
    expect(r.set_field_value?.cf_fulfillment_default).toBe('drop_free');
    expect(r.set_field_value?.cf_delivery_fee).toBe(0);
    expect(r.set_field_value?.cf_branch_origin).toBe('kingston');
  });
});
test/helpers/global-setup.ts
excerpt · the readiness check
async function waitForDevServer(timeoutMs = 10_000, intervalMs = 500): Promise<void> {
  const startedAt = Date.now();
  let lastError = '';

  while (Date.now() - startedAt < timeoutMs) {
    try {
      const r = await fetch(HEALTH_URL);
      if (r.ok) return;
      lastError = `HTTP ${r.status}`;
    } catch (e: any) {
      lastError = e.message ?? String(e);
    }
    await sleep(intervalMs);
  }

  throw new Error(
    `\n\n[setup] ✗ wrangler dev not reachable at ${HEALTH_URL}\n\n` +
    `Last error: ${lastError}\n\n` +
    `Start it in another terminal:\n` +
    `   cd worker && npm run dev\n\n` +
    `Then re-run the tests:\n` +
    `   npm test\n`
  );
}
test/helpers/r2-utils.ts
excerpt · reading R2 objects via wrangler CLI
import { execSync } from 'node:child_process';

const BUCKET = '613parts-catalog';

/**
 * Read an R2 object as parsed JSON. Throws if the key doesn't exist.
 */
export function getR2JSON<T = unknown>(key: string): T {
  const text = execSync(
    `npx wrangler r2 object get ${BUCKET}/${key} --pipe --local 2>/dev/null`,
    { encoding: 'utf-8' }
  );
  return JSON.parse(text) as T;
}

/**
 * Check whether an R2 object exists.
 */
export function r2KeyExists(key: string): boolean {
  try {
    execSync(
      `npx wrangler r2 object get ${BUCKET}/${key} --pipe --local 2>/dev/null`,
      { encoding: 'utf-8', stdio: 'pipe' }
    );
    return true;
  } catch { return false; }
}
CI / runbook

ship-time confidence in under 5 seconds.

Run before every deploy. Catches regressions in scoring weights, fulfillment lane logic, postal code parsing, and catalog generator output shape — the four most-likely places to accidentally break the bot.

Pre-deploy gate

Add to your deploy script: npm test && npm run deploy. If tests fail, the deploy aborts.

GitHub Actions (recommended)

Spawn wrangler dev as a background process, run tests, exit. ~30 sec total. Free for public repos and ample free tier for private.

Manual smoke test

After any change to scoring weights or fulfillment thresholds: npm run dev in one terminal, npm test in another. ~10 seconds end-to-end.

Why E2E vs unit?

Unit tests for scoring formula are easy. The hard part is verifying the full request → handler → catalog load → search → carousel → response chain works under real wrangler runtime. E2E catches integration bugs unit tests miss.