项目文件夹

文件
Alice a8c741a9d7 feat: 实现幂等键(Idempotency Key)中间件
- 实现 Express.js 幂等键中间件,支持 Idempotency-Key 请求头
- 缓存成功响应(2xx)24小时,重复请求直接返回缓存结果
- 处理并发重复请求(in-flight 去重)
- 错误响应不缓存,允许客户端重试
- 实现 POST /api/orders 订单创建端点演示幂等性
- 添加 7 组共 21 个测试用例,全部通过
2026-09-08 17:06:01 +08:00

163 行
6.0 KiB
JavaScript

/**
* Tests for Idempotency Key Middleware
*
* Verifies:
* 1. First request with a new idempotency key creates a resource
* 2. Duplicate request with same key returns cached response (no duplicate creation)
* 3. Different keys create different resources
* 4. GET requests are not affected by idempotency middleware
* 5. Missing key allows normal processing
*/
const http = require('http');
const { v4: uuidv4 } = require('uuid');
const app = require('../src/server');
const PORT = 0; // Let the OS assign a port
let server;
let baseUrl;
function makeRequest(method, path, headers = {}, body = null) {
return new Promise((resolve, reject) => {
const url = new URL(path, baseUrl);
const options = {
method,
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
headers: {
'Content-Type': 'application/json',
...headers
}
};
if (body) {
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
options.headers['Content-Length'] = Buffer.byteLength(bodyStr);
}
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve({ statusCode: res.statusCode, headers: res.headers, body: JSON.parse(data) });
} catch {
resolve({ statusCode: res.statusCode, headers: res.headers, body: data });
}
});
});
req.on('error', reject);
if (body) {
req.write(typeof body === 'string' ? body : JSON.stringify(body));
}
req.end();
});
}
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
console.log(` ✓ ${message}`);
passed++;
} else {
console.log(` ✗ ${message}`);
failed++;
}
}
async function runTests() {
await new Promise(resolve => {
server = app.listen(PORT, () => {
const addr = server.address();
baseUrl = `http://localhost:${addr.port}`;
resolve();
});
});
console.log('Running idempotency tests...\n');
// Test 1: First request creates an order
console.log('Test 1: First request with idempotency key creates order');
const key1 = uuidv4();
const orderData = {
customerId: 'cust-test-1',
items: [{ productId: 'prod-A', quantity: 2, price: 50 }]
};
const res1 = await makeRequest('POST', '/api/orders', { 'Idempotency-Key': key1 }, orderData);
assert(res1.statusCode === 201, 'First request returns 201 Created');
assert(res1.body.order !== undefined, 'Response contains order object');
assert(res1.body.replayed === false, 'First request is not a replay');
assert(res1.body.order.customerId === 'cust-test-1', 'Order has correct customer ID');
assert(res1.body.order.total === 100, 'Order total is calculated correctly');
const orderId1 = res1.body.order.id;
// Test 2: Duplicate request with same key returns cached response
console.log('\nTest 2: Duplicate request returns cached response (no duplicate creation)');
const res2 = await makeRequest('POST', '/api/orders', { 'Idempotency-Key': key1 }, orderData);
assert(res2.statusCode === 201, 'Duplicate request returns same status code (201)');
assert(res2.body.order.id === orderId1, 'Duplicate returns same order ID');
assert(res2.headers['x-idempotency-replay'] === 'true', 'Replay header is set');
assert(res2.body.replayed === true, 'Response indicates replay');
// Test 3: Verify only one order was created
console.log('\nTest 3: Only one order exists after duplicate requests');
const res3 = await makeRequest('GET', '/api/orders');
assert(res3.statusCode === 200, 'GET orders returns 200');
assert(res3.body.count === 1, 'Only one order in the system');
// Test 4: Different key creates a new order
console.log('\nTest 4: Different idempotency key creates new order');
const key2 = uuidv4();
const res4 = await makeRequest('POST', '/api/orders', { 'Idempotency-Key': key2 }, orderData);
assert(res4.statusCode === 201, 'New key returns 201');
assert(res4.body.order.id !== orderId1, 'New key creates different order ID');
assert(res4.body.replayed === false, 'New key is not a replay');
// Verify we now have 2 orders
const res4b = await makeRequest('GET', '/api/orders');
assert(res4b.body.count === 2, 'Two orders exist after second unique key');
// Test 5: GET requests are not affected by idempotency
console.log('\nTest 5: GET requests are not idempotency-protected');
const res5a = await makeRequest('GET', `/api/orders/${orderId1}`);
const res5b = await makeRequest('GET', `/api/orders/${orderId1}`, { 'Idempotency-Key': key1 });
assert(res5a.statusCode === 200, 'GET returns 200');
assert(!res5b.headers['x-idempotency-replay'], 'GET does not set replay header even with key');
// Test 6: Request without key processes normally
console.log('\nTest 6: Request without idempotency key proceeds normally');
const res6 = await makeRequest('POST', '/api/orders', {}, orderData);
assert(res6.statusCode === 201, 'Request without key returns 201');
assert(!res6.headers['x-idempotency-replay'], 'No replay header on keyless request');
// Test 7: Validation errors are not cached
console.log('\nTest 7: Validation errors are not cached');
const key3 = uuidv4();
const invalidData = { customerId: 'cust-bad' }; // Missing items
const res7a = await makeRequest('POST', '/api/orders', { 'Idempotency-Key': key3 }, invalidData);
assert(res7a.statusCode === 400, 'Invalid request returns 400');
// Same key with valid data should succeed (error wasn't cached)
const res7b = await makeRequest('POST', '/api/orders', { 'Idempotency-Key': key3 }, orderData);
assert(res7b.statusCode === 201, 'Same key with valid data succeeds (error not cached)');
console.log(`\n${'='.repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
console.log(`${'='.repeat(50)}`);
server.close();
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch(err => {
console.error('Test error:', err);
if (server) server.close();
process.exit(1);
});