From 962bae6c5415a003a6d224c7ce94c8d693d364e5 Mon Sep 17 00:00:00 2001 From: devoclan Date: Mon, 29 Jun 2026 16:44:31 +0100 Subject: [PATCH 1/4] feat: add in-memory indexed store with binary-search range queries to TransactionsService Replaces on-demand full-scan filtering with a TTL-cached index that supports O(log n) amount range lookups and hash/proposal-based direct access. --- .../transactions/transactions.service.ts | 204 +++++++++++++----- 1 file changed, 152 insertions(+), 52 deletions(-) diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts index 78e74a4d..dc8768ce 100644 --- a/backend/src/modules/transactions/transactions.service.ts +++ b/backend/src/modules/transactions/transactions.service.ts @@ -2,6 +2,7 @@ * TransactionsService * * Provides executed proposal transactions indexed from proposal activity persistence. + * Uses an in-memory indexed store with sorted amount index for fast range queries. */ import { ProposalActivityType } from "../proposals/types.js"; @@ -13,7 +14,74 @@ import type { } from "./transactions.types.js"; import { decodeMemo } from "../../shared/utils/memo.js"; +interface IndexedEntry { + tx: Transaction; + amount: number; + timestampMs: number; +} + +class TransactionIndex { + private byContract = new Map(); + private byHash = new Map(); + private byProposal = new Map(); + private sortedByAmount = new Map(); + + clear(contractId: string): void { + this.byContract.delete(contractId); + this.sortedByAmount.delete(contractId); + } + + addAll(contractId: string, entries: IndexedEntry[]): void { + this.byContract.set(contractId, entries); + for (const entry of entries) { + this.byHash.set(`${contractId}:${entry.tx.transactionHash}`, entry); + const key = `${contractId}:${entry.tx.proposalId}`; + const arr = this.byProposal.get(key) ?? []; + arr.push(entry); + this.byProposal.set(key, arr); + } + const sorted = [...entries].sort((a, b) => a.amount - b.amount); + this.sortedByAmount.set(contractId, sorted); + } + + getByContract(contractId: string): IndexedEntry[] | undefined { + return this.byContract.get(contractId); + } + + getByHash(contractId: string, hash: string): IndexedEntry | undefined { + return this.byHash.get(`${contractId}:${hash}`); + } + + getByProposal(contractId: string, proposalId: string): IndexedEntry[] | undefined { + return this.byProposal.get(`${contractId}:${proposalId}`); + } + + rangeByAmount(contractId: string, min: number, max: number): IndexedEntry[] { + const sorted = this.sortedByAmount.get(contractId); + if (!sorted) return []; + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (sorted[mid].amount < min) lo = mid + 1; + else hi = mid; + } + const start = lo; + hi = sorted.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (sorted[mid].amount <= max) lo = mid + 1; + else hi = mid; + } + return sorted.slice(start, lo); + } +} + export class TransactionsService { + private readonly index = new TransactionIndex(); + private readonly indexTTL = 30_000; + private readonly indexTimestamps = new Map(); + constructor( private readonly persistence: ProposalActivityPersistence, private readonly horizonUrl?: string, @@ -31,20 +99,20 @@ export class TransactionsService { return typeof value === "string" ? value : ""; } - /** - * Returns paginated executed transactions for a contract with optional filters. - */ - async getTransactions( - params: GetTransactionsParams, - ): Promise { - const allRecords = await this.persistence.getByContractId( - params.contractId, - ); - const executed = allRecords + private async ensureIndex(contractId: string): Promise { + const now = Date.now(); + const lastBuilt = this.indexTimestamps.get(contractId) ?? 0; + const cached = this.index.getByContract(contractId); + if (cached && now - lastBuilt < this.indexTTL) { + return cached; + } + + const allRecords = await this.persistence.getByContractId(contractId); + const entries: IndexedEntry[] = allRecords .filter((record) => record.type === ProposalActivityType.EXECUTED) - .map((record): Transaction => { + .map((record): IndexedEntry => { const data = record.data ?? {}; - const base: Transaction = { + const tx: Transaction = { proposalId: record.proposalId, contractId: record.metadata.contractId, transactionHash: record.metadata.transactionHash, @@ -56,60 +124,82 @@ export class TransactionsService { amount: TransactionsService.readDataString(data, "amount"), }; - // best-effort: try to decode memo if horizonUrl provided if (this.horizonUrl && record.metadata.transactionHash) { - void this.attachMemoInfo(record.metadata.transactionHash, base).catch( + void this.attachMemoInfo(record.metadata.transactionHash, tx).catch( () => {}, ); } - return base; + return { + tx, + amount: parseFloat(tx.amount) || 0, + timestampMs: new Date(tx.timestamp).getTime() || 0, + }; + }); + + this.index.clear(contractId); + this.index.addAll(contractId, entries); + this.indexTimestamps.set(contractId, now); + return entries; + } + + async getTransactions( + params: GetTransactionsParams, + ): Promise { + const allEntries = await this.ensureIndex(params.contractId); + + const hasAmountRange = + params.minAmount !== undefined && params.maxAmount !== undefined; + let entries: IndexedEntry[]; + + if (hasAmountRange) { + const rangeResults = this.index.rangeByAmount( + params.contractId, + params.minAmount!, + params.maxAmount!, + ); + entries = rangeResults; + } else { + entries = allEntries; + } + + const executed = entries + .filter((e) => { + if (params.minAmount !== undefined && !hasAmountRange && e.amount < params.minAmount) return false; + if (params.maxAmount !== undefined && !hasAmountRange && e.amount > params.maxAmount) return false; + return true; }) - .filter((tx) => (params.token ? tx.token === params.token : true)) - .filter((tx) => - params.recipient ? tx.recipient === params.recipient : true, + .filter((e) => (params.token ? e.tx.token === params.token : true)) + .filter((e) => + params.recipient ? e.tx.recipient === params.recipient : true, ) - // Filter by date range using timestamp field - .filter((tx) => { + .filter((e) => { if (!params.from && !params.to) return true; - const txDate = new Date(tx.timestamp); - if (isNaN(txDate.getTime())) return false; - - if (params.from && txDate < params.from) return false; - if (params.to && txDate > params.to) return false; - return true; - }) - // Filter by amount range - .filter((tx) => { - if (params.minAmount === undefined && params.maxAmount === undefined) return true; - const amount = parseFloat(tx.amount); - if (isNaN(amount)) return false; - - if (params.minAmount !== undefined && amount < params.minAmount) return false; - if (params.maxAmount !== undefined && amount > params.maxAmount) return false; + if (isNaN(e.timestampMs)) return false; + if (params.from && e.timestampMs < params.from.getTime()) return false; + if (params.to && e.timestampMs > params.to.getTime()) return false; return true; }) - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + .sort((a, b) => b.timestampMs - a.timestampMs) + .map((e) => e.tx); - // Apply cursor-based pagination let startIndex = 0; let endIndex = executed.length; - + if (params.cursor) { - // Find the index of the cursor item const cursorIndex = executed.findIndex(tx => tx.transactionHash === params.cursor); if (cursorIndex !== -1) { startIndex = cursorIndex + 1; } } - + const limit = params.limit ?? 20; - const maxLimit = Math.min(limit, 200); // Cap at 200 per page + const maxLimit = Math.min(limit, 200); endIndex = Math.min(startIndex + maxLimit, executed.length); - + const data = executed.slice(startIndex, endIndex); const nextCursor = endIndex < executed.length ? executed[endIndex]?.transactionHash : null; - + return { data, nextCursor, @@ -117,9 +207,6 @@ export class TransactionsService { }; } - /** - * Returns all transactions linked to a proposal via memo decoding. - */ async getTransactionsByProposal( proposalId: string, contractId: string, @@ -129,12 +216,22 @@ export class TransactionsService { }, ): Promise { const key = `proposal_txns:${contractId}:${proposalId}`; - const ttl = 5 * 60 * 1000; // 5 minutes + const ttl = 5 * 60 * 1000; if (cache) { const cached = cache.get(key); if (cached) return cached; } + await this.ensureIndex(contractId); + const indexed = this.index.getByProposal(contractId, proposalId); + if (indexed) { + const txs = indexed + .map((e) => e.tx) + .sort((a, b) => b.ledger - a.ledger); + if (cache) cache.set(key, txs, ttl); + return txs; + } + const records = await this.persistence.getByProposalId(proposalId); const txs: Transaction[] = []; for (const record of records) { @@ -163,7 +260,6 @@ export class TransactionsService { txs.push(tx); } - // reverse chronological txs.sort((a, b) => b.ledger - a.ledger); if (cache) cache.set(key, txs, ttl); @@ -183,23 +279,27 @@ export class TransactionsService { (tx as any).decodedProposalId = decoded.decodedProposalId; (tx as any).decodedMemo = decoded.decodedMemo; } catch { - // ignore decoding errors (tx as any).decodedProposalId = null; (tx as any).decodedMemo = null; } } - /** - * Gets a single executed transaction by hash. - */ async getTransactionByHash( contractId: string, txHash: string, ): Promise { + await this.ensureIndex(contractId); + const entry = this.index.getByHash(contractId, txHash); + if (entry) return entry.tx; const result = await this.getTransactions({ contractId, limit: Number.MAX_SAFE_INTEGER, }); return result.data.find((tx) => tx.transactionHash === txHash) ?? null; } + + invalidateIndex(contractId: string): void { + this.indexTimestamps.delete(contractId); + this.index.clear(contractId); + } } From 4b004a1c1589ee11d43bd4e0617a9c162a2dc27d Mon Sep 17 00:00:00 2001 From: devoclan Date: Mon, 29 Jun 2026 17:12:33 +0100 Subject: [PATCH 2/4] feat: add idempotency guard to proposal event consumer Tracks processed event IDs in a bounded set keyed by transactionHash:eventIndex to prevent duplicate processing on replay or restart. --- backend/src/modules/proposals/consumer.ts | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/src/modules/proposals/consumer.ts b/backend/src/modules/proposals/consumer.ts index 559e13b5..0a039fdd 100644 --- a/backend/src/modules/proposals/consumer.ts +++ b/backend/src/modules/proposals/consumer.ts @@ -62,11 +62,16 @@ export class ProposalActivityConsumer { private readonly maxRetries: number; private readonly initialBackoffMs: number; + // Idempotency: track processed event IDs to prevent duplicate processing + private readonly processedEventIds = new Set(); + private readonly maxDedupeSize: number; + constructor(options?: { batchSize?: number; flushIntervalMs?: number; maxRetries?: number; initialBackoffMs?: number; + maxDedupeSize?: number; metricsRegistry?: MetricsRegistry; notificationQueue?: NotificationPublisher; /** Broadcast hook — called synchronously after each record is produced. */ @@ -77,11 +82,33 @@ export class ProposalActivityConsumer { options?.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; this.maxRetries = options?.maxRetries ?? 5; this.initialBackoffMs = options?.initialBackoffMs ?? 1000; + this.maxDedupeSize = options?.maxDedupeSize ?? 100_000; this.metrics = options?.metricsRegistry; this.notificationQueue = options?.notificationQueue; this.onActivity = options?.onActivity; } + private deriveEventKey(event: NormalizedEvent): string { + const meta = event.metadata as any; + if (meta.transactionHash && meta.eventIndex !== undefined) { + return `${meta.transactionHash}:${meta.eventIndex}`; + } + return `${meta.id ?? ""}:${meta.ledger ?? ""}:${event.type}`; + } + + private isDuplicate(event: NormalizedEvent): boolean { + const key = this.deriveEventKey(event); + if (this.processedEventIds.has(key)) { + return true; + } + if (this.processedEventIds.size >= this.maxDedupeSize) { + const first = this.processedEventIds.values().next().value; + if (first !== undefined) this.processedEventIds.delete(first); + } + this.processedEventIds.add(key); + return false; + } + /** * Starts the consumer's periodic flush timer. */ @@ -143,6 +170,11 @@ export class ProposalActivityConsumer { * Processes a single normalized event. */ public async process(event: NormalizedEvent): Promise { + if (this.isDuplicate(event)) { + this.logger.debug(`skipping duplicate event: ${this.deriveEventKey(event)}`); + return; + } + const record = this.toRecord(event); if (!record) { @@ -190,6 +222,10 @@ export class ProposalActivityConsumer { const records: ProposalActivityRecord[] = []; for (const event of events) { + if (this.isDuplicate(event)) { + this.logger.debug(`skipping duplicate event in batch: ${this.deriveEventKey(event)}`); + continue; + } const record = this.toRecord(event); if (record) { records.push(record); From ef5eb2e93959fc9d2bb72f78b85464036425d394 Mon Sep 17 00:00:00 2001 From: devoclan Date: Mon, 29 Jun 2026 17:13:33 +0100 Subject: [PATCH 3/4] feat: add distributed tracing spans, middleware, and RPC/DB trace helpers Adds withSpan wrapper, traceMiddleware for Express routes (propagates trace context and sets X-Trace-Id header), and traceRpcCall/traceDbCall helpers to instrument business logic with OpenTelemetry spans. --- backend/src/shared/tracing.ts | 93 ++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/backend/src/shared/tracing.ts b/backend/src/shared/tracing.ts index a951f563..5565cb56 100644 --- a/backend/src/shared/tracing.ts +++ b/backend/src/shared/tracing.ts @@ -7,7 +7,12 @@ import { DiagConsoleLogger, DiagLogLevel, trace, + SpanStatusCode, + type Span, + context, + propagation, } from "@opentelemetry/api"; +import type { Request, Response, NextFunction } from "express"; let sdk: NodeSDK | null = null; @@ -31,7 +36,6 @@ export function initTracing( sdk.start(); } catch (e) { - // Best-effort: don't crash if tracing fails to initialize console.warn( "tracing failed to initialize", e instanceof Error ? e.message : e, @@ -48,3 +52,90 @@ export function shutdownTracing() { export function getTracer(name = "vaultdao") { return trace.getTracer(name); } + +export async function withSpan( + name: string, + fn: (span: Span) => Promise, + attributes?: Record, +): Promise { + const tracer = getTracer(); + return tracer.startActiveSpan(name, async (span) => { + if (attributes) { + for (const [k, v] of Object.entries(attributes)) { + span.setAttribute(k, v); + } + } + try { + const result = await fn(span); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : String(err), + }); + span.recordException(err instanceof Error ? err : new Error(String(err))); + throw err; + } finally { + span.end(); + } + }); +} + +export function traceMiddleware() { + return (req: Request, res: Response, next: NextFunction) => { + const tracer = getTracer(); + const parentCtx = propagation.extract(context.active(), req.headers); + const span = tracer.startSpan( + `${req.method} ${req.path}`, + { + attributes: { + "http.method": req.method, + "http.url": req.originalUrl, + "http.route": req.path, + "http.user_agent": req.get("user-agent") ?? "", + }, + }, + parentCtx, + ); + + const traceId = span.spanContext().traceId; + res.setHeader("X-Trace-Id", traceId); + + res.on("finish", () => { + span.setAttribute("http.status_code", res.statusCode); + if (res.statusCode >= 400) { + span.setStatus({ code: SpanStatusCode.ERROR }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + span.end(); + }); + + context.with(trace.setSpan(parentCtx, span), () => { + next(); + }); + }; +} + +export function traceRpcCall( + method: string, + fn: () => Promise, +): Promise { + return withSpan(`rpc.${method}`, async (span) => { + span.setAttribute("rpc.method", method); + span.setAttribute("rpc.system", "soroban"); + return fn(); + }); +} + +export function traceDbCall( + operation: string, + fn: () => Promise, +): Promise { + return withSpan(`db.${operation}`, async (span) => { + span.setAttribute("db.operation", operation); + span.setAttribute("db.system", "persistence"); + return fn(); + }); +} From bff2393a881b2326a5b41f08b27687f46e64656a Mon Sep 17 00:00:00 2001 From: devoclan Date: Mon, 29 Jun 2026 17:17:01 +0100 Subject: [PATCH 4/4] feat: add end-to-end integration tests for full request lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests the complete chain: request → middleware (CORS, auth, request ID) → controller → service → persistence → response for transactions, proposals, health, and error handling paths. --- backend/src/e2e.test.ts | 228 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 backend/src/e2e.test.ts diff --git a/backend/src/e2e.test.ts b/backend/src/e2e.test.ts new file mode 100644 index 00000000..cfbe3acd --- /dev/null +++ b/backend/src/e2e.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "./app.js"; +import { Server } from "node:http"; +import { once } from "node:events"; +import { MetricsRegistry } from "./modules/health/metrics.registry.js"; +import { + createMemoryPersistence, + createProposalConsumer, + createProposalAggregator, + ProposalActivityType, +} from "./modules/proposals/index.js"; +import { TransactionsService } from "./modules/transactions/transactions.service.js"; +import type { ProposalActivityRecord } from "./modules/proposals/types.js"; +import { REQUEST_ID_HEADER } from "./shared/http/requestId.js"; +import { randomUUID } from "node:crypto"; + +const mockEnv = { + port: 0, + host: "127.0.0.1", + nodeEnv: "test", + stellarNetwork: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + contractId: "CDTEST", + websocketUrl: "ws://localhost:8080", + eventPollingIntervalMs: 5000, + eventPollingEnabled: true, + corsOrigin: ["*"], + requestBodyLimit: "1mb", + apiKey: "test-api-key", +}; + +function createTestRecord(overrides: Partial = {}): ProposalActivityRecord { + const id = randomUUID(); + return { + activityId: id, + proposalId: overrides.proposalId ?? "proposal-1", + type: overrides.type ?? ProposalActivityType.EXECUTED, + timestamp: overrides.timestamp ?? new Date().toISOString(), + metadata: { + id, + contractId: "CDTEST", + ledger: 1000, + ledgerClosedAt: new Date().toISOString(), + transactionHash: overrides.metadata?.transactionHash ?? `txhash-${id}`, + eventIndex: 0, + ...overrides.metadata, + }, + data: overrides.data ?? { + activityType: ProposalActivityType.EXECUTED, + executor: "GABC", + recipient: "GXYZ", + token: "XLM", + amount: "500", + executionLedger: 1000, + }, + }; +} + +test("E2E Integration: full request lifecycle", async (t) => { + let server: Server; + let baseUrl: string; + const persistence = createMemoryPersistence(); + const aggregator = createProposalAggregator(); + + const metricsRegistry = new MetricsRegistry(); + const runtime = { + startedAt: new Date().toISOString(), + eventPollingService: { + getStatus: () => ({ lastLedgerPolled: 123, isPolling: true, errors: 0 }), + }, + snapshotService: { + getSnapshot: async () => null, + getSigners: async () => [], + getSigner: async () => null, + getRoles: async () => [], + getStats: async () => null, + }, + proposalActivityAggregator: aggregator, + recurringIndexerService: { + getStatus: () => ({ isIndexing: true, lastLedger: 100 }), + }, + jobManager: { + getAllJobs: () => [ + { name: "event-polling", isRunning: () => true }, + { name: "recurring-indexer", isRunning: () => true }, + ], + stopAll: async () => {}, + }, + metricsRegistry, + proposalActivityPersistence: persistence, + get transactionsService() { + return new TransactionsService(this.proposalActivityPersistence); + }, + }; + + t.before(async () => { + const record1 = createTestRecord({ + proposalId: "proposal-1", + metadata: { id: "e1", contractId: "CDTEST", ledger: 1000, ledgerClosedAt: new Date().toISOString(), transactionHash: "txhash-001", eventIndex: 0 }, + data: { activityType: ProposalActivityType.EXECUTED, executor: "GABC", recipient: "GXYZ", token: "XLM", amount: "100", executionLedger: 1000 }, + }); + const record2 = createTestRecord({ + proposalId: "proposal-2", + metadata: { id: "e2", contractId: "CDTEST", ledger: 1001, ledgerClosedAt: new Date().toISOString(), transactionHash: "txhash-002", eventIndex: 0 }, + data: { activityType: ProposalActivityType.EXECUTED, executor: "GABC", recipient: "GDEF", token: "USDC", amount: "500", executionLedger: 1001 }, + }); + const record3 = createTestRecord({ + proposalId: "proposal-3", + type: ProposalActivityType.CREATED, + metadata: { id: "e3", contractId: "CDTEST", ledger: 1002, ledgerClosedAt: new Date().toISOString(), transactionHash: "txhash-003", eventIndex: 0 }, + data: { activityType: ProposalActivityType.CREATED, proposer: "GABC", recipient: "GHIJ", token: "XLM", amount: "200", insuranceAmount: "0", description: "Test" }, + }); + + await persistence.save(record1); + await persistence.save(record2); + await persistence.save(record3); + + aggregator.addRecord(record1); + aggregator.addRecord(record2); + aggregator.addRecord(record3); + + const app = await createApp(mockEnv as any, runtime as any); + server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (typeof address === "object" && address !== null) { + baseUrl = `http://127.0.0.1:${address.port}`; + } + }); + + t.after(() => + new Promise((resolve) => { + if (typeof (server as any).closeAllConnections === "function") { + (server as any).closeAllConnections(); + } + server.close(() => resolve()); + }), + ); + + await t.test("health → middleware → controller → response (full lifecycle)", async () => { + const requestId = "e2e-trace-001"; + const res = await fetch(`${baseUrl}/health`, { + headers: { [REQUEST_ID_HEADER]: requestId }, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get(REQUEST_ID_HEADER), requestId); + const body = (await res.json()) as any; + assert.strictEqual(body.success, true); + assert.strictEqual(body.data.ok, true); + }); + + await t.test("transactions endpoint: request → middleware → controller → service → persistence → response", async () => { + const res = await fetch(`${baseUrl}/api/v1/transactions?contractId=CDTEST`, { + headers: { Authorization: `Bearer ${mockEnv.apiKey}` }, + }); + assert.strictEqual(res.status, 200); + const body = (await res.json()) as any; + assert.strictEqual(body.success, true); + assert.ok(Array.isArray(body.data.data)); + assert.strictEqual(body.data.data.length, 2); + assert.ok(body.data.data.every((tx: any) => tx.contractId === "CDTEST")); + }); + + await t.test("transactions by proposal: full lifecycle through service layer", async () => { + const res = await fetch( + `${baseUrl}/api/v1/transactions/by-proposal/proposal-1?contractId=CDTEST`, + { headers: { Authorization: `Bearer ${mockEnv.apiKey}` } }, + ); + assert.strictEqual(res.status, 200); + const body = (await res.json()) as any; + assert.strictEqual(body.success, true); + assert.ok(Array.isArray(body.data.data)); + assert.ok(body.data.data.length >= 1); + assert.ok(body.data.data.every((tx: any) => tx.proposalId === "proposal-1")); + }); + + await t.test("transaction by hash: full lifecycle with 404 for missing", async () => { + const res = await fetch( + `${baseUrl}/api/v1/transactions/nonexistent-hash?contractId=CDTEST`, + { headers: { Authorization: `Bearer ${mockEnv.apiKey}` } }, + ); + assert.strictEqual(res.status, 404); + const body = (await res.json()) as any; + assert.strictEqual(body.success, false); + assert.strictEqual(body.error.code, "NOT_FOUND"); + }); + + await t.test("proposals stats: request → auth middleware → controller → aggregator → response", async () => { + const res = await fetch(`${baseUrl}/api/v1/proposals/stats`, { + headers: { Authorization: `Bearer ${mockEnv.apiKey}` }, + }); + assert.strictEqual(res.status, 200); + const body = (await res.json()) as any; + assert.strictEqual(body.success, true); + assert.ok(typeof body.data.totalProposals === "number"); + assert.ok(body.data.totalProposals >= 3); + }); + + await t.test("auth middleware rejects unauthenticated requests to protected routes", async () => { + const res = await fetch(`${baseUrl}/api/v1/transactions`); + assert.strictEqual(res.status, 401); + const body = (await res.json()) as any; + assert.strictEqual(body.success, false); + assert.strictEqual(body.error.code, "UNAUTHORIZED"); + }); + + await t.test("middleware ordering: request ID is set even on error responses", async () => { + const traceId = "e2e-error-trace"; + const res = await fetch(`${baseUrl}/nonexistent-route`, { + headers: { [REQUEST_ID_HEADER]: traceId }, + }); + assert.strictEqual(res.status, 404); + assert.strictEqual(res.headers.get(REQUEST_ID_HEADER), traceId); + const body = (await res.json()) as any; + assert.strictEqual(body.error.requestId, traceId); + }); + + await t.test("CORS → routing → 404 handler chain", async () => { + const res = await fetch(`${baseUrl}/does-not-exist`, { + method: "OPTIONS", + headers: { Origin: "http://test.local" }, + }); + assert.strictEqual(res.status, 204); + assert.ok(res.headers.get("Access-Control-Allow-Origin")); + }); +});