llm-router / apps /api /src /services /expense /slip-queue /slip-queue.controller.test.ts
ClaraSu's picture
Upload folder using huggingface_hub
0ccc0de verified
Raw History Blame Contribute Delete
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()
})
})
})