/ tests / utils / auditandfix-cf-worker.test.js
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  });