Files

180 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import Fastify from 'fastify'
import apophisPlugin from '@apophis/fastify'
import crypto from 'crypto'
const fastify = Fastify()
await fastify.register(apophisPlugin, {
runtime: 'error',
cleanup: true,
})
const processedKeys = new Set<string>()
const ledger = new Map<string, { idempotencyKey: string; amount: number; createdAt: number }>()
// PAYMENT — idempotent, must not double-charge
fastify.post('/payments', {
schema: {
'x-category': 'mutator',
'x-requires': [
'request_header(this).idempotency-key != null',
],
'x-ensures': [
'response_status(this) == 201 || response_status(this) == 200',
'response_body(this).id != null',
// Core idempotency assurance: same key, same result
'response_body(this).amount == request_body(this).amount',
],
headers: {
type: 'object',
properties: {
'idempotency-key': { type: 'string' },
},
required: ['idempotency-key'],
},
body: {
type: 'object',
properties: {
amount: { type: 'number', minimum: 1 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
},
required: ['amount', 'currency'],
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
idempotencyKey: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
200: {
type: 'object',
properties: {
id: { type: 'string' },
idempotencyKey: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const key = req.headers['idempotency-key'] as string
// Idempotency check: if already processed, return the saved result
if (processedKeys.has(key)) {
for (const record of ledger.values()) {
if (record.idempotencyKey === key) {
return { ...record, status: 'duplicate' }
}
}
}
const id = `pay-${crypto.randomUUID().slice(0, 8)}`
const record = {
id,
idempotencyKey: key,
amount: req.body.amount,
status: 'processed',
createdAt: Date.now(),
}
processedKeys.add(key)
ledger.set(id, record)
reply.status(201)
return record
})
// REFUND — idempotent reversal
fastify.post('/payments/:id/refund', {
schema: {
'x-category': 'mutator',
'x-requires': [
'response_code(GET /payments/{request_params(this).id}) == 200',
],
'x-ensures': [
'response_body(this).refundId != null',
'response_body(this).amount == previous(response_body(GET /payments/{request_params(this).id})).amount',
// Refund must be reversible only once
],
headers: {
type: 'object',
properties: {
'idempotency-key': { type: 'string' },
},
required: ['idempotency-key'],
},
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
response: {
201: {
type: 'object',
properties: {
refundId: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
const payment = ledger.get(req.params.id)
if (!payment) {
reply.status(404)
return { error: 'Payment not found' }
}
const key = req.headers['idempotency-key'] as string
if (processedKeys.has(key)) {
return {
refundId: `ref-${payment.id}`,
amount: payment.amount,
status: 'duplicate',
}
}
processedKeys.add(key)
reply.status(201)
return {
refundId: `ref-${payment.id}`,
amount: payment.amount,
status: 'refunded',
}
})
// PAYMENT STATUS — observer
fastify.get('/payments/:id', {
schema: {
'x-category': 'observer',
'x-requires': [
'response_status(this) == 200 || response_status(this) == 404',
],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
},
}, async (req, reply) => {
const payment = ledger.get(req.params.id)
if (!payment) {
reply.status(404)
return { error: 'Payment not found' }
}
return payment
})
await fastify.ready()
const result = await fastify.apophis.contract({ runs: 50 })
console.log('Contract tests:', result.summary)
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
console.log('Stateful tests:', stateful.summary)