feat(backend): add foundation services and sql idempotency
This commit is contained in:
13
backend/command-api/Dockerfile
Normal file
13
backend/command-api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
3035
backend/command-api/package-lock.json
generated
Normal file
3035
backend/command-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backend/command-api/package.json
Normal file
25
backend/command-api/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@krow/command-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test",
|
||||
"migrate:idempotency": "node scripts/migrate-idempotency.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^13.0.2",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
29
backend/command-api/scripts/migrate-idempotency.mjs
Normal file
29
backend/command-api/scripts/migrate-idempotency.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('IDEMPOTENCY_DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url)));
|
||||
const migrationPath = resolve(scriptDir, '../sql/001_command_idempotency.sql');
|
||||
const sql = readFileSync(migrationPath, 'utf8');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
|
||||
});
|
||||
|
||||
try {
|
||||
await pool.query(sql);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Idempotency migration applied successfully');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
13
backend/command-api/sql/001_command_idempotency.sql
Normal file
13
backend/command-api/sql/001_command_idempotency.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS command_idempotency (
|
||||
composite_key TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
route TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at
|
||||
ON command_idempotency (expires_at);
|
||||
30
backend/command-api/src/app.js
Normal file
30
backend/command-api/src/app.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createCommandsRouter } from './routes/commands.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
customProps: (req) => ({ requestId: req.requestId }),
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use('/commands', createCommandsRouter());
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const commandBaseSchema = z.object({
|
||||
payload: z.record(z.any()).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
26
backend/command-api/src/lib/errors.js
Normal file
26
backend/command-api/src/lib/errors.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export class AppError extends Error {
|
||||
constructor(code, message, status = 400, details = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function toErrorEnvelope(error, requestId) {
|
||||
const status = error?.status && Number.isInteger(error.status) ? error.status : 500;
|
||||
const code = error?.code || 'INTERNAL_ERROR';
|
||||
const message = error?.message || 'Unexpected error';
|
||||
const details = error?.details || {};
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
45
backend/command-api/src/middleware/auth.js
Normal file
45
backend/command-api/src/middleware/auth.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { can } from '../services/policy.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
function getBearerToken(header) {
|
||||
if (!header) return null;
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function requireAuth(req, _res, next) {
|
||||
try {
|
||||
const token = getBearerToken(req.get('Authorization'));
|
||||
if (!token) {
|
||||
throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401);
|
||||
}
|
||||
|
||||
if (process.env.AUTH_BYPASS === 'true') {
|
||||
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = await verifyFirebaseToken(token);
|
||||
req.actor = {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
role: decoded.role || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) return next(error);
|
||||
return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401));
|
||||
}
|
||||
}
|
||||
|
||||
export function requirePolicy(action, resource) {
|
||||
return (req, _res, next) => {
|
||||
if (!can(action, resource, req.actor)) {
|
||||
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
25
backend/command-api/src/middleware/error-handler.js
Normal file
25
backend/command-api/src/middleware/error-handler.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { toErrorEnvelope } from '../lib/errors.js';
|
||||
|
||||
export function notFoundHandler(req, res) {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Route not found: ${req.method} ${req.path}`,
|
||||
details: {},
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorHandler(error, req, res, _next) {
|
||||
const envelope = toErrorEnvelope(error, req.requestId);
|
||||
if (req.log) {
|
||||
req.log.error(
|
||||
{
|
||||
errCode: envelope.body.code,
|
||||
status: envelope.status,
|
||||
details: envelope.body.details,
|
||||
},
|
||||
envelope.body.message
|
||||
);
|
||||
}
|
||||
res.status(envelope.status).json(envelope.body);
|
||||
}
|
||||
10
backend/command-api/src/middleware/idempotency.js
Normal file
10
backend/command-api/src/middleware/idempotency.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
|
||||
export function requireIdempotencyKey(req, _res, next) {
|
||||
const idempotencyKey = req.get('Idempotency-Key');
|
||||
if (!idempotencyKey) {
|
||||
return next(new AppError('MISSING_IDEMPOTENCY_KEY', 'Missing Idempotency-Key header', 400));
|
||||
}
|
||||
req.idempotencyKey = idempotencyKey;
|
||||
return next();
|
||||
}
|
||||
9
backend/command-api/src/middleware/request-context.js
Normal file
9
backend/command-api/src/middleware/request-context.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function requestContext(req, res, next) {
|
||||
const incoming = req.get('X-Request-Id');
|
||||
req.requestId = incoming || randomUUID();
|
||||
res.setHeader('X-Request-Id', req.requestId);
|
||||
res.locals.startedAt = Date.now();
|
||||
next();
|
||||
}
|
||||
113
backend/command-api/src/routes/commands.js
Normal file
113
backend/command-api/src/routes/commands.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Router } from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { requireAuth, requirePolicy } from '../middleware/auth.js';
|
||||
import { requireIdempotencyKey } from '../middleware/idempotency.js';
|
||||
import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js';
|
||||
import { commandBaseSchema } from '../contracts/commands/command-base.js';
|
||||
|
||||
function parseBody(body) {
|
||||
const parsed = commandBaseSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function createCommandResponse(route, requestId, idempotencyKey) {
|
||||
return {
|
||||
accepted: true,
|
||||
route,
|
||||
commandId: `${route}:${Date.now()}`,
|
||||
idempotencyKey,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCommandHandler(policyAction, policyResource) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
parseBody(req.body);
|
||||
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
});
|
||||
|
||||
const existing = await readIdempotentResult(compositeKey);
|
||||
if (existing) {
|
||||
return res.status(existing.statusCode).json(existing.payload);
|
||||
}
|
||||
|
||||
const payload = createCommandResponse(route, req.requestId, req.idempotencyKey);
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCommandsRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/orders/create',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.create', 'order'),
|
||||
buildCommandHandler('orders.create', 'order')
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/update',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.update', 'order'),
|
||||
buildCommandHandler('orders.update', 'order')
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/orders/:orderId/cancel',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.cancel', 'order'),
|
||||
buildCommandHandler('orders.cancel', 'order')
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/shifts/:shiftId/change-status',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.change-status', 'shift'),
|
||||
buildCommandHandler('shifts.change-status', 'shift')
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/shifts/:shiftId/assign-staff',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.assign-staff', 'shift'),
|
||||
buildCommandHandler('shifts.assign-staff', 'shift')
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/shifts/:shiftId/accept',
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.accept', 'shift'),
|
||||
buildCommandHandler('shifts.accept', 'shift')
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
12
backend/command-api/src/routes/health.js
Normal file
12
backend/command-api/src/routes/health.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get('/healthz', (req, res) => {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: 'krow-command-api',
|
||||
version: process.env.SERVICE_VERSION || 'dev',
|
||||
requestId: req.requestId,
|
||||
});
|
||||
});
|
||||
9
backend/command-api/src/server.js
Normal file
9
backend/command-api/src/server.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8080);
|
||||
const app = createApp();
|
||||
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`krow-command-api listening on port ${port}`);
|
||||
});
|
||||
13
backend/command-api/src/services/firebase-auth.js
Normal file
13
backend/command-api/src/services/firebase-auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
function ensureAdminApp() {
|
||||
if (getApps().length === 0) {
|
||||
initializeApp({ credential: applicationDefault() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyFirebaseToken(token) {
|
||||
ensureAdminApp();
|
||||
return getAuth().verifyIdToken(token);
|
||||
}
|
||||
208
backend/command-api/src/services/idempotency-store.js
Normal file
208
backend/command-api/src/services/idempotency-store.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10);
|
||||
const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10);
|
||||
|
||||
const memoryStore = new Map();
|
||||
let adapterPromise = null;
|
||||
|
||||
function shouldUseSqlStore() {
|
||||
const mode = (process.env.IDEMPOTENCY_STORE || '').toLowerCase();
|
||||
if (mode === 'memory') {
|
||||
return false;
|
||||
}
|
||||
if (mode === 'sql') {
|
||||
return true;
|
||||
}
|
||||
return Boolean(process.env.IDEMPOTENCY_DATABASE_URL);
|
||||
}
|
||||
|
||||
function gcExpiredMemoryRecords(now = Date.now()) {
|
||||
for (const [key, value] of memoryStore.entries()) {
|
||||
if (value.expiresAt <= now) {
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMemoryAdapter() {
|
||||
return {
|
||||
async read(compositeKey) {
|
||||
gcExpiredMemoryRecords();
|
||||
return memoryStore.get(compositeKey) || null;
|
||||
},
|
||||
async write({
|
||||
compositeKey,
|
||||
payload,
|
||||
statusCode = 200,
|
||||
}) {
|
||||
const now = Date.now();
|
||||
const existing = memoryStore.get(compositeKey);
|
||||
if (existing && existing.expiresAt > now) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const record = {
|
||||
payload,
|
||||
statusCode,
|
||||
createdAt: now,
|
||||
expiresAt: now + (DEFAULT_TTL_SECONDS * 1000),
|
||||
};
|
||||
memoryStore.set(compositeKey, record);
|
||||
return record;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createSqlAdapter() {
|
||||
const connectionString = process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store');
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10),
|
||||
});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS command_idempotency (
|
||||
composite_key TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
route TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at
|
||||
ON command_idempotency (expires_at);
|
||||
`);
|
||||
|
||||
let opCount = 0;
|
||||
|
||||
async function maybeCleanupExpiredRows() {
|
||||
opCount += 1;
|
||||
if (CLEANUP_EVERY_OPS <= 0 || opCount % CLEANUP_EVERY_OPS !== 0) {
|
||||
return;
|
||||
}
|
||||
await pool.query('DELETE FROM command_idempotency WHERE expires_at <= NOW()');
|
||||
}
|
||||
|
||||
function mapRow(row) {
|
||||
return {
|
||||
statusCode: row.status_code,
|
||||
payload: row.response_payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async read(compositeKey) {
|
||||
await maybeCleanupExpiredRows();
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT status_code, response_payload
|
||||
FROM command_idempotency
|
||||
WHERE composite_key = $1
|
||||
AND expires_at > NOW()
|
||||
`,
|
||||
[compositeKey]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return mapRow(result.rows[0]);
|
||||
},
|
||||
async write({
|
||||
compositeKey,
|
||||
userId,
|
||||
route,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
statusCode = 200,
|
||||
}) {
|
||||
await maybeCleanupExpiredRows();
|
||||
|
||||
const expiresAt = new Date(Date.now() + (DEFAULT_TTL_SECONDS * 1000));
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO command_idempotency (
|
||||
composite_key,
|
||||
user_id,
|
||||
route,
|
||||
idempotency_key,
|
||||
status_code,
|
||||
response_payload,
|
||||
expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||
ON CONFLICT (composite_key) DO NOTHING
|
||||
`,
|
||||
[compositeKey, userId, route, idempotencyKey, statusCode, payloadJson, expiresAt]
|
||||
);
|
||||
|
||||
const existingResult = await pool.query(
|
||||
`
|
||||
SELECT status_code, response_payload
|
||||
FROM command_idempotency
|
||||
WHERE composite_key = $1
|
||||
AND expires_at > NOW()
|
||||
`,
|
||||
[compositeKey]
|
||||
);
|
||||
|
||||
if (existingResult.rowCount === 0) {
|
||||
throw new Error('Idempotency write failed to persist or recover existing record');
|
||||
}
|
||||
return mapRow(existingResult.rows[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getAdapter() {
|
||||
if (!adapterPromise) {
|
||||
adapterPromise = shouldUseSqlStore()
|
||||
? createSqlAdapter()
|
||||
: Promise.resolve(createMemoryAdapter());
|
||||
}
|
||||
return adapterPromise;
|
||||
}
|
||||
|
||||
export function buildIdempotencyKey({ userId, route, idempotencyKey }) {
|
||||
return `${userId}:${route}:${idempotencyKey}`;
|
||||
}
|
||||
|
||||
export async function readIdempotentResult(compositeKey) {
|
||||
const adapter = await getAdapter();
|
||||
return adapter.read(compositeKey);
|
||||
}
|
||||
|
||||
export async function writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId,
|
||||
route,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
statusCode = 200,
|
||||
}) {
|
||||
const adapter = await getAdapter();
|
||||
return adapter.write({
|
||||
compositeKey,
|
||||
userId,
|
||||
route,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
export function __resetIdempotencyStoreForTests() {
|
||||
memoryStore.clear();
|
||||
adapterPromise = null;
|
||||
}
|
||||
5
backend/command-api/src/services/policy.js
Normal file
5
backend/command-api/src/services/policy.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export function can(action, resource, actor) {
|
||||
void action;
|
||||
void resource;
|
||||
return Boolean(actor?.uid);
|
||||
}
|
||||
54
backend/command-api/test/app.test.js
Normal file
54
backend/command-api/test/app.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import test, { beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js';
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
__resetIdempotencyStoreForTests();
|
||||
});
|
||||
|
||||
test('GET /healthz returns healthy response', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app).get('/healthz');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.ok, true);
|
||||
assert.equal(typeof res.body.requestId, 'string');
|
||||
});
|
||||
|
||||
test('command route requires idempotency key', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({ payload: {} });
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY');
|
||||
});
|
||||
|
||||
test('command route is idempotent by key', async () => {
|
||||
const app = createApp();
|
||||
|
||||
const first = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'abc-123')
|
||||
.send({ payload: { order: 'x' } });
|
||||
|
||||
const second = await request(app)
|
||||
.post('/commands/orders/create')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('Idempotency-Key', 'abc-123')
|
||||
.send({ payload: { order: 'x' } });
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(first.body.commandId, second.body.commandId);
|
||||
assert.equal(first.body.idempotencyKey, 'abc-123');
|
||||
});
|
||||
56
backend/command-api/test/idempotency-store.test.js
Normal file
56
backend/command-api/test/idempotency-store.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import test, { beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
__resetIdempotencyStoreForTests,
|
||||
buildIdempotencyKey,
|
||||
readIdempotentResult,
|
||||
writeIdempotentResult,
|
||||
} from '../src/services/idempotency-store.js';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.IDEMPOTENCY_STORE = 'memory';
|
||||
delete process.env.IDEMPOTENCY_DATABASE_URL;
|
||||
__resetIdempotencyStoreForTests();
|
||||
});
|
||||
|
||||
test('buildIdempotencyKey composes user route and client key', () => {
|
||||
const key = buildIdempotencyKey({
|
||||
userId: 'user-1',
|
||||
route: '/commands/orders/create',
|
||||
idempotencyKey: 'req-abc',
|
||||
});
|
||||
|
||||
assert.equal(key, 'user-1:/commands/orders/create:req-abc');
|
||||
});
|
||||
|
||||
test('memory idempotency store returns existing payload for duplicate key', async () => {
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: 'user-1',
|
||||
route: '/commands/orders/create',
|
||||
idempotencyKey: 'req-abc',
|
||||
});
|
||||
|
||||
const first = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: 'user-1',
|
||||
route: '/commands/orders/create',
|
||||
idempotencyKey: 'req-abc',
|
||||
payload: { accepted: true, commandId: 'c-1' },
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
const second = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: 'user-1',
|
||||
route: '/commands/orders/create',
|
||||
idempotencyKey: 'req-abc',
|
||||
payload: { accepted: true, commandId: 'c-2' },
|
||||
statusCode: 200,
|
||||
});
|
||||
|
||||
const read = await readIdempotentResult(compositeKey);
|
||||
|
||||
assert.equal(first.payload.commandId, 'c-1');
|
||||
assert.equal(second.payload.commandId, 'c-1');
|
||||
assert.equal(read.payload.commandId, 'c-1');
|
||||
});
|
||||
Reference in New Issue
Block a user