@@ -102,6 +102,31 @@ describe('errorResponse', () => {
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23505' })
|
||||
})
|
||||
|
||||
it('maps the ignored-transaction journal constraint to a typed conflict', async () => {
|
||||
const pgErr = Object.assign(
|
||||
new Error(
|
||||
'new row for relation "transactions" violates check constraint "transactions_is_ignored_no_journal_entry"',
|
||||
),
|
||||
{ code: '23514' },
|
||||
)
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_ignored_tx' })
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_IGNORED_CONFLICT')
|
||||
expect(body.error.message).not.toContain('check constraint')
|
||||
expect(body.error.details).toMatchObject({ pgCode: '23514' })
|
||||
})
|
||||
|
||||
it('does not apply unrelated message heuristics to Postgres errors', async () => {
|
||||
const pgErr = Object.assign(new Error('Invoice not found'), { code: 'P0001' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_unrelated' })
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const body = await readEnvelope(res)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
|
||||
it('maps Postgres no-data-found to NOT_FOUND with pgCode', async () => {
|
||||
const pgErr = Object.assign(new Error('invoice not found'), { code: 'P0002' })
|
||||
const res = errorResponse(pgErr, noopLogger, { requestId: 'req_pg_not_found' })
|
||||
|
||||
@@ -189,7 +189,9 @@ export function getStructuredError(
|
||||
const message_sv = getErrorMessage(error)
|
||||
|
||||
const transient = isTransientFailure(error, message_en)
|
||||
let code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
let code = isIgnoredTransactionJournalConstraint(error)
|
||||
? 'TX_CATEGORIZE_IGNORED_CONFLICT'
|
||||
: extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
// Nothing more specific matched but the failure is transient: surface the
|
||||
// stable TRANSIENT_ERROR code so agents can dispatch on it.
|
||||
if (code === 'UNKNOWN_ERROR' && transient) code = 'TRANSIENT_ERROR'
|
||||
@@ -313,6 +315,13 @@ function isPostgresError(err: unknown): err is { code: string; message: string }
|
||||
)
|
||||
}
|
||||
|
||||
export function isIgnoredTransactionJournalConstraint(error: unknown): boolean {
|
||||
return (
|
||||
isPostgresError(error) &&
|
||||
/transactions_is_ignored_no_journal_entry/i.test(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical REST error envelope for any thrown value.
|
||||
*
|
||||
@@ -358,7 +367,9 @@ export function errorResponse(
|
||||
|
||||
// 3. Postgres errors
|
||||
if (isPostgresError(err)) {
|
||||
const mapped = postgresCodeToStructured(err.code)
|
||||
const mapped = isIgnoredTransactionJournalConstraint(err)
|
||||
? 'TX_CATEGORIZE_IGNORED_CONFLICT'
|
||||
: postgresCodeToStructured(err.code)
|
||||
if (mapped) {
|
||||
const entry = entryFor(mapped)
|
||||
logAtLevel(log, entry.httpStatus, 'database error', err as unknown as Error, {
|
||||
|
||||
@@ -447,6 +447,16 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.',
|
||||
message_en: 'Transaction was already categorized by another request.',
|
||||
},
|
||||
TX_CATEGORIZE_IGNORED_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Transaktionen är fortfarande markerad som ignorerad och kan därför inte kopplas till en verifikation.',
|
||||
message_en:
|
||||
'The transaction is still marked as ignored and cannot be linked to a journal entry.',
|
||||
remediation: {
|
||||
description: 'Reload and retry categorization. Report the conflict if it persists.',
|
||||
},
|
||||
},
|
||||
TX_CATEGORIZE_SUGGEST_SI_MATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
|
||||
Reference in New Issue
Block a user