Files
accounted/lib/api/__tests__/idempotency.test.ts
T
Mattsson bb855d2ddc Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
2026-05-04 11:12:29 +02:00

147 lines
4.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
hashRequest,
checkIdempotencyKey,
storeIdempotencyResponse,
cleanupExpiredIdempotencyKeys,
IdempotencyKeyReuseError,
} from '../idempotency'
describe('hashRequest', () => {
it('produces stable SHA-256 for the same payload', () => {
expect(hashRequest({ a: 1, b: 'x' })).toBe(hashRequest({ a: 1, b: 'x' }))
})
it('is order-independent', () => {
expect(hashRequest({ a: 1, b: 2 })).toBe(hashRequest({ b: 2, a: 1 }))
})
it('detects different values', () => {
expect(hashRequest({ a: 1 })).not.toBe(hashRequest({ a: 2 }))
})
it('handles nested objects deterministically', () => {
const h1 = hashRequest({ outer: { x: 1, y: 2 }, list: [1, 2, 3] })
const h2 = hashRequest({ list: [1, 2, 3], outer: { y: 2, x: 1 } })
expect(h1).toBe(h2)
})
})
function mockClient(maybeSingleResult: { data: Record<string, unknown> | null; error: unknown }) {
const select = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue(maybeSingleResult),
}),
}),
}),
})
const insert = vi.fn().mockResolvedValue({ error: null })
const deleteFn = vi.fn().mockReturnValue({
lt: vi.fn().mockResolvedValue({ error: null, count: 5 }),
})
return {
client: {
from: vi.fn().mockReturnValue({
select,
insert,
delete: deleteFn,
}),
} as never,
select,
insert,
deleteFn,
}
}
describe('checkIdempotencyKey', () => {
beforeEach(() => vi.clearAllMocks())
it('returns null when no cached row exists', async () => {
const { client } = mockClient({ data: null, error: null })
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
expect(result).toBeNull()
})
it('returns cached body when key + hash match', async () => {
const future = new Date(Date.now() + 60_000).toISOString()
const { client } = mockClient({
data: {
request_hash: 'hash-1',
response_status: 'success',
response_body: { foo: 'bar' },
expires_at: future,
},
error: null,
})
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
expect(result).toEqual({ status: 'success', body: { foo: 'bar' } })
})
it('throws IdempotencyKeyReuseError on hash mismatch', async () => {
const future = new Date(Date.now() + 60_000).toISOString()
const { client } = mockClient({
data: {
request_hash: 'hash-old',
response_status: 'success',
response_body: { foo: 'old' },
expires_at: future,
},
error: null,
})
await expect(
checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-new')
).rejects.toBeInstanceOf(IdempotencyKeyReuseError)
})
it('treats expired rows as misses', async () => {
const past = new Date(Date.now() - 60_000).toISOString()
const { client } = mockClient({
data: {
request_hash: 'hash-1',
response_status: 'success',
response_body: { foo: 'bar' },
expires_at: past,
},
error: null,
})
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
expect(result).toBeNull()
})
})
describe('storeIdempotencyResponse', () => {
beforeEach(() => vi.clearAllMocks())
it('writes the response row', async () => {
const { client, insert } = mockClient({ data: null, error: null })
await storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', { ok: true })
expect(insert).toHaveBeenCalledWith(expect.objectContaining({
user_id: 'user-1',
company_id: 'company-1',
key: 'key-1',
request_hash: 'hash-1',
response_status: 'success',
response_body: { ok: true },
scope: 'mcp_tool',
}))
})
it('swallows duplicate-row races (23505)', async () => {
const { client, insert } = mockClient({ data: null, error: null })
insert.mockResolvedValueOnce({ error: { code: '23505', message: 'unique_violation' } })
await expect(
storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', {})
).resolves.toBeUndefined()
})
})
describe('cleanupExpiredIdempotencyKeys', () => {
it('returns delete count', async () => {
const { client } = mockClient({ data: null, error: null })
const count = await cleanupExpiredIdempotencyKeys(client)
expect(count).toBe(5)
})
})