Files
pn-new-crm/tests/e2e/realapi/receipt-ocr.spec.ts
Matt Ciaccio f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00

133 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
},
});
});
});