auditandfix-cf-worker.test.js
1 /** 2 * Audit&Fix CF Worker API Tests 3 * 4 * Tests the Cloudflare Worker request handler (workers/auditandfix-api/src/index.js) 5 * by calling its fetch() export directly with mock Request objects and an 6 * in-memory KV store — no wrangler or network required. 7 * 8 * Covers: auth, pricing CRUD, purchase queue CRUD, validation, error handling. 9 */ 10 11 import { describe, test, before, beforeEach } from 'node:test'; 12 import assert from 'node:assert/strict'; 13 import { readFileSync } from 'fs'; 14 import { join, dirname } from 'path'; 15 import { fileURLToPath } from 'url'; 16 17 const __filename = fileURLToPath(import.meta.url); 18 const __dirname = dirname(__filename); 19 20 // Import the worker handler directly (it uses standard Fetch API, compatible with Node.js 22+) 21 const workerPath = join(__dirname, '../../workers/auditandfix-api/src/index.js'); 22 const worker = await import(workerPath); 23 const handler = worker.default; 24 25 const AUTH_SECRET = 'test-secret-abc'; 26 27 /** 28 * In-memory KV store matching the Cloudflare KV API 29 */ 30 function createMockKV() { 31 const store = new Map(); 32 return { 33 get: async (key, opts) => { 34 const val = store.get(key); 35 if (val === undefined) return null; 36 if (opts?.type === 'json') { 37 try { 38 return JSON.parse(val); 39 } catch { 40 return null; 41 } 42 } 43 return val; 44 }, 45 put: async (key, val) => { 46 store.set(key, String(val)); 47 }, 48 delete: async key => { 49 store.delete(key); 50 }, 51 list: async ({ prefix = '' } = {}) => ({ 52 keys: [...store.keys()].filter(k => k.startsWith(prefix)).map(name => ({ name })), 53 }), 54 // Test helpers 55 _store: store, 56 _size: () => store.size, 57 }; 58 } 59 60 /** 61 * Build a mock env object with KV namespaces and auth secret 62 */ 63 function createEnv(overrides = {}) { 64 return { 65 AUTH_SECRET, 66 PURCHASES: createMockKV(), 67 PRICING: createMockKV(), 68 ...overrides, 69 }; 70 } 71 72 /** 73 * Build a Request with common options 74 */ 75 function makeRequest(method, path, { body, auth = false, json = false } = {}) { 76 const url = `https://auditandfix-api.workers.dev${path}`; 77 const headers = {}; 78 79 if (auth) headers['X-Auth-Secret'] = AUTH_SECRET; 80 if (json) headers['Content-Type'] = 'application/json'; 81 82 const init = { method, headers }; 83 if (body !== undefined) { 84 init.body = typeof body === 'string' ? body : JSON.stringify(body); 85 } 86 87 return new Request(url, init); 88 } 89 90 /** 91 * Seed a valid purchase into the KV store 92 */ 93 async function seedPurchase(env, id = 'purchase_001', overrides = {}) { 94 const purchase = { 95 email: 'buyer@test.com', 96 landing_page_url: 'https://test-site.com', 97 paypal_order_id: 'ORDER_TEST_001', 98 amount: 29700, 99 currency: 'USD', 100 amount_usd: 29700, 101 created_at: new Date().toISOString(), 102 ...overrides, 103 }; 104 await env.PURCHASES.put(id, JSON.stringify(purchase)); 105 return id; 106 } 107 108 const samplePricing = { 109 US: { currency: 'USD', priceLocal: 297, priceUsd: 297 }, 110 AU: { currency: 'AUD', priceLocal: 447, priceUsd: 297 }, 111 }; 112 113 describe('Auditandfix CF Worker', () => { 114 let env; 115 116 before(() => { 117 env = createEnv(); 118 }); 119 120 beforeEach(() => { 121 env = createEnv(); // Fresh KV each test 122 }); 123 124 // ── Health ───────────────────────────────────────────────────────────────── 125 126 describe('GET /health', () => { 127 test('returns 200 with ok status (no auth required)', async () => { 128 const req = makeRequest('GET', '/health'); 129 const res = await handler.fetch(req, env); 130 131 assert.equal(res.status, 200); 132 const body = await res.json(); 133 assert.equal(body.status, 'ok'); 134 assert.equal(body.service, 'auditandfix-api'); 135 assert.ok(body.timestamp, 'Should include ISO timestamp'); 136 }); 137 }); 138 139 // ── CORS preflight ───────────────────────────────────────────────────────── 140 141 describe('OPTIONS /*', () => { 142 test('returns 204 with CORS headers', async () => { 143 const req = makeRequest('OPTIONS', '/purchases'); 144 const res = await handler.fetch(req, env); 145 146 assert.equal(res.status, 204); 147 assert.ok(res.headers.get('Access-Control-Allow-Origin')); 148 assert.ok(res.headers.get('Access-Control-Allow-Methods')); 149 }); 150 }); 151 152 // ── Pricing ──────────────────────────────────────────────────────────────── 153 154 describe('GET /pricing', () => { 155 test('returns empty object when no pricing stored', async () => { 156 const req = makeRequest('GET', '/pricing'); 157 const res = await handler.fetch(req, env); 158 159 assert.equal(res.status, 200); 160 const body = await res.json(); 161 assert.deepEqual(body, {}); 162 }); 163 164 test('returns stored pricing data (no auth required)', async () => { 165 await env.PRICING.put('pricing_data', JSON.stringify(samplePricing)); 166 167 const req = makeRequest('GET', '/pricing'); 168 const res = await handler.fetch(req, env); 169 170 assert.equal(res.status, 200); 171 const body = await res.json(); 172 assert.ok(body.US, 'Should include US pricing'); 173 assert.ok(body.AU, 'Should include AU pricing'); 174 assert.equal(body.US.currency, 'USD'); 175 }); 176 }); 177 178 describe('POST /pricing', () => { 179 test('returns 401 without auth', async () => { 180 const req = makeRequest('POST', '/pricing', { body: samplePricing, json: true }); 181 const res = await handler.fetch(req, env); 182 183 assert.equal(res.status, 401); 184 }); 185 186 test('returns 401 with wrong auth secret', async () => { 187 const url = 'https://auditandfix-api.workers.dev/pricing'; 188 const req = new Request(url, { 189 method: 'POST', 190 headers: { 'X-Auth-Secret': 'wrong-secret', 'Content-Type': 'application/json' }, 191 body: JSON.stringify(samplePricing), 192 }); 193 const res = await handler.fetch(req, env); 194 195 assert.equal(res.status, 401); 196 }); 197 198 test('stores pricing and returns country count', async () => { 199 const req = makeRequest('POST', '/pricing', { body: samplePricing, auth: true, json: true }); 200 const res = await handler.fetch(req, env); 201 202 assert.equal(res.status, 200); 203 const body = await res.json(); 204 assert.equal(body.success, true); 205 assert.equal(body.countries, 2); 206 207 // Verify stored 208 const stored = await env.PRICING.get('pricing_data', { type: 'json' }); 209 assert.ok(stored.US, 'US pricing should be stored'); 210 }); 211 212 test('returns 400 on invalid JSON', async () => { 213 const url = 'https://auditandfix-api.workers.dev/pricing'; 214 const req = new Request(url, { 215 method: 'POST', 216 headers: { 'X-Auth-Secret': AUTH_SECRET, 'Content-Type': 'application/json' }, 217 body: '{ not valid json }', 218 }); 219 const res = await handler.fetch(req, env); 220 221 assert.equal(res.status, 400); 222 }); 223 }); 224 225 // ── Purchases ────────────────────────────────────────────────────────────── 226 227 describe('POST /purchases', () => { 228 const validPurchase = { 229 email: 'buyer@test.com', 230 landing_page_url: 'https://example.com', 231 paypal_order_id: 'ORDER_123', 232 amount: 29700, 233 currency: 'USD', 234 amount_usd: 29700, 235 }; 236 237 test('returns 401 without auth', async () => { 238 const req = makeRequest('POST', '/purchases', { body: validPurchase, json: true }); 239 const res = await handler.fetch(req, env); 240 241 assert.equal(res.status, 401); 242 }); 243 244 test('stores purchase and returns 201 with KV key', async () => { 245 const req = makeRequest('POST', '/purchases', { 246 body: validPurchase, 247 auth: true, 248 json: true, 249 }); 250 const res = await handler.fetch(req, env); 251 252 assert.equal(res.status, 201); 253 const body = await res.json(); 254 assert.equal(body.success, true); 255 assert.ok(body.id.startsWith('purchase_'), `Key should start with purchase_: ${body.id}`); 256 }); 257 258 test('purchase is retrievable from KV after POST', async () => { 259 const req = makeRequest('POST', '/purchases', { 260 body: validPurchase, 261 auth: true, 262 json: true, 263 }); 264 const res = await handler.fetch(req, env); 265 const { id } = await res.json(); 266 267 const stored = await env.PURCHASES.get(id, { type: 'json' }); 268 assert.ok(stored, 'Purchase should be in KV'); 269 assert.equal(stored.email, 'buyer@test.com'); 270 assert.equal(stored.paypal_order_id, 'ORDER_123'); 271 assert.ok(stored.created_at, 'Should have created_at timestamp'); 272 }); 273 274 test('rejects missing required field: email', async () => { 275 const { email: _, ...noemail } = validPurchase; 276 const req = makeRequest('POST', '/purchases', { body: noemail, auth: true, json: true }); 277 const res = await handler.fetch(req, env); 278 279 assert.equal(res.status, 400); 280 const body = await res.json(); 281 assert.ok(body.error.includes('email')); 282 }); 283 284 test('rejects missing required field: paypal_order_id', async () => { 285 const { paypal_order_id: _, ...noid } = validPurchase; 286 const req = makeRequest('POST', '/purchases', { body: noid, auth: true, json: true }); 287 const res = await handler.fetch(req, env); 288 289 assert.equal(res.status, 400); 290 }); 291 292 test('rejects invalid email format', async () => { 293 const req = makeRequest('POST', '/purchases', { 294 body: { ...validPurchase, email: 'not-an-email' }, 295 auth: true, 296 json: true, 297 }); 298 const res = await handler.fetch(req, env); 299 300 assert.equal(res.status, 400); 301 const body = await res.json(); 302 assert.ok(body.error.includes('email')); 303 }); 304 305 test('rejects invalid landing_page_url', async () => { 306 const req = makeRequest('POST', '/purchases', { 307 body: { ...validPurchase, landing_page_url: 'not-a-url' }, 308 auth: true, 309 json: true, 310 }); 311 const res = await handler.fetch(req, env); 312 313 assert.equal(res.status, 400); 314 const body = await res.json(); 315 assert.ok(body.error.includes('landing_page_url')); 316 }); 317 318 test('returns 400 on malformed JSON body', async () => { 319 const url = 'https://auditandfix-api.workers.dev/purchases'; 320 const req = new Request(url, { 321 method: 'POST', 322 headers: { 'X-Auth-Secret': AUTH_SECRET, 'Content-Type': 'application/json' }, 323 body: 'this is not json', 324 }); 325 const res = await handler.fetch(req, env); 326 327 assert.equal(res.status, 400); 328 }); 329 }); 330 331 describe('GET /purchases', () => { 332 test('returns 401 without auth', async () => { 333 const req = makeRequest('GET', '/purchases'); 334 const res = await handler.fetch(req, env); 335 336 assert.equal(res.status, 401); 337 }); 338 339 test('returns empty purchases array when queue is empty', async () => { 340 const req = makeRequest('GET', '/purchases', { auth: true }); 341 const res = await handler.fetch(req, env); 342 343 assert.equal(res.status, 200); 344 const body = await res.json(); 345 assert.deepEqual(body.purchases, []); 346 assert.equal(body.count, 0); 347 }); 348 349 test('returns queued purchases with KV key as id', async () => { 350 await seedPurchase(env, 'purchase_aaa'); 351 await seedPurchase(env, 'purchase_bbb', { 352 email: 'second@test.com', 353 paypal_order_id: 'ORDER_002', 354 }); 355 356 const req = makeRequest('GET', '/purchases', { auth: true }); 357 const res = await handler.fetch(req, env); 358 359 assert.equal(res.status, 200); 360 const body = await res.json(); 361 assert.equal(body.count, 2); 362 assert.equal(body.purchases.length, 2); 363 364 // Each purchase should include its KV key as `id` 365 const ids = body.purchases.map(p => p.id); 366 assert.ok(ids.includes('purchase_aaa')); 367 assert.ok(ids.includes('purchase_bbb')); 368 }); 369 370 test('only returns keys with purchase_ prefix', async () => { 371 await seedPurchase(env, 'purchase_valid'); 372 await env.PURCHASES.put('other_key', JSON.stringify({ email: 'other@test.com' })); 373 374 const req = makeRequest('GET', '/purchases', { auth: true }); 375 const res = await handler.fetch(req, env); 376 377 const body = await res.json(); 378 assert.equal(body.count, 1, 'Should only return purchase_ prefixed keys'); 379 }); 380 }); 381 382 describe('DELETE /purchases/:id', () => { 383 test('returns 401 without auth', async () => { 384 const req = makeRequest('DELETE', '/purchases/purchase_001'); 385 const res = await handler.fetch(req, env); 386 387 assert.equal(res.status, 401); 388 }); 389 390 test('deletes existing purchase and returns success', async () => { 391 const id = await seedPurchase(env, 'purchase_delete_me'); 392 393 const req = makeRequest('DELETE', `/purchases/${id}`, { auth: true }); 394 const res = await handler.fetch(req, env); 395 396 assert.equal(res.status, 200); 397 const body = await res.json(); 398 assert.equal(body.success, true); 399 assert.equal(body.deleted, id); 400 401 // Verify removed from KV 402 const gone = await env.PURCHASES.get(id); 403 assert.equal(gone, null, 'Purchase should be removed from KV'); 404 }); 405 406 test('returns 404 for non-existent purchase', async () => { 407 const req = makeRequest('DELETE', '/purchases/purchase_doesnt_exist', { auth: true }); 408 const res = await handler.fetch(req, env); 409 410 assert.equal(res.status, 404); 411 const body = await res.json(); 412 assert.ok(body.error.includes('not found')); 413 }); 414 }); 415 416 // ── Unknown routes ───────────────────────────────────────────────────────── 417 418 describe('Unknown routes', () => { 419 test('GET /unknown returns 404', async () => { 420 const req = makeRequest('GET', '/unknown-path'); 421 const res = await handler.fetch(req, env); 422 423 assert.equal(res.status, 404); 424 }); 425 426 test('POST /health returns 404 (wrong method)', async () => { 427 const req = makeRequest('POST', '/health'); 428 const res = await handler.fetch(req, env); 429 430 assert.equal(res.status, 404); 431 }); 432 }); 433 434 // ── Response format ──────────────────────────────────────────────────────── 435 436 describe('Response format', () => { 437 test('all responses include Content-Type: application/json', async () => { 438 const routes = [ 439 makeRequest('GET', '/health'), 440 makeRequest('GET', '/pricing'), 441 makeRequest('GET', '/unknown'), 442 makeRequest('GET', '/purchases'), // 401 443 makeRequest('POST', '/purchases'), // 401 444 ]; 445 446 for (const req of routes) { 447 const res = await handler.fetch(req, env); 448 const ct = res.headers.get('Content-Type'); 449 assert.ok(ct?.includes('application/json'), `Expected JSON content-type, got: ${ct}`); 450 } 451 }); 452 453 test('all responses include CORS Access-Control-Allow-Origin header', async () => { 454 const req = makeRequest('GET', '/health'); 455 const res = await handler.fetch(req, env); 456 457 assert.equal(res.headers.get('Access-Control-Allow-Origin'), '*'); 458 }); 459 }); 460 });