Files
accounted/lib/receipt-hunt/__tests__/ingest.test.ts
T
845add4573 feat(documents): dedupe intake channels on content, not just provenance (#1528)
* feat(documents): dedupe intake channels on content, not just provenance

Every ingestion path already computes and stores sha256_hash, but only
WhatsApp ever read it back: the manual upload, Resend inbound, and mail
hunt deduped on provenance keys alone (or not at all), so the same
receipt forwarded to two inboxes, re-hunted by a sweep, or uploaded
twice became a second archived document and a second inbox item. With
the hunt live and three channels feeding one inbox, that is an unbounded
duplicate generator (flows plan, prerequisite PR 1).

uploadDocument gains an opt-in dedupeByContent flag: before storing, it
looks for a current-version document in the same company with the same
SHA-256 and returns it (marked deduplicated) instead of archiving a
copy. Opt-in because archival callers must store what they produced even
when bytes repeat; the SELECT-then-insert race is accepted exactly as in
the WhatsApp intake precedent.

uploadAndExtract turns the flag on for every inbox channel. On a hit it
adopts the oldest inbox item for that document, so callers always
receive a real inbox_item_id, and only files a new item (against the
EXISTING document) when the content entered the archive outside the
inbox. The mail hunt skips outright: its provenance key catches the same
message re-hunted, the content check catches the same receipt arriving
through another inbox. WhatsApp keeps its own pre-check, which also
drives the duplicate reply to the sender.

No migration: the hash column and its index have existed since the
original archive schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): review round: fail closed, adopt-or-file in the hunt, audit trail

CodeRabbit: both dedupe lookups failed OPEN, so a transient DB error
would silently archive the duplicate the feature exists to prevent; both
now throw before anything is stored, and a regression test locks it.
The ingest test also asserts the dedupeByContent flag in the production
call, so removing the flag fails the suite.

Swedish compliance review, both findings real: (1) the mail hunt's
unconditional skip could swallow a receipt whose content matches a
document that never passed the inbox (a manually attached copy), leaving
an affärshändelse without underlag routing (BFL 5 kap): the hunt now
mirrors the funnel's adopt-or-file semantics, skipping only when an
inbox item already carries the document and otherwise filing an item
against the EXISTING document. (2) The skip decision now lands in
behandlingshistorik as DocumentDuplicateSkipped (BFNAR 2013:2 kap 8),
not just the app log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(receipt-hunt): keep the audit payload pseudonymous; lock the skip trail in tests

Review round 2. The DocumentDuplicateSkipped payload carried the mailbox
address, violating the processing-history contract (pseudonymous IDs
only, never emails); the digit-shaped PII validator would not have
caught it, which is exactly why the contract must hold at the call site.
Which mailbox first delivered the receipt is already on the existing
item's channel_context. Tests now assert the audit event lands with the
right identifiers and no address, and that a history outage still skips
rather than filing a duplicate.

Not changed: a duplicate-lookup error still soft-fails the attachment
(warn + continue). Aborting the candidate would contradict this
function's documented contract (one bad message never costs the night's
hunt); fail-closed holds either way, and the next sweep retries since
no item was filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:55:12 +02:00

300 lines
12 KiB
TypeScript

