Files
accounted/app/api/pending-operations/bulk-commit/__tests__/route.test.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

335 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCommit = vi.fn()
vi.mock('@/lib/pending-operations/commit', () => ({
commitPendingOperation: (...args: unknown[]) => mockCommit(...args),
}))
import { POST } from '../route'
const VALID_ID_1 = '11111111-1111-4111-8111-111111111111'
const VALID_ID_2 = '22222222-2222-4222-8222-222222222222'
const VALID_ID_3 = '33333333-3333-4333-8333-333333333333'
const VALID_ID_4 = '44444444-4444-4444-8444-444444444444'
const VALID_ID_5 = '55555555-5555-4555-8555-555555555555'
function makeOp(overrides: Record<string, unknown> = {}) {
return {
id: VALID_ID_1,
company_id: 'company-1',
user_id: 'user-1',
operation_type: 'categorize_transaction',
status: 'pending',
risk_level: 'low',
title: 'Kategorisera test',
params: {},
preview_data: {},
...overrides,
}
}
describe('POST /api/pending-operations/bulk-commit', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when ids array is empty', async () => {
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [] },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 400 when ids contain non-UUID values', async () => {
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: ['not-a-uuid'] },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 400 when ids exceed 100 items', async () => {
const ids = Array.from({ length: 101 }, (_, i) => {
const hex = i.toString(16).padStart(4, '0')
return `${hex}${hex}${hex}${hex}-${hex}${hex}-4${hex.slice(1)}-8${hex.slice(1)}-${hex}${hex}${hex}${hex}${hex}${hex}`
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 500 when fetching pending operations fails', async () => {
enqueue({ data: null, error: { message: 'db connection lost' } })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('db connection lost')
})
it('reports per-item not-found as failed without calling commit', async () => {
enqueue({ data: [] })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'failed', error: 'Operation not found' },
])
expect(body.data.summary).toEqual({
total: 1,
committed: 0,
failed: 1,
skipped: 0,
rejected: 0,
})
expect(mockCommit).not.toHaveBeenCalled()
})
it('skips non-pending operations and high-risk operations', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1, status: 'committed' }),
makeOp({ id: VALID_ID_2, status: 'pending', risk_level: 'high' }),
],
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'skipped', error: 'Already committed' },
{
id: VALID_ID_2,
status: 'skipped',
error: 'Hög risk: kräver individuellt godkännande',
},
])
expect(body.data.summary).toEqual({
total: 2,
committed: 0,
failed: 0,
skipped: 2,
rejected: 0,
})
expect(mockCommit).not.toHaveBeenCalled()
})
it('commits pending operations and aggregates summary on the happy path', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1 }),
makeOp({ id: VALID_ID_2 }),
],
})
mockCommit.mockResolvedValue({ status: 'committed', data: {} })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'committed' },
{ id: VALID_ID_2, status: 'committed' },
])
expect(body.data.summary).toEqual({
total: 2,
committed: 2,
failed: 0,
skipped: 0,
rejected: 0,
})
expect(mockCommit).toHaveBeenCalledTimes(2)
expect(mockCommit).toHaveBeenCalledWith(
mockSupabase,
'user-1',
'company-1',
expect.objectContaining({ id: VALID_ID_1 }),
// commit_method must be 'bulk_accept' so any journal_entries created
// during bulk approval are tagged distinctly from single-approval ones
// (BFNAR 2013:2 behandlingshistorik). The actor option attributes the
// commits to the approving user (migration 20260619120000).
{
userEmail: 'test@test.se',
commitMethod: 'bulk_accept',
actor: { type: 'user', label: 'test@test.se' },
}
)
})
it('routes auto_rejected results into the rejected bucket', async () => {
enqueue({ data: [makeOp({ id: VALID_ID_1 })] })
mockCommit.mockResolvedValue({
status: 'rejected',
auto_rejected: true,
error: 'Resource already deleted',
http_status: 409,
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'rejected', error: 'Resource already deleted' },
])
expect(body.data.summary).toEqual({
total: 1,
committed: 0,
failed: 0,
skipped: 0,
rejected: 1,
})
})
it('reports commit failures as failed and aggregates a mixed summary', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1 }),
makeOp({ id: VALID_ID_2 }),
makeOp({ id: VALID_ID_3, status: 'rejected' }),
makeOp({ id: VALID_ID_4 }),
],
})
mockCommit
.mockResolvedValueOnce({ status: 'committed', data: {} })
.mockResolvedValueOnce({ status: 'failed', error: 'boom', http_status: 500 })
.mockResolvedValueOnce({
status: 'rejected',
auto_rejected: true,
error: 'gone',
http_status: 404,
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2, VALID_ID_3, VALID_ID_4, VALID_ID_5] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'committed' },
{ id: VALID_ID_2, status: 'failed', error: 'boom' },
{ id: VALID_ID_3, status: 'skipped', error: 'Already rejected' },
{ id: VALID_ID_4, status: 'rejected', error: 'gone' },
{ id: VALID_ID_5, status: 'failed', error: 'Operation not found' },
])
expect(body.data.summary).toEqual({
total: 5,
committed: 1,
failed: 2,
skipped: 1,
rejected: 1,
})
expect(mockCommit).toHaveBeenCalledTimes(3)
})
})