Spaces:
Runtime error
Runtime error
Download apps/api/src/services/expense/slip-queue/slip-queue.controller.test.ts from ClaraSu/llm-router: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/spaces/ClaraSu/llm-router/resolve/main/apps/api/src/services/expense/slip-queue/slip-queue.controller.test.ts
- Command line
-
hf download hf://spaces/ClaraSu/llm-router/apps/api/src/services/expense/slip-queue/slip-queue.controller.test.ts
-
curl -L -o slip-queue.controller.test.ts https://huggingface.co/spaces/ClaraSu/llm-router/resolve/main/apps/api/src/services/expense/slip-queue/slip-queue.controller.test.ts
12.3 kB
| import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' | |
| import express from 'express' | |
| import type { AddressInfo } from 'node:net' | |
| import type { Server } from 'node:http' | |
| import type { Request, Response, NextFunction } from 'express' | |
| const getMock = vi.fn() | |
| const deleteMock = vi.fn() | |
| const setConfirmedMock = vi.fn() | |
| const createExpenseMock = vi.fn() | |
| const splitMock = vi.fn() | |
| const applyMatchMock = vi.fn() | |
| const listByStatusMock = vi.fn() | |
| const countsByStatusMock = vi.fn() | |
| vi.mock('../loader.js', () => ({ | |
| container: { | |
| slipQueueService: { | |
| get: getMock, delete: deleteMock, setConfirmed: setConfirmedMock, applyMatch: applyMatchMock, | |
| listByStatus: listByStatusMock, countsByStatus: countsByStatusMock, | |
| }, | |
| expenseService: { create: createExpenseMock }, | |
| billShareService: { split: splitMock }, | |
| }, | |
| })) | |
| describe('slip-queue.controller', () => { | |
| let server: Server | |
| let baseUrl: string | |
| beforeAll(async () => { | |
| const { confirmSlipQueueItem, deleteSlipQueueItem, applyMatchSlipQueueItem, getSlipQueue, getSlipQueueCounts } = await import('./slip-queue.controller.js') | |
| const app = express() | |
| app.use(express.json()) | |
| app.use((req: Request, _res: Response, next: NextFunction) => { | |
| ;(req as Request & { auth: { tenantId: string } }).auth = { tenantId: 'tenant-1' } | |
| next() | |
| }) | |
| app.get('/slip-queue', (req, res) => { void getSlipQueue(req, res) }) | |
| app.get('/slip-queue/counts', (req, res) => { void getSlipQueueCounts(req, res) }) | |
| app.post('/slip-queue/:id/confirm', (req, res) => { void confirmSlipQueueItem(req, res) }) | |
| app.delete('/slip-queue/:id', (req, res) => { void deleteSlipQueueItem(req, res) }) | |
| app.post('/slip-queue/:id/apply-match', (req, res) => { void applyMatchSlipQueueItem(req, res) }) | |
| server = app.listen(0) | |
| await new Promise<void>((resolve) => server.once('listening', resolve)) | |
| const { port } = server.address() as AddressInfo | |
| baseUrl = `http://localhost:${port}` | |
| }) | |
| afterAll(() => new Promise<void>((resolve) => server.close(() => resolve()))) | |
| beforeEach(() => { | |
| getMock.mockReset() | |
| deleteMock.mockReset() | |
| setConfirmedMock.mockReset() | |
| createExpenseMock.mockReset() | |
| splitMock.mockReset() | |
| applyMatchMock.mockReset() | |
| listByStatusMock.mockReset() | |
| countsByStatusMock.mockReset() | |
| }) | |
| describe('getSlipQueue', () => { | |
| it('400s when status is missing', async () => { | |
| const res = await fetch(`${baseUrl}/slip-queue`) | |
| expect(res.status).toBe(400) | |
| }) | |
| it('400s for an invalid status value', async () => { | |
| const res = await fetch(`${baseUrl}/slip-queue?status=bogus`) | |
| expect(res.status).toBe(400) | |
| }) | |
| it('passes status/limit/cursor through to the service and returns its page as-is', async () => { | |
| listByStatusMock.mockResolvedValue({ items: [], hasMore: true }) | |
| const res = await fetch(`${baseUrl}/slip-queue?status=needs_review&limit=10&after_created_at=2026-08-01T00:00:00.000Z&after_id=abc`) | |
| expect(res.status).toBe(200) | |
| expect(await res.json()).toEqual({ items: [], hasMore: true }) | |
| expect(listByStatusMock).toHaveBeenCalledWith('tenant-1', 'needs_review', { | |
| limit: 10, after: { createdAt: '2026-08-01T00:00:00.000Z', id: 'abc' }, | |
| }) | |
| }) | |
| it('defaults to a page size of 30 with no cursor when neither is supplied', async () => { | |
| listByStatusMock.mockResolvedValue({ items: [], hasMore: false }) | |
| await fetch(`${baseUrl}/slip-queue?status=pending`) | |
| expect(listByStatusMock).toHaveBeenCalledWith('tenant-1', 'pending', { limit: 30, after: undefined }) | |
| }) | |
| it('caps limit at 100', async () => { | |
| listByStatusMock.mockResolvedValue({ items: [], hasMore: false }) | |
| await fetch(`${baseUrl}/slip-queue?status=pending&limit=9999`) | |
| expect(listByStatusMock).toHaveBeenCalledWith('tenant-1', 'pending', { limit: 100, after: undefined }) | |
| }) | |
| }) | |
| describe('getSlipQueueCounts', () => { | |
| it('returns the service\'s per-status counts', async () => { | |
| const counts = { pending: 1, processing: 0, success: 2, needs_review: 3, failed: 0, confirmed: 10 } | |
| countsByStatusMock.mockResolvedValue(counts) | |
| const res = await fetch(`${baseUrl}/slip-queue/counts`) | |
| expect(res.status).toBe(200) | |
| expect(await res.json()).toEqual(counts) | |
| }) | |
| }) | |
| describe('confirmSlipQueueItem (I8)', () => { | |
| it('passes slip_image_key and reference from the parsed slip into expenseService.create', async () => { | |
| getMock.mockResolvedValue({ | |
| id: 'slip-1', | |
| status: 'success', | |
| image_key: 'slip-queue/slip-1.jpg', | |
| parsed_result: { | |
| amount: 250, currency: 'THB', date: '2026-08-01', merchant: 'Cafe X', | |
| category: 'food', confidence: 0.9, raw_description: 'coffee', reference: 'TXN-777', | |
| }, | |
| }) | |
| createExpenseMock.mockResolvedValue({ id: 'exp-1', amount: 250 }) | |
| setConfirmedMock.mockResolvedValue({ id: 'slip-1', status: 'confirmed' }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-1/confirm`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ category_id: 'cat-1' }), | |
| }) | |
| expect(res.status).toBe(201) | |
| expect(createExpenseMock).toHaveBeenCalledWith('tenant-1', expect.objectContaining({ | |
| slip_image_key: 'slip-queue/slip-1.jpg', | |
| reference: 'TXN-777', | |
| })) | |
| expect(setConfirmedMock).toHaveBeenCalledWith('tenant-1', 'slip-1') | |
| }) | |
| it('passes null slip_image_key/reference through when the slip has none', async () => { | |
| getMock.mockResolvedValue({ | |
| id: 'slip-2', | |
| status: 'success', | |
| image_key: null, | |
| parsed_result: { | |
| amount: 80, currency: 'THB', date: '2026-08-01', merchant: null, | |
| category: 'other', confidence: 0.7, raw_description: 'cash note', reference: null, | |
| }, | |
| }) | |
| createExpenseMock.mockResolvedValue({ id: 'exp-2', amount: 80 }) | |
| setConfirmedMock.mockResolvedValue({ id: 'slip-2', status: 'confirmed' }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-2/confirm`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ category_id: 'cat-1' }), | |
| }) | |
| expect(res.status).toBe(201) | |
| expect(createExpenseMock).toHaveBeenCalledWith('tenant-1', expect.objectContaining({ | |
| slip_image_key: null, | |
| reference: null, | |
| })) | |
| }) | |
| it('surfaces the duplicate-guard error status/message instead of a generic 500', async () => { | |
| getMock.mockResolvedValue({ | |
| id: 'slip-3', | |
| status: 'success', | |
| image_key: 'slip-queue/slip-3.jpg', | |
| parsed_result: { | |
| amount: 88.5, currency: 'THB', date: '2026-06-06', merchant: 'Line man', | |
| category: 'food', confidence: 0.9, raw_description: '', reference: 'TXN-DUP', | |
| }, | |
| }) | |
| createExpenseMock.mockRejectedValue( | |
| Object.assign(new Error('This looks like a duplicate of an existing expense from 2026-06-06 (THB 88.5)'), { status: 409 }), | |
| ) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-3/confirm`, { | |
| method: 'POST', | |
| headers: { 'content-type': 'application/json' }, | |
| body: JSON.stringify({ category_id: 'cat-1' }), | |
| }) | |
| expect(res.status).toBe(409) | |
| const body = await res.json() as { error?: string } | |
| expect(body.error).toContain('duplicate of an existing expense') | |
| expect(setConfirmedMock).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| describe('deleteSlipQueueItem (I5)', () => { | |
| it('rejects deleting a confirmed slip with a 400, without calling delete', async () => { | |
| getMock.mockResolvedValue({ id: 'slip-3', status: 'confirmed', image_key: 'slip-queue/slip-3.jpg' }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-3`, { method: 'DELETE' }) | |
| expect(res.status).toBe(400) | |
| const body = await res.json() as { error: string } | |
| expect(body.error).toMatch(/confirmed/i) | |
| expect(deleteMock).not.toHaveBeenCalled() | |
| }) | |
| it('allows deleting a non-confirmed (e.g. failed) slip', async () => { | |
| getMock.mockResolvedValue({ id: 'slip-4', status: 'failed', image_key: null }) | |
| deleteMock.mockResolvedValue(undefined) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-4`, { method: 'DELETE' }) | |
| expect(res.status).toBe(204) | |
| expect(deleteMock).toHaveBeenCalledWith('tenant-1', 'slip-4') | |
| }) | |
| it('returns 404 when the slip does not exist', async () => { | |
| getMock.mockResolvedValue(null) | |
| const res = await fetch(`${baseUrl}/slip-queue/missing`, { method: 'DELETE' }) | |
| expect(res.status).toBe(404) | |
| expect(deleteMock).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| describe('applyMatchSlipQueueItem (slip-queue-match-review-ui)', () => { | |
| it('uses the slip\'s own stored match when no override body is sent ("use matched template")', async () => { | |
| getMock.mockResolvedValue({ | |
| id: 'slip-nr-1', status: 'needs_review', | |
| match_confidence: 0.75, match_template_id: 'tpl-1', | |
| parsed_result: { amount: 100, currency: 'THB', date: null, merchant: 'X', category: 'food', confidence: 0.75, raw_description: 'r', reference: null }, | |
| }) | |
| applyMatchMock.mockResolvedValue({ id: 'slip-nr-1', status: 'success' }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-nr-1/apply-match`, { | |
| method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), | |
| }) | |
| expect(res.status).toBe(200) | |
| expect(applyMatchMock).toHaveBeenCalledWith('tenant-1', 'slip-nr-1', { | |
| parsed_result: { amount: 100, currency: 'THB', date: null, merchant: 'X', category: 'food', confidence: 0.75, raw_description: 'r', reference: null }, | |
| match_confidence: 0.75, | |
| match_template_id: 'tpl-1', | |
| }) | |
| }) | |
| it('uses the override body when a freshly-taught template result is sent ("set up a new template")', async () => { | |
| getMock.mockResolvedValue({ id: 'slip-nr-2', status: 'needs_review', match_confidence: 0, match_template_id: null, parsed_result: null }) | |
| applyMatchMock.mockResolvedValue({ id: 'slip-nr-2', status: 'success' }) | |
| const override = { | |
| parsed_result: { amount: 42, currency: 'THB', date: null, merchant: 'New', category: 'food', confidence: 1, raw_description: 'r', reference: null, template_id: 'tpl-new' }, | |
| match_confidence: 1, | |
| match_template_id: 'tpl-new', | |
| } | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-nr-2/apply-match`, { | |
| method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(override), | |
| }) | |
| expect(res.status).toBe(200) | |
| expect(applyMatchMock).toHaveBeenCalledWith('tenant-1', 'slip-nr-2', override) | |
| }) | |
| it('rejects with 400 when the slip has no stored match and no override is sent', async () => { | |
| getMock.mockResolvedValue({ id: 'slip-nr-3', status: 'needs_review', match_confidence: 0, match_template_id: null, parsed_result: null }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-nr-3/apply-match`, { | |
| method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), | |
| }) | |
| expect(res.status).toBe(400) | |
| expect(applyMatchMock).not.toHaveBeenCalled() | |
| }) | |
| it('rejects with 400 when the slip is not needs_review', async () => { | |
| getMock.mockResolvedValue({ id: 'slip-nr-4', status: 'success', match_confidence: 0.9, match_template_id: 'tpl-1' }) | |
| const res = await fetch(`${baseUrl}/slip-queue/slip-nr-4/apply-match`, { | |
| method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), | |
| }) | |
| expect(res.status).toBe(400) | |
| expect(applyMatchMock).not.toHaveBeenCalled() | |
| }) | |
| it('returns 404 when the slip does not exist', async () => { | |
| getMock.mockResolvedValue(null) | |
| const res = await fetch(`${baseUrl}/slip-queue/missing/apply-match`, { | |
| method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}), | |
| }) | |
| expect(res.status).toBe(404) | |
| expect(applyMatchMock).not.toHaveBeenCalled() | |
| }) | |
| }) | |
| }) | |