/**
* Filing a hunted receipt. What matters here is what must NOT happen: no
* duplicate ingest, no oversized download, and one unreadable attachment never
* costing the rest of the run.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { MailCandidate } from '@/lib/mail-search/service'
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockAppendHistory = vi.fn()
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: (...args: unknown[]) => mockAppendHistory(...args),
}))
const mockFetchAttachment = vi.fn()
vi.mock('@/lib/mail-search/service', () => ({
getMailSearchService: () => ({
fetchAttachment: (...args: unknown[]) => mockFetchAttachment(...args),
search: vi.fn(),
isConfigured: () => true,
}),
}))
import { ingestMailCandidate, sniffMimeType } from '../ingest'
function candidate(overrides: Partial<MailCandidate> = {}): MailCandidate {
return {
connectionId: 'conn-1',
mailbox: 'ekonomi@nordvik.se',
provider: 'gmail',
messageId: 'msg-1',
subject: 'Ditt kvitto',
from: 'no-reply@circlek.se',
receivedAt: '2026-05-02T10:00:00Z',
attachmentIds: ['att-1'],
bodyIsReceipt: false,
...overrides,
}
}
/** Table-dispatching Supabase stand-in with a settable existing-row answer. */
function mockSupabase(
existing: { id: string } | null,
insertResult: { data?: unknown; error?: unknown } = {},
laterInboxAnswers: Array<{ id: string } | null> = [],
) {
const inboxQueue: Array<{ id: string } | null> = [existing, ...laterInboxAnswers]
const inserted: Array<Record<string, unknown>> = []
const client = {
from(table: string) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'is', 'not', 'order', 'limit']) chain[m] = vi.fn(() => chain)
chain.maybeSingle = vi.fn(() =>
Promise.resolve(
table === 'document_attachments'
? { data: { extracted_data: { total_amount: 425 } }, error: null }
: { data: inboxQueue.length ? inboxQueue.shift() ?? null : null, error: null },
),
)
chain.insert = vi.fn((row: Record<string, unknown>) => {
inserted.push(row)
return {
select: () => ({
single: () =>
Promise.resolve(
insertResult.error
? { data: null, error: insertResult.error }
: { data: insertResult.data ?? { id: 'item-1' }, error: null },
),
}),
}
})
return chain
},
}
return { client: client as never, inserted }
}
beforeEach(() => {
vi.clearAllMocks()
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockFetchAttachment.mockResolvedValue({
filename: 'kvitto.pdf',
mimeType: 'application/pdf',
bytes: Buffer.from('%PDF-1.4 fake'),
})
})
describe('ingestMailCandidate', () => {
it('files an attachment and returns the pairing material', async () => {
const { client, inserted } = mockSupabase(null)
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toMatchObject({ documentId: 'doc-1', inboxItemId: 'item-1', fileName: 'kvitto.pdf' })
expect(inserted).toHaveLength(1)
expect(inserted[0].source).toBe('mail_hunt')
// Provenance goes in channel_context, never extracted_data: retrying
// extraction overwrites extracted_data wholesale, and the record of which
// mailbox a receipt came from has to survive that.
const ctx = inserted[0].channel_context as Record<string, unknown>
expect(ctx.mail_message_id).toBe('msg-1')
expect(ctx.mail_mailbox).toBe('ekonomi@nordvik.se')
// Keyed per attachment: a batch forward carries receipts for several
// purchases, and filing the first must not block the rest.
expect(ctx.mail_file_key).toBe('msg-1::att-1')
const extracted = inserted[0].extracted_data as Record<string, unknown> | null
expect(extracted).not.toHaveProperty('mail_message_id')
// The extraction that ran on upload is copied onto the inbox item: the
// pool is read from here, and a row with no amount can never be paired.
expect(extracted).toMatchObject({ total_amount: 425 })
})
it('does not fetch anything for a message already ingested', async () => {
const { client } = mockSupabase({ id: 'existing' })
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toBeNull()
// The point of the pre-check is that a known message costs no provider call.
expect(mockFetchAttachment).not.toHaveBeenCalled()
})
it('treats a unique-violation as success, not an error', async () => {
// Another run won the race; the receipt is filed either way.
const { client } = mockSupabase(null, { error: { code: '23505', message: 'duplicate key' } })
await expect(ingestMailCandidate(client, 'co-1', 'user-1', candidate())).resolves.toBeNull()
})
it('skips filing when the archived duplicate already has an inbox item', async () => {
// The provenance key catches a re-hunted message; this catches the same
// receipt arriving through ANOTHER inbox: the receipt is already in the
// Underlag flow, so no second item is filed.
mockUploadDocument.mockResolvedValue({ id: 'doc-orig', deduplicated: true })
const { client, inserted } = mockSupabase(null, {}, [{ id: 'item-existing' }])
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toBeNull()
expect(inserted).toHaveLength(0)
// Locks the flag itself: without dedupeByContent the mock still answers
// deduplicated, but production would silently archive copies again.
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'co-1',
expect.anything(),
{ upload_source: 'mail_hunt', dedupeByContent: true },
)
// The skip is behandlingshistorik, not just an app log; and the payload
// stays pseudonymous (never a mailbox address).
expect(mockAppendHistory).toHaveBeenCalledTimes(1)
const event = mockAppendHistory.mock.calls[0]![0] as {
eventType: string
aggregateId: string
payload: Record<string, unknown>
}
expect(event.eventType).toBe('DocumentDuplicateSkipped')
expect(event.aggregateId).toBe('doc-orig')
expect(event.payload).toMatchObject({
channel: 'mail_hunt',
document_id: 'doc-orig',
inbox_item_id: 'item-existing',
reason: 'duplicate_content',
})
expect(JSON.stringify(event.payload)).not.toContain('@')
})
it('still skips the duplicate when the history append fails', async () => {
// The audit write is best-effort by design: a history outage must not
// turn a correct skip into a duplicate filing.
mockUploadDocument.mockResolvedValue({ id: 'doc-orig', deduplicated: true })
mockAppendHistory.mockRejectedValueOnce(new Error('history down'))
const { client, inserted } = mockSupabase(null, {}, [{ id: 'item-existing' }])
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toBeNull()
expect(inserted).toHaveLength(0)
})
it('files an item against the existing document when the duplicate never passed the inbox', async () => {
// A content match against a document with no inbox item (a manually
// attached copy) must not swallow the receipt: the affärshändelse still
// needs routing to matching (BFL 5 kap), just without a second archive copy.
mockUploadDocument.mockResolvedValue({ id: 'doc-orig', deduplicated: true })
const { client, inserted } = mockSupabase(null, {}, [null])
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toMatchObject({ documentId: 'doc-orig', inboxItemId: 'item-1' })
expect(inserted).toHaveLength(1)
expect(inserted[0].document_id).toBe('doc-orig')
})
it('ignores a body-only receipt, which has nothing to download', async () => {
const { client } = mockSupabase(null)
const result = await ingestMailCandidate(
client,
'co-1',
'user-1',
candidate({ attachmentIds: [], bodyIsReceipt: true }),
)
expect(result).toBeNull()
expect(mockFetchAttachment).not.toHaveBeenCalled()
})
it('skips an oversized attachment rather than storing a report', async () => {
mockFetchAttachment.mockResolvedValue({
filename: 'arsredovisning.pdf',
mimeType: 'application/pdf',
bytes: Buffer.alloc(11 * 1024 * 1024),
})
const { client, inserted } = mockSupabase(null)
const result = await ingestMailCandidate(client, 'co-1', 'user-1', candidate())
expect(result).toBeNull()
expect(inserted).toHaveLength(0)
})
it('tries the next attachment when one cannot be fetched', async () => {
mockFetchAttachment
.mockRejectedValueOnce(new Error('gmail 404'))
.mockResolvedValueOnce({
filename: 'kvitto.pdf',
mimeType: 'application/pdf',
bytes: Buffer.from('%PDF-1.4 fake'),
})
const { client } = mockSupabase(null)
const result = await ingestMailCandidate(
client,
'co-1',
'user-1',
candidate({ attachmentIds: ['bad', 'good'] }),
)
expect(result).toMatchObject({ documentId: 'doc-1' })
})
it('never throws when the upload itself is rejected', async () => {
// Magic-byte validation rejects a mislabelled file; one bad message must
// not abort a night's hunt.
mockUploadDocument.mockRejectedValue(new Error('File content does not match'))
const { client } = mockSupabase(null)
await expect(ingestMailCandidate(client, 'co-1', 'user-1', candidate())).resolves.toBeNull()
})
})
/**
* The first live fetch died here: Gmail declared a PDF as
* application/octet-stream, and uploadDocument validates content against the
* declared type, so the receipt was rejected at the door.
*/
describe('sniffMimeType', () => {
it('believes the bytes over a mail that says octet-stream', () => {
const pdf = Buffer.from('%PDF-1.4 ...')
expect(sniffMimeType(pdf, 'application/octet-stream', 'kvitto.pdf')).toBe('application/pdf')
})
it('recognises a photographed receipt', () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0])
expect(sniffMimeType(jpeg, 'application/octet-stream', 'IMG_5626')).toBe('image/jpeg')
})
it('falls back to the filename when the bytes say nothing', () => {
const unknown = Buffer.from('not a known header at all')
expect(sniffMimeType(unknown, 'application/octet-stream', 'faktura.pdf')).toBe('application/pdf')
})
it('keeps the declared type when nothing else identifies it', () => {
const unknown = Buffer.from('mystery bytes')
expect(sniffMimeType(unknown, 'text/plain', 'anteckning')).toBe('text/plain')
})
})
/**
* A message can carry several receipts. Whichever one is stored has to be
* filed under its own identity: recording index 0 while the loop is on a later
* attachment would both mislabel the row and permanently block the sibling,
* since the file key is unique.
*/
describe('ingestMailCandidate, over several attachments', () => {
it('files the attachment it actually stored, not the first one', async () => {
// The first attachment cannot be fetched, so the second is stored.
mockFetchAttachment
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
filename: 'ignored-by-caller.pdf',
mimeType: 'application/pdf',
bytes: Buffer.from('%PDF-1.4 fake'),
})
const { client, inserted } = mockSupabase(null)
const result = await ingestMailCandidate(
client,
'co-1',
'user-1',
candidate({ attachmentIds: ['att-1', 'att-2'], attachmentNames: ['first.pdf', 'second.pdf'] }),
)
const ctx = inserted[0].channel_context as Record<string, unknown>
expect(ctx.mail_attachment_id).toBe('att-2')
expect(ctx.mail_file_key).toBe('msg-1::att-2')
expect(result?.fileName).toBe('second.pdf')
})
})