Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions __tests__/idempotency/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'

import { withIdempotency } from '@/lib/idempotency/middleware'
import {
IdempotencyInProgressError,
IdempotencyKeyReusedError,
} from '@/lib/idempotency/errors'

vi.mock('@/lib/idempotency/service', () => ({
idempotencyService: { run: vi.fn() },
IdempotencyService: class {},
}))

import { idempotencyService } from '@/lib/idempotency/service'

const mockRun = vi.mocked(idempotencyService.run)
const KEY = 'test-key-1234567890abcd'
const AUTH = { walletAddress: 'GABC' }

function makeRequest(options: {
body?: unknown
key?: string
rawBody?: string
}): NextRequest {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (options.key) headers['Idempotency-Key'] = options.key
return new NextRequest('http://localhost/api/escrow/fund', {
method: 'POST',
headers,
body: options.rawBody ?? JSON.stringify(options.body ?? {}),
})
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('withIdempotency', () => {
it('returns 400 KEY_REQUIRED when no key is supplied', async () => {
const handler = vi.fn()
const wrapped = withIdempotency('escrow_fund', handler)

const res = await wrapped(makeRequest({ body: { contractId: 'c-1' } }), AUTH)

expect(res.status).toBe(400)
expect((await res.json()).code).toBe('IDEMPOTENCY_KEY_REQUIRED')
expect(mockRun).not.toHaveBeenCalled()
expect(handler).not.toHaveBeenCalled()
})

it('returns 400 KEY_INVALID for a malformed key', async () => {
const wrapped = withIdempotency('escrow_fund', vi.fn())

const res = await wrapped(
makeRequest({ body: { contractId: 'c-1' }, key: 'short' }),
AUTH
)

expect(res.status).toBe(400)
expect((await res.json()).code).toBe('IDEMPOTENCY_KEY_INVALID')
expect(mockRun).not.toHaveBeenCalled()
})

it('returns 400 INVALID_JSON for an unparsable body', async () => {
const wrapped = withIdempotency('escrow_fund', vi.fn())

const res = await wrapped(
makeRequest({ rawBody: 'not-json', key: KEY }),
AUTH
)

expect(res.status).toBe(400)
expect((await res.json()).code).toBe('INVALID_JSON')
expect(mockRun).not.toHaveBeenCalled()
})

it('executes the handler and passes the parsed body', async () => {
mockRun.mockImplementation(async (params) => ({
...(await params.handler()),
replayed: false,
}))
const handler = vi.fn(async () => NextResponse.json({ contractId: 'c-1' }, { status: 200 }))
const wrapped = withIdempotency('escrow_fund', handler)

const res = await wrapped(
makeRequest({ body: { contractId: 'c-1' }, key: KEY }),
AUTH
)

expect(res.status).toBe(200)
expect(await res.json()).toEqual({ contractId: 'c-1' })
expect(res.headers.get('Idempotency-Replayed')).toBe('false')
expect(handler).toHaveBeenCalledTimes(1)
expect(handler.mock.calls[0][2]).toEqual({ contractId: 'c-1' })
expect(mockRun).toHaveBeenCalledWith(
expect.objectContaining({ key: KEY, operationType: 'escrow_fund' })
)
})

it('accepts the key from the request body', async () => {
mockRun.mockImplementation(async (params) => ({
...(await params.handler()),
replayed: false,
}))
const wrapped = withIdempotency('escrow_fund', vi.fn(async () => NextResponse.json({ ok: true })))

await wrapped(makeRequest({ body: { contractId: 'c-1', idempotencyKey: KEY } }), AUTH)

expect(mockRun).toHaveBeenCalledWith(expect.objectContaining({ key: KEY }))
})

it('replays a stored response and marks the header', async () => {
mockRun.mockResolvedValue({ status: 201, body: { contractId: 'c-1' }, replayed: true })
const handler = vi.fn()
const wrapped = withIdempotency('escrow_fund', handler)

const res = await wrapped(makeRequest({ body: { contractId: 'c-1' }, key: KEY }), AUTH)

expect(res.status).toBe(201)
expect(await res.json()).toEqual({ contractId: 'c-1' })
expect(res.headers.get('Idempotency-Replayed')).toBe('true')
expect(handler).not.toHaveBeenCalled()
})

it('returns 409 with Retry-After while a duplicate is in progress', async () => {
mockRun.mockRejectedValue(new IdempotencyInProgressError())
const wrapped = withIdempotency('escrow_fund', vi.fn())

const res = await wrapped(makeRequest({ body: { contractId: 'c-1' }, key: KEY }), AUTH)

expect(res.status).toBe(409)
expect((await res.json()).code).toBe('IDEMPOTENCY_IN_PROGRESS')
expect(res.headers.get('Retry-After')).toBe('1')
})

it('returns 409 when a key is reused with a different payload', async () => {
mockRun.mockRejectedValue(new IdempotencyKeyReusedError())
const wrapped = withIdempotency('escrow_fund', vi.fn())

const res = await wrapped(makeRequest({ body: { contractId: 'c-2' }, key: KEY }), AUTH)

expect(res.status).toBe(409)
expect((await res.json()).code).toBe('IDEMPOTENCY_KEY_REUSED')
})
})
114 changes: 114 additions & 0 deletions __tests__/idempotency/repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('@/lib/db', () => ({
sql: vi.fn(),
}))

import { sql } from '@/lib/db'
import { IdempotencyRepository } from '@/lib/idempotency/repository'

const mockSql = sql as unknown as ReturnType<typeof vi.fn>

function row(overrides: Record<string, unknown> = {}) {
return {
id: 'r-1',
idempotency_key: 'key-1234567890abcdef',
operation_type: 'escrow_fund',
request_hash: 'hash-1',
request_payload: { contractId: 'c-1' },
response_payload: null,
response_status: null,
status: 'in_progress',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
expires_at: '2026-01-02T00:00:00.000Z',
...overrides,
}
}

beforeEach(() => {
vi.clearAllMocks()
})

describe('IdempotencyRepository', () => {
const repo = new IdempotencyRepository()

it('claims a new key when the insert returns a row', async () => {
mockSql.mockResolvedValueOnce([row()])

const result = await repo.claim({
key: 'key-1234567890abcdef',
operationType: 'escrow_fund',
requestHash: 'hash-1',
requestPayload: { contractId: 'c-1' },
ttlHours: 24,
})

expect(result.claimed).toBe(true)
expect(result.record?.status).toBe('in_progress')
expect(mockSql).toHaveBeenCalledTimes(1)
})

it('falls back to the existing record when the insert conflicts', async () => {
mockSql
.mockResolvedValueOnce([]) // ON CONFLICT … DO UPDATE WHERE β†’ no row
.mockResolvedValueOnce([row({ status: 'completed', response_status: 200, response_payload: { ok: true } })])

const result = await repo.claim({
key: 'key-1234567890abcdef',
operationType: 'escrow_fund',
requestHash: 'hash-1',
requestPayload: { contractId: 'c-1' },
ttlHours: 24,
})

expect(result.claimed).toBe(false)
expect(result.record?.status).toBe('completed')
expect(result.record?.responseStatus).toBe(200)
expect(result.record?.responsePayload).toEqual({ ok: true })
expect(mockSql).toHaveBeenCalledTimes(2)
})

it('parses string-serialised JSON payloads', async () => {
mockSql.mockResolvedValueOnce([
row({ request_payload: '{"contractId":"c-9"}', response_payload: '{"ok":true}' }),
])

const record = await repo.get('key-1234567890abcdef', 'escrow_fund')

expect(record?.requestPayload).toEqual({ contractId: 'c-9' })
expect(record?.responsePayload).toEqual({ ok: true })
})

it('returns null when no record exists', async () => {
mockSql.mockResolvedValueOnce([])
expect(await repo.get('missing-key-123456', 'escrow_fund')).toBeNull()
})

it('marks a completed record and returns it', async () => {
mockSql.mockResolvedValueOnce([row({ status: 'completed', response_status: 201, response_payload: { ok: true } })])

const record = await repo.complete(
'key-1234567890abcdef',
'escrow_fund',
{ ok: true },
201,
24
)

expect(record?.status).toBe('completed')
expect(record?.responseStatus).toBe(201)
})

it('purges expired records and returns the count', async () => {
mockSql.mockResolvedValueOnce([{ id: 'r-1' }, { id: 'r-2' }])
expect(await repo.purgeExpired()).toBe(2)
})

it('marks failed and removes records', async () => {
mockSql.mockResolvedValue([])
await repo.markFailed('key-1234567890abcdef', 'escrow_fund')
await repo.remove('key-1234567890abcdef', 'escrow_fund')
expect(mockSql).toHaveBeenCalledTimes(2)
})
})
Loading
Loading