133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
|
|
import 'dotenv/config';
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
import { promises as fs } from 'fs';
|
|||
|
|
|
|||
|
|
import { login, apiHeaders, getPortId } from '../smoke/helpers';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Real-API receipt OCR coverage. Two-step:
|
|||
|
|
*
|
|||
|
|
* 1. Admin save + test-connection round-trip: writes a real OpenAI key
|
|||
|
|
* to the global OCR config, calls /admin/ocr-settings/test (which
|
|||
|
|
* sends a 1×1 pixel PNG — essentially free in tokens), and asserts
|
|||
|
|
* the provider responds 2xx. Validates the auth + key-storage path.
|
|||
|
|
*
|
|||
|
|
* 2. Real receipt parse: when REALAPI_RECEIPT_FIXTURE is set to an
|
|||
|
|
* image on disk, POSTs it to /api/v1/expenses/scan-receipt and
|
|||
|
|
* asserts the parsed payload looks plausible (numeric amount >= 0,
|
|||
|
|
* non-empty parsed.confidence).
|
|||
|
|
*
|
|||
|
|
* Both tests skip when OPENAI_API_KEY isn't set so the suite remains
|
|||
|
|
* CI-safe by default.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
|||
|
|
const RECEIPT_FIXTURE = process.env.REALAPI_RECEIPT_FIXTURE;
|
|||
|
|
|
|||
|
|
test.describe('Receipt OCR — real provider', () => {
|
|||
|
|
test.skip(!OPENAI_API_KEY, 'OPENAI_API_KEY not configured');
|
|||
|
|
|
|||
|
|
test('admin can save an OpenAI key and the test endpoint passes', async ({ page }) => {
|
|||
|
|
await login(page, 'super_admin');
|
|||
|
|
const headers = await apiHeaders(page);
|
|||
|
|
|
|||
|
|
// Save a global OCR config with the real key. Super-admin only.
|
|||
|
|
const saveRes = await page.request.put('/api/v1/admin/ocr-settings', {
|
|||
|
|
headers,
|
|||
|
|
data: {
|
|||
|
|
scope: 'global',
|
|||
|
|
provider: 'openai',
|
|||
|
|
model: 'gpt-4o-mini',
|
|||
|
|
apiKey: OPENAI_API_KEY,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
expect(saveRes.ok()).toBeTruthy();
|
|||
|
|
|
|||
|
|
const testRes = await page.request.post('/api/v1/admin/ocr-settings/test', {
|
|||
|
|
headers,
|
|||
|
|
data: {
|
|||
|
|
provider: 'openai',
|
|||
|
|
model: 'gpt-4o-mini',
|
|||
|
|
apiKey: OPENAI_API_KEY,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
expect(testRes.ok()).toBeTruthy();
|
|||
|
|
const body = (await testRes.json()) as { ok: boolean; reason?: string };
|
|||
|
|
expect(body.ok).toBe(true);
|
|||
|
|
|
|||
|
|
// Cleanup: clear the global key so subsequent test runs don't accidentally
|
|||
|
|
// bill the same OpenAI account if someone forgets to unset it.
|
|||
|
|
const cleanupRes = await page.request.put('/api/v1/admin/ocr-settings', {
|
|||
|
|
headers,
|
|||
|
|
data: {
|
|||
|
|
scope: 'global',
|
|||
|
|
provider: 'openai',
|
|||
|
|
model: 'gpt-4o-mini',
|
|||
|
|
clearApiKey: true,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
expect(cleanupRes.ok()).toBeTruthy();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('scan-receipt endpoint returns a parsed payload for a real image', async ({ page }) => {
|
|||
|
|
test.skip(!RECEIPT_FIXTURE, 'REALAPI_RECEIPT_FIXTURE not set');
|
|||
|
|
|
|||
|
|
await login(page, 'super_admin');
|
|||
|
|
const portId = await getPortId(page);
|
|||
|
|
|
|||
|
|
// Configure the per-port OCR with the test key for the duration of this run.
|
|||
|
|
await page.request.put('/api/v1/admin/ocr-settings', {
|
|||
|
|
headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
|
|||
|
|
data: {
|
|||
|
|
scope: 'port',
|
|||
|
|
provider: 'openai',
|
|||
|
|
model: 'gpt-4o-mini',
|
|||
|
|
apiKey: OPENAI_API_KEY,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const buffer = await fs.readFile(RECEIPT_FIXTURE!);
|
|||
|
|
const res = await page.request.post('/api/v1/expenses/scan-receipt', {
|
|||
|
|
headers: { 'X-Port-Id': portId },
|
|||
|
|
multipart: {
|
|||
|
|
file: {
|
|||
|
|
name: 'receipt.jpg',
|
|||
|
|
mimeType: 'image/jpeg',
|
|||
|
|
buffer,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
expect(res.ok()).toBeTruthy();
|
|||
|
|
const body = (await res.json()) as {
|
|||
|
|
data: {
|
|||
|
|
parsed: {
|
|||
|
|
amount: number | null;
|
|||
|
|
confidence: number;
|
|||
|
|
establishment: string | null;
|
|||
|
|
date: string | null;
|
|||
|
|
};
|
|||
|
|
source: 'ai' | 'manual';
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
expect(body.data.source).toBe('ai');
|
|||
|
|
// Confidence must be a valid number 0..1 — provider should always emit it.
|
|||
|
|
expect(body.data.parsed.confidence).toBeGreaterThanOrEqual(0);
|
|||
|
|
expect(body.data.parsed.confidence).toBeLessThanOrEqual(1);
|
|||
|
|
// Amount, if present, should be non-negative.
|
|||
|
|
if (body.data.parsed.amount !== null) {
|
|||
|
|
expect(body.data.parsed.amount).toBeGreaterThanOrEqual(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cleanup
|
|||
|
|
await page.request.put('/api/v1/admin/ocr-settings', {
|
|||
|
|
headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
|
|||
|
|
data: {
|
|||
|
|
scope: 'port',
|
|||
|
|
provider: 'openai',
|
|||
|
|
model: 'gpt-4o-mini',
|
|||
|
|
clearApiKey: true,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|