S9 (a1): slip reader — zero-API OCR
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review honesty — the reader never guesses. POST /api/slips/parse (auth, free 1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or paste, manual-correct UI, per-leg grades through the normal engine (refusals render honestly), add-all to Parlay Lab, share card. Vision model upgrade logged post-revenue. 2574 -> 2608 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user