diff --git a/backend/src/api/middleware/errorHandler.ts b/backend/src/api/middleware/errorHandler.ts index 9e7c6f8..7e1ad9d 100644 --- a/backend/src/api/middleware/errorHandler.ts +++ b/backend/src/api/middleware/errorHandler.ts @@ -3,22 +3,43 @@ import { createLogger } from "../../utils/logger"; const log = createLogger(); -export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void { +const isDevelopment = process.env.NODE_ENV === "development"; + +export function errorHandler( + err: any, + req: Request, + res: Response, + _next: NextFunction, +): void { + const statusCode: number = err.statusCode || 500; + + // Always log the full error server-side for observability log.error( { - error: err.message, - stack: err.stack, - method: req.method, - path: req.path, + err, requestId: res.locals.requestId, + path: req.path, + method: req.method, }, "unhandled error", ); - res.status(500).json({ - error: { - message: err.message || "Internal server error", - code: "INTERNAL_ERROR", - }, - }); + // Build the sanitized response sent to the client + const response: Record = { + statusCode, + path: req.path, + requestId: res.locals.requestId, + }; + + if (isDevelopment) { + // Development: include full details to aid debugging + response.error = err.message; + response.stack = err.stack; + } else { + // Production: generic message — never expose internals + response.error = err.code || "INTERNAL_SERVER_ERROR"; + response.message = "An unexpected error occurred. Please try again later."; + } + + res.status(statusCode).json(response); } diff --git a/backend/tests/errorHandler.test.ts b/backend/tests/errorHandler.test.ts new file mode 100644 index 0000000..4d746fd --- /dev/null +++ b/backend/tests/errorHandler.test.ts @@ -0,0 +1,196 @@ +import { Request, Response, NextFunction } from "express"; + +// Helper to get a fresh module (so NODE_ENV changes take effect) +function freshErrorHandler(nodeEnv: string) { + jest.resetModules(); + process.env.NODE_ENV = nodeEnv; + return require("../src/api/middleware/errorHandler") + .errorHandler as typeof import("../src/api/middleware/errorHandler").errorHandler; +} + +function makeReq(path = "/api/test"): Request { + return { path, method: "GET" } as unknown as Request; +} + +function makeRes(requestId = "req-123"): { + locals: { requestId: string }; + status: jest.Mock; + json: jest.Mock; + _body?: unknown; +} { + const res = { + locals: { requestId }, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res; +} + +// Silence pino output during tests +jest.mock("../src/utils/logger", () => ({ + createLogger: () => ({ + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }), +})); + +afterEach(() => { + delete process.env.NODE_ENV; + jest.resetModules(); +}); + +// ── Production mode ─────────────────────────────────────────────────────────── + +describe("errorHandler — production mode", () => { + it("returns a generic error message (no raw err.message)", () => { + const errorHandler = freshErrorHandler("production"); + const err = new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email"); + const req = makeReq(); + const res = makeRes(); + + errorHandler(err, req, res as unknown as Response, jest.fn() as NextFunction); + + expect(res.status).toHaveBeenCalledWith(500); + const body = res.json.mock.calls[0][0]; + expect(body.error).not.toContain("SQLITE_CONSTRAINT"); + expect(body.error).not.toBe(err.message); + expect(body.message).toBe( + "An unexpected error occurred. Please try again later.", + ); + }); + + it("does not include a stack trace", () => { + const errorHandler = freshErrorHandler("production"); + const err = new Error("something internal"); + err.stack = "Error: something internal\n at /app/src/services/foo.ts:42"; + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + const body = res.json.mock.calls[0][0]; + expect(body.stack).toBeUndefined(); + }); + + it("uses err.code when present", () => { + const errorHandler = freshErrorHandler("production"); + const err: any = new Error("db is down"); + err.code = "DB_UNAVAILABLE"; + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + const body = res.json.mock.calls[0][0]; + expect(body.error).toBe("DB_UNAVAILABLE"); + }); + + it("falls back to INTERNAL_SERVER_ERROR when err.code is absent", () => { + const errorHandler = freshErrorHandler("production"); + const err = new Error("some unknown failure"); + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + const body = res.json.mock.calls[0][0]; + expect(body.error).toBe("INTERNAL_SERVER_ERROR"); + }); + + it("respects err.statusCode", () => { + const errorHandler = freshErrorHandler("production"); + const err: any = new Error("not found"); + err.statusCode = 404; + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("always includes requestId in the response", () => { + const errorHandler = freshErrorHandler("production"); + const res = makeRes("abc-999"); + + errorHandler( + new Error("oops"), + makeReq(), + res as unknown as Response, + jest.fn() as NextFunction, + ); + + const body = res.json.mock.calls[0][0]; + expect(body.requestId).toBe("abc-999"); + }); + + it("always includes path in the response", () => { + const errorHandler = freshErrorHandler("production"); + const res = makeRes(); + + errorHandler( + new Error("oops"), + makeReq("/api/agents"), + res as unknown as Response, + jest.fn() as NextFunction, + ); + + const body = res.json.mock.calls[0][0]; + expect(body.path).toBe("/api/agents"); + }); +}); + +// ── Development mode ────────────────────────────────────────────────────────── + +describe("errorHandler — development mode", () => { + it("includes the raw error message", () => { + const errorHandler = freshErrorHandler("development"); + const err = new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed"); + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + const body = res.json.mock.calls[0][0]; + expect(body.error).toBe(err.message); + }); + + it("includes the stack trace", () => { + const errorHandler = freshErrorHandler("development"); + const err = new Error("boom"); + err.stack = "Error: boom\n at Object. (/app/src/foo.ts:10:3)"; + const res = makeRes(); + + errorHandler(err, makeReq(), res as unknown as Response, jest.fn() as NextFunction); + + const body = res.json.mock.calls[0][0]; + expect(body.stack).toBe(err.stack); + }); + + it("does not include a generic message field", () => { + const errorHandler = freshErrorHandler("development"); + const res = makeRes(); + + errorHandler( + new Error("debug detail"), + makeReq(), + res as unknown as Response, + jest.fn() as NextFunction, + ); + + const body = res.json.mock.calls[0][0]; + expect(body.message).toBeUndefined(); + }); + + it("always includes requestId in the response", () => { + const errorHandler = freshErrorHandler("development"); + const res = makeRes("dev-req-42"); + + errorHandler( + new Error("oops"), + makeReq(), + res as unknown as Response, + jest.fn() as NextFunction, + ); + + const body = res.json.mock.calls[0][0]; + expect(body.requestId).toBe("dev-req-42"); + }); +}); diff --git a/backend/tests/stats.test.ts b/backend/tests/stats.test.ts new file mode 100644 index 0000000..670b571 --- /dev/null +++ b/backend/tests/stats.test.ts @@ -0,0 +1,173 @@ +/** + * Integration test for GET /api/stats + * + * Verifies that the stats router is correctly mounted in the Express app at + * /api/stats and returns a valid JSON payload instead of a 404. + * + * Uses a lightweight Express app (mirroring the agents.test.ts pattern) so + * each test case gets a fresh StatsCache instance and a clean database state. + * + * Note: db/stats.ts uses SQLite (better-sqlite3) syntax throughout, so these + * tests work against an in-memory SQLite database. Full end-to-end correctness + * depends on issue #164 (SQL dialect fix). + */ + +import express from "express"; +import request from "supertest"; +import Database from "better-sqlite3"; +import { createStatsRouter } from "../src/api/routes/stats"; + +/** Create an isolated in-memory database with all tables the stats queries need. */ +function createTestDb(): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + "createdAt" TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + amount REAL NOT NULL, + status TEXT NOT NULL, + "createdAt" TEXT NOT NULL + ); + `); + return db; +} + +/** Minimal Express app that mounts createStatsRouter at /api/stats. */ +function createTestApp(db: Database.Database) { + const app = express(); + app.use(express.json()); + app.use("/api/stats", createStatsRouter(db)); + return app; +} + +describe("GET /api/stats", () => { + it("returns 200 with the expected stats shape on an empty database", async () => { + const db = createTestDb(); + const app = createTestApp(db); + + const res = await request(app).get("/api/stats"); + + expect(res.status).toBe(200); + + // All fields must be present. + expect(typeof res.body.totalAgents).toBe("number"); + expect(typeof res.body.totalTasks).toBe("number"); + expect(typeof res.body.uptimePercent).toBe("number"); + expect(typeof res.body.totalXLMTransacted).toBe("number"); + expect(Array.isArray(res.body.tasksLast24h)).toBe(true); + expect(Array.isArray(res.body.xlmLast24h)).toBe(true); + + // 24 hourly buckets for each time-series. + expect(res.body.tasksLast24h).toHaveLength(24); + expect(res.body.xlmLast24h).toHaveLength(24); + + // Empty database → zeroed totals and 100 % uptime (no tasks to fail). + expect(res.body.totalAgents).toBe(0); + expect(res.body.totalTasks).toBe(0); + expect(res.body.uptimePercent).toBe(100); + expect(res.body.totalXLMTransacted).toBe(0); + + db.close(); + }); + + it("reflects inserted data correctly", async () => { + const db = createTestDb(); + + db.exec(` + INSERT INTO agents (id) VALUES ('agent-1'), ('agent-2'); + + INSERT INTO tasks (id, status, "createdAt") VALUES + ('t1', 'completed', datetime('now', '-1 hour')), + ('t2', 'completed', datetime('now', '-1 hour')), + ('t3', 'failed', datetime('now', '-1 hour')); + + INSERT INTO payments (id, amount, status, "createdAt") VALUES + ('p1', 50000000, 'released', datetime('now', '-1 hour')); + `); + + const app = createTestApp(db); + const res = await request(app).get("/api/stats"); + + expect(res.status).toBe(200); + expect(res.body.totalAgents).toBe(2); + expect(res.body.totalTasks).toBe(3); + // 2 out of 3 tasks completed → ~66.66667 % + expect(res.body.uptimePercent).toBeCloseTo(66.6667, 3); + // 50_000_000 stroops / 1e7 = 5 XLM + expect(res.body.totalXLMTransacted).toBe(5); + + db.close(); + }); + + it("returns 500 when the database throws", async () => { + const db = createTestDb(); + // Close the db to force an error on any subsequent query. + db.close(); + + const app = createTestApp(db); + const res = await request(app).get("/api/stats"); + + expect(res.status).toBe(500); + expect(res.body).toHaveProperty("error"); + }); + + it("is wired in the full Express app at /api/stats (not a 404)", async () => { + // Verify the router is correctly mounted inside createApp by importing the + // factory and checking the stats path directly. + const Database = require("better-sqlite3"); + const { createApp } = require("../src/api"); + + const inMemoryDb = new Database(":memory:"); + inMemoryDb.exec(` + CREATE TABLE IF NOT EXISTS agents (id TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, status TEXT NOT NULL, "createdAt" TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, amount REAL NOT NULL, + status TEXT NOT NULL, "createdAt" TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskId TEXT NOT NULL, + type TEXT NOT NULL, + nodeId TEXT, + payload TEXT, + timestamp TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS tasks_main ( + id TEXT PRIMARY KEY, + prompt TEXT NOT NULL, + walletPublicKey TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'queued', + dagJson TEXT NOT NULL DEFAULT '[]', + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + `); + + const getTaskDbSpy = jest + .spyOn(require("../src/db/tasks"), "getTaskDb") + .mockReturnValue(inMemoryDb); + + const app = createApp(); + + const res = await request(app.httpServer).get("/api/stats"); + + expect(res.status).toBe(200); + expect(typeof res.body.totalAgents).toBe("number"); + + app.close(); + inMemoryDb.close(); + getTaskDbSpy.mockRestore(); + }); +});