Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
|
import {
|
|
createQueuedMockSupabase,
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
} from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
return invoiceInboxExtension.apiRoutes!.find(
|
|
(r) => r.method === method && r.path === path,
|
|
)!
|
|
}
|
|
|
|
const matchRoute = findRoute('POST', '/items/:id/match-transaction')
|
|
const unmatchRoute = findRoute('POST', '/items/:id/unmatch-transaction')
|
|
|
|
function buildCtx(supabase: unknown): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'invoice-inbox',
|
|
supabase: supabase as ExtensionContext['supabase'],
|
|
emit: vi.fn(),
|
|
settings: { get: vi.fn(), set: vi.fn() },
|
|
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
|
services: {},
|
|
} as ExtensionContext
|
|
}
|
|
|
|
function makeReq(path: string, body?: unknown) {
|
|
return createMockRequest(path, {
|
|
method: 'POST',
|
|
searchParams: { _id: 'item-1' },
|
|
body,
|
|
})
|
|
}
|
|
|
|
describe('POST /items/:id/match-transaction', () => {
|
|
let mock: ReturnType<typeof createQueuedMockSupabase>
|
|
beforeEach(() => {
|
|
mock = createQueuedMockSupabase()
|
|
})
|
|
|
|
it('returns 401 without ctx', async () => {
|
|
const res = await matchRoute.handler(makeReq('/items/item-1/match-transaction'))
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when transaction_id is missing', async () => {
|
|
const ctx = buildCtx(mock.supabase)
|
|
const res = await matchRoute.handler(
|
|
makeReq('/items/item-1/match-transaction', {}),
|
|
ctx,
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 when transaction is outside this company', async () => {
|
|
// tx lookup returns null → 404 before any further calls
|
|
mock.enqueue({ data: null })
|
|
const ctx = buildCtx(mock.supabase)
|
|
const res = await matchRoute.handler(
|
|
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
|
ctx,
|
|
)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('sets matched_transaction_id and mirrors document_id onto the transaction', async () => {
|
|
// Sequence: tx lookup (no doc yet) → inbox doc lookup → inbox update → tx update
|
|
mock.enqueue({ data: { id: 'tx-1', document_id: null } })
|
|
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
|
|
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
|
|
mock.enqueue({ data: null }) // tx update result (we don't read)
|
|
const ctx = buildCtx(mock.supabase)
|
|
const res = await matchRoute.handler(
|
|
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
|
ctx,
|
|
)
|
|
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.matched_transaction_id).toBe('tx-1')
|
|
})
|
|
|
|
it('does not overwrite an existing tx.document_id', async () => {
|
|
// tx already has a doc: match still succeeds but the tx update for
|
|
// document_id is skipped (no enqueue beyond the inbox update).
|
|
mock.enqueue({ data: { id: 'tx-1', document_id: 'existing-doc' } })
|
|
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
|
|
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
|
|
const ctx = buildCtx(mock.supabase)
|
|
const res = await matchRoute.handler(
|
|
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
|
ctx,
|
|
)
|
|
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.matched_transaction_id).toBe('tx-1')
|
|
})
|
|
})
|
|
|
|
describe('POST /items/:id/unmatch-transaction', () => {
|
|
let mock: ReturnType<typeof createQueuedMockSupabase>
|
|
beforeEach(() => {
|
|
mock = createQueuedMockSupabase()
|
|
})
|
|
|
|
it('clears matched_transaction_id and mirrors the unmatch onto the transaction', async () => {
|
|
// Sequence: existing-state lookup → inbox update → tx update
|
|
mock.enqueue({
|
|
data: {
|
|
id: 'item-1',
|
|
document_id: 'doc-1',
|
|
matched_transaction_id: 'tx-1',
|
|
},
|
|
})
|
|
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: null } })
|
|
mock.enqueue({ data: null }) // tx update result
|
|
const ctx = buildCtx(mock.supabase)
|
|
const res = await unmatchRoute.handler(
|
|
makeReq('/items/item-1/unmatch-transaction'),
|
|
ctx,
|
|
)
|
|
const { body } = await parseJsonResponse<{
|
|
data: { matched_transaction_id: string | null }
|
|
}>(res)
|
|
expect(res.status).toBe(200)
|
|
expect(body.data.matched_transaction_id).toBeNull()
|
|
})
|
|
|
|
it('returns 401 without ctx', async () => {
|
|
const res = await unmatchRoute.handler(makeReq('/items/item-1/unmatch-transaction'))
|
|
expect(res.status).toBe(401)
|
|
})
|
|
})
|