// A1 S9 — POST /api/slips/parse route contract: auth, tier-aware daily // quota (free 1/day, paid 10/day), payload validation, text-path parse. // Auth + redis are mocked (hermetic — no supabase, no ioredis client). const express = require('express'); const request = require('supertest'); const fs = require('fs'); const path = require('path'); // requireAuth stub: Authorization present → user from header knobs; else 401. jest.mock('../../src/middleware/auth', () => ({ requireAuth: (req, res, next) => { if (!req.headers.authorization) return res.status(401).json({ error: 'Authentication required' }); req.user = { id: req.headers['x-test-user'] || 'u1', tier: req.headers['x-test-tier'] || 'free', }; return next(); }, })); // Redis stub — in-memory kv so no ioredis client is ever created (single-suite // runs hang at exit otherwise; see Session 8 note). const mockKv = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: jest.fn(async (k) => (mockKv.has(k) ? mockKv.get(k) : null)), cacheSet: jest.fn(async (k, v) => { mockKv.set(k, v); }), cacheDel: jest.fn(async (k) => { mockKv.delete(k); }), getRedisClient: jest.fn(() => null), isDegraded: jest.fn(() => false), })); const slipsRouter = require('../../src/routes/slips'); const dkFixture = fs.readFileSync( path.join(__dirname, '..', 'fixtures', 'slips', 'draftkings.txt'), 'utf8', ); function mountApp() { const app = express(); app.use(express.json({ limit: '10mb' })); app.use('/api/slips', slipsRouter); return app; } describe('POST /api/slips/parse', () => { let app; beforeEach(() => { mockKv.clear(); slipsRouter.__internals.resetForTests(); slipsRouter.__internals.setDeps({}); app = mountApp(); }); it('401s without auth', async () => { const res = await request(app).post('/api/slips/parse').send({ text: dkFixture }); expect(res.status).toBe(401); }); it('400s with neither image nor text', async () => { const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({}); expect(res.status).toBe(400); }); it('400s an undecodable image without burning the daily slot', async () => { const bad = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ image: '!!!not-base64!!!' }); expect(bad.status).toBe(400); // The slot was not consumed — a valid parse still works. const ok = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ text: dkFixture }); expect(ok.status).toBe(200); }); it('400s an image over 4MB', async () => { // ~4.5MB of zeros, base64-encoded (~6MB payload — under the 10mb body cap). const big = Buffer.alloc(4.5 * 1024 * 1024).toString('base64'); const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ image: big }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/4MB/); }); it('parses slip text into legs (user_slip semantics)', async () => { const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ text: dkFixture }); expect(res.status).toBe(200); expect(res.body.book).toBe('draftkings'); expect(res.body.source).toBe('user_slip'); expect(res.body.legs).toHaveLength(3); expect(res.body.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115, }); }); it('free tier: 1 parse/day, second 429s with quota headers', async () => { const first = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ text: dkFixture }); expect(first.status).toBe(200); expect(first.headers['x-slips-limit']).toBe('1'); const second = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ text: dkFixture }); expect(second.status).toBe(429); expect(second.body.limit).toBe(1); expect(second.body.tier).toBe('free'); }); it('paid tier: 10 parses/day, 11th 429s', async () => { for (let i = 0; i < 10; i += 1) { const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .set('x-test-tier', 'analyst') .send({ text: dkFixture }); expect(res.status).toBe(200); } const eleventh = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .set('x-test-tier', 'analyst') .send({ text: dkFixture }); expect(eleventh.status).toBe(429); expect(eleventh.body.limit).toBe(10); }); it('quota is per-user (a second user still parses)', async () => { await request(app).post('/api/slips/parse') .set('Authorization', 'Bearer t').send({ text: dkFixture }); const other = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .set('x-test-user', 'u2') .send({ text: dkFixture }); expect(other.status).toBe(200); }); it('image path runs the injected OCR then parses', async () => { slipsRouter.__internals.setDeps({ recognizeImage: jest.fn(async () => dkFixture), }); const png = Buffer.from('fake-png-bytes').toString('base64'); const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ image: `data:image/png;base64,${png}` }); expect(res.status).toBe(200); expect(res.body.legs).toHaveLength(3); }); it('503s when OCR blows up (honest failure, no empty fabrication)', async () => { slipsRouter.__internals.setDeps({ recognizeImage: jest.fn(async () => { throw new Error('wasm sad'); }), }); const png = Buffer.from('fake-png-bytes').toString('base64'); const res = await request(app) .post('/api/slips/parse') .set('Authorization', 'Bearer t') .send({ image: png }); expect(res.status).toBe(503); }); });