5fe3ae71a1
A document_attachments row is reachable from eight places. A row referenced by none of them is stored, retained for seven years under BFL, and connected to no bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across 210 companies, 481 of them in the preceding week. The naive predicate is a trap. Without a mime filter the same query returns 15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are unlinked by design. Putting them on an orientation surface would hand an agent eleven thousand items of work it must not do, which is worse than showing nothing. So the rule is an allow-list of the mime types an underlag can actually be. Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps the next machine-payload format out by default rather than after someone notices it leaking. Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter first, then eight reference lookups that run only when candidates exist, so a company with none costs exactly one query. The scan cap is set by URL length rather than table size, because every candidate id is echoed back through those eight .in() lookups. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
/**
|
|
* The unlinked-document predicate, and above all the mime allow-list, which is
|
|
* the part that decides whether this surface is useful or actively harmful.
|
|
*/
|
|
import { describe, it, expect } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import {
|
|
fetchUnlinkedDocuments,
|
|
UNDERLAG_MIME_TYPES,
|
|
UNLINKED_DOCUMENT_SCAN_CAP,
|
|
} from '../unlinked-documents'
|
|
|
|
type Enqueue = (r: { data?: unknown; error?: unknown; count?: number | null }) => void
|
|
|
|
/** One empty result per referencing table, so nothing claims any candidate. */
|
|
function enqueueNoReferences(enqueue: Enqueue) {
|
|
for (let i = 0; i < 8; i += 1) enqueue({ data: [] })
|
|
}
|
|
|
|
const doc = (id: string, overrides: Record<string, unknown> = {}) => ({
|
|
id,
|
|
file_name: `${id}.pdf`,
|
|
mime_type: 'application/pdf',
|
|
file_size_bytes: 1024,
|
|
upload_source: 'api',
|
|
created_at: '2026-08-01T00:00:00.000Z',
|
|
...overrides,
|
|
})
|
|
|
|
describe('UNDERLAG_MIME_TYPES', () => {
|
|
it('excludes application/json, which on this surface is only the PSD2 archive', () => {
|
|
// Measured on production 2026-08-27: of the document rows referenced by
|
|
// nothing, application/json was 11 309 of 11 309 archived PSD2 bank-API
|
|
// responses, and 0 of 4 495 pdf/png/jpeg/heic rows were. Those archives are
|
|
// unlinked BY DESIGN. Admitting them here would hand an agent 11 309 items
|
|
// of work it must not do, which is worse than showing nothing at all.
|
|
expect(UNDERLAG_MIME_TYPES).not.toContain('application/json')
|
|
})
|
|
|
|
it('is an allow-list, so a future machine payload format stays out by default', () => {
|
|
// The alternative, excluding known-bad filenames, leaks every new archive
|
|
// format until someone notices and adds another exclusion.
|
|
for (const mime of UNDERLAG_MIME_TYPES) {
|
|
expect(mime === 'application/pdf' || mime.startsWith('image/')).toBe(true)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('fetchUnlinkedDocuments', () => {
|
|
it('costs exactly one query when the company has no candidates', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] })
|
|
// Deliberately NOT enqueueing the eight reference lookups: if the early
|
|
// return regressed, the mock would starve and this test would fail.
|
|
|
|
const result = await fetchUnlinkedDocuments(supabase as never, 'company-1')
|
|
|
|
expect(result).toEqual({ documents: [], count: 0, capped: false })
|
|
})
|
|
|
|
it('returns candidates that no table references', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [doc('d-1'), doc('d-2')] })
|
|
enqueueNoReferences(enqueue)
|
|
|
|
const result = await fetchUnlinkedDocuments(supabase as never, 'company-1')
|
|
|
|
expect(result.count).toBe(2)
|
|
expect(result.documents.map((d) => d.id)).toEqual(['d-1', 'd-2'])
|
|
expect(result.capped).toBe(false)
|
|
})
|
|
|
|
it('drops a candidate claimed by any one of the eight referencing tables', async () => {
|
|
// Walk the tables one at a time: a document claimed only by the Nth table
|
|
// must still be excluded, which is what catches a missing entry in the list.
|
|
for (let table = 0; table < 8; table += 1) {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [doc('d-claimed'), doc('d-free')] })
|
|
for (let i = 0; i < 8; i += 1) {
|
|
// Every referencing query selects its own column name, and the
|
|
// implementation reads whichever key the row carries.
|
|
enqueue({ data: i === table ? [{ document_id: 'd-claimed', document_attachment_id: 'd-claimed', xml_document_id: 'd-claimed', dokument_id: 'd-claimed', file_document_id: 'd-claimed' }] : [] })
|
|
}
|
|
|
|
const result = await fetchUnlinkedDocuments(supabase as never, 'company-1')
|
|
|
|
expect(result.documents.map((d) => d.id), `table index ${table}`).toEqual(['d-free'])
|
|
}
|
|
})
|
|
|
|
it('reports capped when the candidate scan hits the limit', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
const many = Array.from({ length: 3 }, (_, i) => doc(`d-${i}`))
|
|
enqueue({ data: many })
|
|
enqueueNoReferences(enqueue)
|
|
|
|
const result = await fetchUnlinkedDocuments(supabase as never, 'company-1', { limit: 3 })
|
|
|
|
// count is a floor, not a total, and the caller says so in its prose.
|
|
expect(result.capped).toBe(true)
|
|
expect(result.count).toBe(3)
|
|
})
|
|
|
|
it('keeps the scan cap small enough that the eight .in() lookups stay valid URLs', () => {
|
|
// Each candidate id is echoed back through eight .in(column, ids) queries,
|
|
// and a UUID costs ~38 bytes in a PostgREST query string. Past a few
|
|
// hundred the URL exceeds what the gateway accepts, the lookups fail, and
|
|
// the "claims nothing" fallback turns every candidate into a false
|
|
// positive. This is the constraint that sets the cap, not table size.
|
|
const bytesPerLookup = UNLINKED_DOCUMENT_SCAN_CAP * 38
|
|
expect(bytesPerLookup).toBeLessThan(16_000)
|
|
})
|
|
})
|