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.
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.
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.
Asserts cf_top_match_sku=ACD-PF64, cf_top_match_price=14.99, and SKU appears in cf_match_results.
Verifies the exact-MPN +1500 score boost — bare MPN should beat semantic matches.
Verifies multi-token matching (brake + pads + f-150) plus fitment boost (+250 for vehicle match).
Both Michelin X-Ice and Bridgestone Blizzak in 215/50R17 should appear.
Asserts brand match (+500) + line token in name = high score.
Brand exact match + tag match. Top result should be the AMSOIL OE 5W-30 quart bottle.
Asserts cf_match_count=0 and bot says "couldn't find a match" — no 400 status.
POST with empty body should still return 200 + helpful prompt. ManyChat must never see non-200.
Verifies card schema: title, subtitle, image_url, 3 buttons (Buy/Specs/Compare), and "fits your car ✓" indicator when vehicle is provided.
K8N 4Z2 + $249 cart + tire=false. Lanes: pickup_belleville, pickup_kingston, drop_paid. Cart total with fee = $264.
K8N 4Z2 + $419 cart. drop_free shows; drop_paid is hidden. Default = drop_free. Fee = $0.
K7L 2R3 + $1,489 + tire=true. Branch routing kicks in (Kingston). drop_free is default.
M5V 2T6 (Toronto). Only pickup lanes shown. cf_in_zone=false, cf_branch_origin="".
Tests "K8N 4Z2", "k8n4z2", "K8N4Z2", " K8N 4Z2 " — all return identical results. Verifies normalization is robust.
Empty cf_postal returns 200 + bot says "postal code is required" — graceful UX.
POST without Authorization header should return 401. Manual cron trigger is bearer-protected.
POST with Bearer wrong-token should return 401. Constant-time comparison via env match.
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.
Liveness check used by Cloudflare uptime monitoring.
Verifies Allow: POST response header is set correctly.
POST to /api/v1/er-nonsense falls through the switch to 404.
The router catches JSON parse errors before they reach handlers.
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.
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.
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.
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.
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.
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.
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');
});
});
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`
);
}
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; }
}
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.
Add to your deploy script: npm test && npm run deploy. If tests fail, the deploy aborts.
Spawn wrangler dev as a background process, run tests, exit. ~30 sec total. Free for public repos and ample free tier for private.
After any change to scoring weights or fulfillment thresholds: npm run dev in one terminal, npm test in another. ~10 seconds end-to-end.
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.