srs-api/src/transcribe/transcribeImage.test.ts
Aswin Biju 3640b2d785 feat(whatsapp): read slip photos and add them to the order
Shops can now send handwritten slips as WhatsApp photos. srs-api reads them
through OpenRouter in the background. The model only proposes text: the parser
decides what is an entry, and nothing is saved without the user's Save.
2026-11-04 13:46:50 +05:30

216 lines
7.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
MAX_RETRY_WAIT_MS,
OPENROUTER_URL,
SLIP_PROMPT,
stripCodeFences,
transcribeImage,
type TranscribeDeps,
} from './transcribeImage';
const KEY = 'test-key-not-real-Zq7';
const IMAGE = Buffer.from('fake-image-bytes');
const MODEL_TEXT = '6379 100 MODELTEXT';
const ERROR_BODY = 'BODY-MAY-QUOTE-THE-INPUT';
type Reply = () => Response;
const json =
(body: unknown, status = 200, headers: Record<string, string> = {}): Reply =>
() =>
new Response(JSON.stringify(body), { status, headers });
const answer = (content: unknown, finish: unknown = 'stop'): Reply =>
json({ choices: [{ finish_reason: finish, message: { content } }] });
const fail = (status: number, headers: Record<string, string> = {}, error: unknown = { message: ERROR_BODY }): Reply =>
json({ error }, status, headers);
const networkError: Reply = () => {
throw new TypeError('fetch failed');
};
const htmlPage =
(status: number): Reply =>
() =>
new Response('<html>not json</html>', { status });
function setup(...replies: Reply[]) {
const calls: { url: string; init: RequestInit }[] = [];
const waits: number[] = [];
const logs: Record<string, unknown>[] = [];
let attempts = 0;
const deps: TranscribeDeps = {
apiKey: KEY,
model: 'test/model:free',
timeoutSec: 60,
fetchImpl: (async (url: string, init: RequestInit) => {
calls.push({ url, init });
const reply = replies[calls.length - 1];
if (!reply) throw new Error('more calls than expected');
return reply();
}) as unknown as typeof fetch,
log: (e) => logs.push(e),
sleep: async (ms) => {
waits.push(ms);
},
beforeAttempt: async () => {
attempts++;
},
};
return { deps, calls, waits, logs, attempts: () => attempts };
}
const run = (s: ReturnType<typeof setup>) => transcribeImage(IMAGE, 'image/jpeg', s.deps);
describe('the request', () => {
it('sends the prompt first, reasoning off, a data URL and the key in the header', async () => {
const s = setup(answer(MODEL_TEXT));
await run(s);
expect(s.calls).toHaveLength(1);
const { url, init } = s.calls[0]!;
expect(url).toBe(OPENROUTER_URL);
expect(init.method).toBe('POST');
expect(init.redirect).toBe('error');
expect((init.headers as Record<string, string>).authorization).toBe(`Bearer ${KEY}`);
const body = JSON.parse(init.body as string);
expect(body.model).toBe('test/model:free');
expect(body.temperature).toBe(0);
expect(body.max_tokens).toBe(1500);
expect(body.reasoning).toEqual({ effort: 'none' });
expect(body.messages[0].content).toEqual([
{ type: 'text', text: SLIP_PROMPT },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${IMAGE.toString('base64')}` } },
]);
expect(init.body).not.toContain(KEY);
});
});
describe('a good answer', () => {
it('returns the text', async () => {
expect(await run(setup(answer('6379 100\n9001 10')))).toEqual({ ok: true, text: '6379 100\n9001 10' });
});
it('strips a code fence but keeps commentary', async () => {
expect(await run(setup(answer('```text\n6379 100\n```')))).toEqual({ ok: true, text: '6379 100' });
expect(await run(setup(answer('Here it is: 6379 100')))).toEqual({ ok: true, text: 'Here it is: 6379 100' });
});
});
describe('failures that are retried once', () => {
const retried: [string, Reply][] = [
['per-minute 429', fail(429, { 'x-ratelimit-limit': '20' })],
['a 429 with no limit header', fail(429)],
["a provider's own 429", fail(429, { 'x-ratelimit-limit': '1000' }, { metadata: { provider_code: 'x' } })],
['408', fail(408)],
['502', fail(502)],
['503', fail(503)],
['a 503 error page that is not JSON', htmlPage(503)],
['a network error', networkError],
['a warm-up reply with no choices', json({ choices: [] })],
['a warm-up reply with no finish reason', answer('', null)],
['a 200 that is not JSON', htmlPage(200)],
['503 inside a 200', json({ error: { code: 503, message: ERROR_BODY } })],
];
it.each(retried)('%s: the second try succeeds', async (_name, reply) => {
const s = setup(reply, answer(MODEL_TEXT));
expect(await run(s)).toEqual({ ok: true, text: MODEL_TEXT });
expect(s.calls).toHaveLength(2);
});
it.each(retried)('%s: fails twice, no third try', async (_name, reply) => {
const s = setup(reply, reply);
expect(await run(s)).toEqual({ ok: false, reason: 'unavailable' });
expect(s.calls).toHaveLength(2);
});
it('asks the limiter before every attempt, retries included', async () => {
const s = setup(fail(503), answer(MODEL_TEXT));
await run(s);
expect(s.attempts()).toBe(2);
});
it('waits for Retry-After, capped, and 5 s without a usable one', async () => {
const given = setup(fail(503, { 'retry-after': '7' }), answer(MODEL_TEXT));
await run(given);
expect(given.waits).toEqual([7000]);
const capped = setup(fail(503, { 'retry-after': '500' }), answer(MODEL_TEXT));
await run(capped);
expect(capped.waits).toEqual([MAX_RETRY_WAIT_MS]);
const none = setup(fail(503), answer(MODEL_TEXT));
await run(none);
expect(none.waits).toEqual([5000]);
const date = setup(fail(503, { 'retry-after': 'Wed, 04 Nov 2026 10:00:00 GMT' }), answer(MODEL_TEXT));
await run(date);
expect(date.waits).toEqual([5000]);
});
});
describe('failures that are not retried', () => {
const once: [string, Reply, string][] = [
['daily 429 at the free limit', fail(429, { 'x-ratelimit-limit': '50' }), 'daily_limit'],
['daily 429 at the higher limit', fail(429, { 'x-ratelimit-limit': '1000' }), 'daily_limit'],
['daily 429 inside a 200', json({ error: { code: 429 } }, 200, { 'x-ratelimit-limit': '50' }), 'daily_limit'],
['400', fail(400), 'unreadable'],
['401', fail(401), 'unreadable'],
['402', fail(402), 'unreadable'],
['403', fail(403), 'unreadable'],
['422', fail(422), 'unreadable'],
['401 inside a 200', json({ error: { code: 401 } }), 'unreadable'],
['ran out of budget', answer('', 'length'), 'unreadable'],
['another finish reason, even with text', answer('6379 100', 'content_filter'), 'unreadable'],
['content that is not text', answer([{ type: 'text' }]), 'unreadable'],
['empty text', answer(''), 'no_writing'],
['null text', answer(null), 'no_writing'],
['only a fence', answer('```\n```'), 'no_writing'],
];
it.each(once)('%s', async (_name, reply, reason) => {
const s = setup(reply);
expect(await run(s)).toEqual({ ok: false, reason });
expect(s.calls).toHaveLength(1);
expect(s.waits).toEqual([]);
});
});
describe('logs', () => {
it('never hold the key, the image, the model text or an error body', async () => {
const replies: Reply[] = [
answer(MODEL_TEXT),
fail(401),
json({ error: { code: 401, message: ERROR_BODY } }),
answer(MODEL_TEXT, 'length'),
networkError,
];
for (const reply of replies) {
const s = setup(reply, reply);
await run(s);
expect(s.logs.length).toBeGreaterThan(0);
const logged = JSON.stringify(s.logs);
for (const secret of [KEY, MODEL_TEXT, ERROR_BODY, IMAGE.toString('base64'), 'fetch failed']) {
expect(logged).not.toContain(secret);
}
}
});
it('record status, finish reason, error type and duration', async () => {
const s = setup(answer(MODEL_TEXT));
await run(s);
expect(s.logs[0]).toMatchObject({ status: 200, finish_reason: 'stop' });
expect(s.logs[0]).toHaveProperty('duration_ms');
const bad = setup(fail(401));
await run(bad);
expect(bad.logs[0]).toMatchObject({ status: 401, error_type: 'http_error', level: 'warn' });
});
});
describe('stripCodeFences', () => {
it('leaves plain text alone and handles an unclosed fence', () => {
expect(stripCodeFences(' 6379 100 ')).toBe('6379 100');
expect(stripCodeFences('```\n6379 100')).toBe('6379 100');
});
});