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((resolve) => server.once('listening', resolve)) const { port } = server.address() as AddressInfo baseUrl = `http://localhost:${port}` }) afterAll(() => new Promise((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() }) }) })