mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
b1c05bca83
Whether the AI Parsing model reads images is an admin setting now, and it lands with the two features that send a photo, so it has something to control. The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On Automatic a local Ollama is asked what the model can do (`/api/show`), and a cloud provider means no; Yes and No are applied as set. The old `multimodal` flag is not read: the admin screens wrote it false on every save, so a stored false meant nothing. The per-user toggle keeps its on/off meaning and is read by the extraction now. The lookup is server-side only: capped read, validated body, never throws, cached per server and model. GET /api/llm/capabilities answers it for the signed-in caller, and both photo pickers below ask it. The booking import takes JPG, PNG and WEBP when the model reads images. The photo goes as an image to every provider (image_url, an Anthropic image block, Ollama's native chat with `images`), capped at 1600px, and skips KDE Itinerary. A scanned PDF, which used to reach the model as empty text, has its first two pages drawn by pdf-parse and sent the same way. The rest is the import as it was: background job, review, the file attached to the booking, the linked cost. On a real qwen3.5:4b a train ticket came back as bare clock times until the router gave the model every type's field meanings, and its price as an object inside a string, which read as 4900; both are handled. Costs gets Scan receipt: a dialog laid out like the booking import's takes one photo, read on the import job queue, then reviewed in the existing expense editor pre-filled with merchant, total, currency, day and lines, the photo waiting in Receipts & Invoices to be uploaded on save. No table, no migration, no category guess, no document-type detection.
56 lines
3.3 KiB
TypeScript
56 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import { ReceiptScanController } from '../../../src/nest/receipt-scan/receipt-scan.controller';
|
|
import type { ImportJobsService } from '../../../src/nest/booking-import/import-jobs.service';
|
|
import type { LlmParseService } from '../../../src/nest/llm-parse/llm-parse.service';
|
|
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 3, role: 'user' } as User;
|
|
const photo = (name = 'r.jpg') => ({ originalname: name, buffer: Buffer.from('x') } as Express.Multer.File);
|
|
|
|
function make(over: { readsImages?: boolean; trip?: unknown; addon?: boolean; allowed?: boolean } = {}) {
|
|
const startReceipt = vi.fn(() => 'job-1');
|
|
const checkPermission = vi.fn(() => over.allowed ?? true);
|
|
const c = new ReceiptScanController(
|
|
{ startReceipt } as unknown as ImportJobsService,
|
|
{ readsImages: vi.fn(async () => over.readsImages ?? true) } as unknown as LlmParseService,
|
|
{ canAccessTrip: vi.fn(() => ('trip' in over ? over.trip : { user_id: 5 })) } as unknown as DatabaseService,
|
|
{ checkPermission } as unknown as PermissionsService,
|
|
{ isAddonEnabled: vi.fn(() => over.addon ?? true) } as unknown as AddonsService,
|
|
);
|
|
return { c, startReceipt, checkPermission };
|
|
}
|
|
|
|
async function failure(fn: () => Promise<unknown>): Promise<[number, unknown]> {
|
|
try { await fn(); } catch (e) { expect(e).toBeInstanceOf(HttpException); return [(e as HttpException).getStatus(), (e as HttpException).getResponse()]; }
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
describe('ReceiptScanController', () => {
|
|
it('queues a photo and answers the job id', async () => {
|
|
const { c, startReceipt, checkPermission } = make();
|
|
await expect(c.scan(user, '9', photo())).resolves.toEqual({ jobId: 'job-1' });
|
|
expect(startReceipt).toHaveBeenCalledWith('9', expect.objectContaining({ originalname: 'r.jpg' }), 3);
|
|
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 5, 3, true);
|
|
});
|
|
|
|
it('answers what the guards would: 404 for a trip out of reach or Costs off, 403 without budget_edit', async () => {
|
|
expect(await failure(() => make({ trip: undefined }).c.scan(user, '9', photo()))).toEqual([404, { error: 'Trip not found' }]);
|
|
expect(await failure(() => make({ addon: false }).c.scan(user, '9', photo()))).toEqual([404, { error: 'Costs addon is not enabled' }]);
|
|
const { c, startReceipt } = make({ allowed: false });
|
|
expect(await failure(() => c.scan(user, '9', photo()))).toEqual([403, { error: 'No permission' }]);
|
|
expect(startReceipt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses with 400 a missing file, a file that is not a photo, or a model that reads no images', async () => {
|
|
expect((await failure(() => make().c.scan(user, '9', undefined)))[0]).toBe(400);
|
|
expect((await failure(() => make().c.scan(user, '9', photo('r.pdf'))))[0]).toBe(400);
|
|
const { c, startReceipt } = make({ readsImages: false });
|
|
expect(await failure(() => c.scan(user, '9', photo()))).toEqual([400, { error: 'The configured AI model does not read photos' }]);
|
|
expect(startReceipt).not.toHaveBeenCalled();
|
|
});
|
|
});
|