diff --git a/README.md b/README.md index 84fb2d5..8160615 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ npm run test:watch ## Project layout +- `src/index.ts` — App entry, middleware, route mounting +- `src/routes/` — credit and risk route handlers +- `src/services/` — Domain/business logic (kept separate from HTTP concerns) +- `src/db/` — Migration + schema validation tooling +- `docs/` — Documentation and guidelines (see `docs/backend-architecture.md`) ``` src/ index.ts — App entry, middleware, route mounting diff --git a/docs/backend-architecture.md b/docs/backend-architecture.md new file mode 100644 index 0000000..8f04d07 --- /dev/null +++ b/docs/backend-architecture.md @@ -0,0 +1,221 @@ +# Creditra Backend — Architecture & Module Boundaries + +This document describes how the backend is structured today (routes, middleware, services, DB tooling) and the intended boundaries as the codebase grows (repositories, listeners, jobs). + +## Goals + +- Help new contributors find the right place to add code. +- Keep responsibilities clear (HTTP concerns vs business logic vs persistence). +- Make request flow, event flow, and data flow easy to reason about and review. + +--- + +## High-level architecture (layers) + +**Current shape (implemented):** + +1. **HTTP entrypoint** (`src/index.ts`) +2. **Routing** (`src/routes/*`) +3. **Middleware** (`src/middleware/*`) +4. **Services (domain logic)** (`src/services/*`) +5. **DB tooling** (`src/db/*`) + **SQL migrations** (`migrations/*`) + +**Near-term planned shape (when persistence + background work lands):** + +- **Repositories** (data access; likely `src/repositories/*`) +- **Listeners** (external event ingestion; e.g. Stellar Horizon) +- **Jobs** (scheduled/background processing; interest accrual, re-evaluations) + +--- + +## Folder structure (current) + +``` +src/ + index.ts # Express app bootstrap + mount routers + middleware/ + adminAuth.ts # Admin auth for privileged endpoints + routes/ + credit.ts # /api/credit routes (REST) + risk.ts # /api/risk routes (REST) + services/ + creditService.ts # Credit line state transitions (placeholder store) + riskService.ts # Risk evaluation entrypoint (placeholder) + horizonListener.ts # Horizon polling + event dispatch (skeleton) + db/ + client.ts # pg client wrapper (DATABASE_URL) + migrations.ts # migration runner helpers + migrate-cli.ts # CLI: apply pending migrations + validate-schema.ts # schema validation helpers + validate-cli.ts # CLI: migrate + validate schema + __test__/ # API/service/middleware tests (Vitest) + +docs/ + data-model.md + security-checklist-backend.md + backend-architecture.md # (this document) + +migrations/ + *.sql # sequential SQL migrations +``` + +--- + +## Module responsibilities & boundaries + +### 1) `src/index.ts` (app bootstrap) + +**Responsibilities** + +- Create and configure the Express app (CORS, JSON parsing). +- Expose basic health endpoint (`GET /health`). +- Mount routers under stable prefixes (`/api/credit`, `/api/risk`). + +**Should not contain** + +- Business rules (belongs in `src/services/*`). +- Persistence logic (belongs in DB/repositories). + +### 2) `src/routes/*` (HTTP routing + translation layer) + +**Responsibilities** + +- Define endpoint paths and HTTP verbs. +- Validate request shape at the boundary (required body fields, params). +- Call services and translate service errors into HTTP status codes. +- Apply middleware (e.g. `adminAuth`) for privileged operations. + +**Patterns in this repo** + +- Routes call service functions directly (`credit.ts` → `creditService.ts`). +- Domain errors are mapped to status codes: + - `CreditLineNotFoundError` → `404` + - `InvalidTransitionError` → `409` + +**Should not contain** + +- Direct DB calls (use services/repositories). +- Complex business logic (keep routes thin). + +### 3) `src/middleware/*` (cross-cutting HTTP concerns) + +**Responsibilities** + +- Authentication/authorization. +- Request-scoped concerns (rate limits, input sanitization, correlation IDs) as they are added. + +**Current implementation** + +- `adminAuth.ts` enforces `X-Admin-Api-Key` (header `x-admin-api-key`) and returns: + - `503` if `ADMIN_API_KEY` is not configured + - `401` if key is missing/invalid + +### 4) `src/services/*` (domain/business logic) + +**Responsibilities** + +- Encode business rules (state transitions, scoring, invariants). +- Provide a stable API that routes (and later jobs/listeners) can call. +- Throw **domain-specific errors** for expected failure cases. + +**Current notes** + +- `creditService.ts` uses an in-memory store (`_store`) as a placeholder for persistence. +- `riskService.ts` validates a Stellar address shape and returns placeholder results. +- `horizonListener.ts` is a skeleton poller + event dispatcher (simulated events today). + +**Rules of thumb** + +- Services should not depend on Express types (`Request`, `Response`). +- Prefer pure functions and explicit inputs/outputs; keep side effects isolated. + +### 5) `src/db/*` + `migrations/*` (schema and migration tooling) + +**Responsibilities** + +- Manage PostgreSQL connectivity for tooling (`DATABASE_URL`). +- Apply SQL migrations in order and record versions in `schema_migrations`. +- Validate that the expected core tables exist (`EXPECTED_TABLES`). + +**How it’s used** + +- `npm run db:migrate` → `src/db/migrate-cli.ts` +- `npm run db:validate` → `src/db/validate-cli.ts` (migrate then validate) + +--- + +## Interaction patterns + +### Request flow (HTTP) + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant E as Express (src/index.ts) + participant R as Router (src/routes/*) + participant M as Middleware (src/middleware/*) + participant S as Service (src/services/*) + + C->>E: HTTP request + E->>R: Dispatch by path prefix + R->>M: Optional auth/guards + alt Authorized + M->>S: Call service function + S-->>R: Result or domain error + R-->>C: JSON + status code + else Unauthorized / not configured + M-->>C: 401/503 + error JSON + end +``` + +### Event flow (Horizon listener) + +The intended flow is: poll Horizon → decode events → dispatch to handlers → persist → trigger downstream work. + +```mermaid +flowchart LR + H[Horizon API] -->|poll| L[src/services/horizonListener.ts] + L --> D{dispatchEvent()} + D --> H1[handler: persist event] + D --> H2[handler: update credit lines] + D --> H3[handler: enqueue jobs] +``` + +### Data flow (migrations + runtime) + +```mermaid +flowchart TB + subgraph Tooling + CLI1[npm run db:migrate] --> MIG[src/db/migrations.ts] + CLI2[npm run db:validate] --> VAL[src/db/validate-schema.ts] + end + MIG --> PG[(PostgreSQL)] + VAL --> PG + + subgraph Runtime + API[Express API] --> SVC[Services] + SVC -->|future| REPO[Repositories] + REPO --> PG + end +``` + +--- + +## Conventions (TypeScript/Node) + +- This repo is **ESM** (`"type": "module"`). When importing local TS modules, use `.js` extensions (e.g. `import { riskRouter } from "./routes/risk.js"`). +- Keep route handlers thin; enforce business rules in services. +- Prefer domain errors from services and map them to status codes in routes. +- Avoid logging secrets. Never log `ADMIN_API_KEY` or database URLs. + +--- + +## Where to add new code + +- **New endpoint**: add to `src/routes/*` and call into `src/services/*`. +- **New business rule**: add to `src/services/*` (and unit test in `src/__test__/*`). +- **DB-backed operations**: add a repository module (recommended new folder: `src/repositories/*`) and have services call it. +- **Background/scheduled work**: add a `src/jobs/*` module and keep job logic calling the same services used by HTTP routes. +- **New external event ingestion**: extend `src/services/horizonListener.ts` and add handler modules that persist/act on events. + diff --git a/src/__test__/adminAuth.test.ts b/src/__test__/adminAuth.test.ts index 5d226e6..9ed9e1b 100644 --- a/src/__test__/adminAuth.test.ts +++ b/src/__test__/adminAuth.test.ts @@ -1,120 +1,104 @@ import express from "express"; -import request from "supertest"; -import { adminAuth, ADMIN_KEY_HEADER } from "../../middleware/adminAuth.js"; -import { afterEach, afterEach, beforeEach } from "node:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { adminAuth, ADMIN_KEY_HEADER } from "../middleware/adminAuth.js"; +import { requestJson } from "./testHttp.js"; const SECRET = "test-admin-secret-key"; function buildApp() { - const app = express(); - app.use(express.json()); - // A single protected route for testing the middleware - app.post("/protected", adminAuth, (_req, res) => { - res.json({ ok: true }); - }); - return app; + const app = express(); + app.use(express.json()); + app.post("/protected", adminAuth, (_req, res) => { + res.json({ ok: true }); + }); + return app; } let originalKey: string | undefined; - + beforeEach(() => { - originalKey = process.env["ADMIN_API_KEY"]; - process.env["ADMIN_API_KEY"] = SECRET; + originalKey = process.env["ADMIN_API_KEY"]; + process.env["ADMIN_API_KEY"] = SECRET; }); afterEach(() => { - if (originalKey === undefined) { - delete process.env["ADMIN_API_KEY"]; - } else { - process.env["ADMIN_API_KEY"] = originalKey; - } + if (originalKey === undefined) { + delete process.env["ADMIN_API_KEY"]; + } else { + process.env["ADMIN_API_KEY"] = originalKey; + } }); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("adminAuth middleware", () => { - describe("when ADMIN_API_KEY env var is configured", () => { - it("calls next() and returns 200 when the correct key is supplied", async () => { - const res = await request(buildApp()) - .post("/protected") - .set(ADMIN_KEY_HEADER, SECRET); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); - }); - - it("returns 401 when the X-Admin-Api-Key header is missing", async () => { - const res = await request(buildApp()).post("/protected"); - - expect(res.status).toBe(401); - expect(res.body.error).toContain("Unauthorized"); - }); - - it("returns 401 when the header value is wrong", async () => { - const res = await request(buildApp()) - .post("/protected") - .set(ADMIN_KEY_HEADER, "wrong-key"); - - expect(res.status).toBe(401); - expect(res.body.error).toContain("Unauthorized"); - }); - - it("returns 401 when the header is an empty string", async () => { - const res = await request(buildApp()) - .post("/protected") - .set(ADMIN_KEY_HEADER, ""); - - expect(res.status).toBe(401); - }); - - it("returns 401 when the header is close but not equal to the secret", async () => { - const res = await request(buildApp()) - .post("/protected") - .set(ADMIN_KEY_HEADER, SECRET + " "); - - expect(res.status).toBe(401); - }); - - it("response body includes X-Admin-Api-Key in the error hint", async () => { - const res = await request(buildApp()).post("/protected"); - - expect(res.body.error).toMatch(/X-Admin-Api-Key/); - }); - - it("returns JSON content-type on 401", async () => { - const res = await request(buildApp()).post("/protected"); - expect(res.headers["content-type"]).toMatch(/application\/json/); - }); + describe("when ADMIN_API_KEY env var is configured", () => { + it("returns 200 when the correct key is supplied", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + headers: { [ADMIN_KEY_HEADER]: SECRET }, + body: {}, + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + it("returns 401 when the X-Admin-Api-Key header is missing", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + body: {}, + }); + + expect(res.status).toBe(401); + expect((res.body as any).error).toContain("Unauthorized"); + }); + + it("returns 401 when the header value is wrong", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + headers: { [ADMIN_KEY_HEADER]: "wrong-key" }, + body: {}, + }); + + expect(res.status).toBe(401); + expect((res.body as any).error).toContain("Unauthorized"); }); - describe("when ADMIN_API_KEY env var is NOT configured", () => { - beforeEach(() => { - delete process.env["ADMIN_API_KEY"]; - }); - - it("returns 503 regardless of what header is sent", async () => { - const res = await request(buildApp()) - .post("/protected") - .set(ADMIN_KEY_HEADER, "anything"); - - expect(res.status).toBe(503); - }); - - it("returns 503 even without a header", async () => { - const res = await request(buildApp()).post("/protected"); - expect(res.status).toBe(503); - }); - - it("body error mentions admin authentication is not configured", async () => { - const res = await request(buildApp()).post("/protected"); - expect(res.body.error).toMatch(/not configured/i); - }); - - it("returns JSON content-type on 503", async () => { - const res = await request(buildApp()).post("/protected"); - expect(res.headers["content-type"]).toMatch(/application\/json/); - }); + it("returns JSON content-type on 401", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + body: {}, + }); + expect(res.headers["content-type"]).toMatch(/application\/json/); }); -}); \ No newline at end of file + }); + + describe("when ADMIN_API_KEY env var is NOT configured", () => { + beforeEach(() => { + delete process.env["ADMIN_API_KEY"]; + }); + + it("returns 503 regardless of what header is sent", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + headers: { [ADMIN_KEY_HEADER]: "anything" }, + body: {}, + }); + + expect(res.status).toBe(503); + }); + + it("body error mentions admin authentication is not configured", async () => { + const res = await requestJson(buildApp(), { + method: "POST", + path: "/protected", + body: {}, + }); + expect((res.body as any).error).toMatch(/not configured/i); + }); + }); +}); diff --git a/src/__test__/creditRoute.test.ts b/src/__test__/creditRoute.test.ts index 0af7583..f574988 100644 --- a/src/__test__/creditRoute.test.ts +++ b/src/__test__/creditRoute.test.ts @@ -1,20 +1,8 @@ - -import express, { Express } from "express"; -import request from "supertest"; -import { jest } from "@jest/globals"; -import { _resetStore, createCreditLine } from "../../services/creditService.js"; - -// Mock adminAuth so we can control auth pass/fail from within tests -jest.mock("../../middleware/adminAuth.js", () => ({ - adminAuth: jest.fn((_req: unknown, _res: unknown, next: () => void) => next()), - ADMIN_KEY_HEADER: "x-admin-api-key", -})); - -import creditRouter from "../../routes/credit.js"; -import { adminAuth } from "../../middleware/adminAuth.js"; -import { afterEach, beforeEach } from "node:test"; - -const mockAdminAuth = adminAuth as jest.MockedFunction; +import express, { type Express } from "express"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import creditRouter from "../routes/credit.js"; +import { _resetStore, _store, createCreditLine } from "../services/creditService.js"; +import { requestJson } from "./testHttp.js"; function buildApp(): Express { const app = express(); @@ -27,82 +15,72 @@ const VALID_ID = "line-abc"; const MISSING_ID = "does-not-exist"; const ADMIN_KEY = "test-secret"; -function allowAdmin() { - mockAdminAuth.mockImplementation((_req, _res, next) => next()); -} - -function denyAdmin() { - mockAdminAuth.mockImplementation((_req, res: any, _next) => { - res.status(401).json({ error: "Unauthorized: valid X-Admin-Api-Key header is required." }); - }); -} - +let originalKey: string | undefined; beforeEach(() => { + originalKey = process.env["ADMIN_API_KEY"]; + process.env["ADMIN_API_KEY"] = ADMIN_KEY; _resetStore(); - allowAdmin(); // default to allowing admin access, override in specific tests as needed }); afterEach(() => { - mockAdminAuth.mockReset(); + if (originalKey === undefined) delete process.env["ADMIN_API_KEY"]; + else process.env["ADMIN_API_KEY"] = originalKey; }); - describe("GET /api/credit/lines", () => { it("returns 200 with an empty array when store is empty", async () => { - const res = await request(buildApp()).get("/api/credit/lines"); + const res = await requestJson(buildApp(), { method: "GET", path: "/api/credit/lines" }); expect(res.status).toBe(200); - expect(res.body.data).toEqual([]); + expect((res.body as any).data).toEqual([]); }); it("returns all credit lines", async () => { createCreditLine("a"); createCreditLine("b"); - const res = await request(buildApp()).get("/api/credit/lines"); - expect(res.body.data).toHaveLength(2); + const res = await requestJson(buildApp(), { method: "GET", path: "/api/credit/lines" }); + expect((res.body as any).data).toHaveLength(2); }); it("returns JSON content-type", async () => { - const res = await request(buildApp()).get("/api/credit/lines"); + const res = await requestJson(buildApp(), { method: "GET", path: "/api/credit/lines" }); expect(res.headers["content-type"]).toMatch(/application\/json/); }); }); - describe("GET /api/credit/lines/:id", () => { it("returns 200 with the credit line for a known id", async () => { createCreditLine(VALID_ID); - const res = await request(buildApp()).get(`/api/credit/lines/${VALID_ID}`); + const res = await requestJson(buildApp(), { method: "GET", path: `/api/credit/lines/${VALID_ID}` }); expect(res.status).toBe(200); - expect(res.body.data.id).toBe(VALID_ID); + expect((res.body as any).data.id).toBe(VALID_ID); }); it("returns 404 for an unknown id", async () => { - const res = await request(buildApp()).get(`/api/credit/lines/${MISSING_ID}`); + const res = await requestJson(buildApp(), { method: "GET", path: `/api/credit/lines/${MISSING_ID}` }); expect(res.status).toBe(404); - expect(res.body.error).toContain(MISSING_ID); - }); - - it("returns JSON content-type on 404", async () => { - const res = await request(buildApp()).get(`/api/credit/lines/${MISSING_ID}`); - expect(res.headers["content-type"]).toMatch(/application\/json/); + expect((res.body as any).error).toContain(MISSING_ID); }); }); describe("POST /api/credit/lines/:id/suspend — authorization", () => { - it("returns 401 when admin auth is denied", async () => { - denyAdmin(); + it("returns 401 when admin key is missing", async () => { createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/suspend`); + const res = await requestJson(buildApp(), { + method: "POST", + path: `/api/credit/lines/${VALID_ID}/suspend`, + body: {}, + }); expect(res.status).toBe(401); }); it("does not suspend the line when auth is denied", async () => { - denyAdmin(); createCreditLine(VALID_ID); - await request(buildApp()).post(`/api/credit/lines/${VALID_ID}/suspend`); - const { _store } = await import("../../services/creditService.js"); + await requestJson(buildApp(), { + method: "POST", + path: `/api/credit/lines/${VALID_ID}/suspend`, + body: {}, + }); expect(_store.get(VALID_ID)?.status).toBe("active"); }); }); @@ -110,140 +88,39 @@ describe("POST /api/credit/lines/:id/suspend — authorization", () => { describe("POST /api/credit/lines/:id/suspend — business logic", () => { it("returns 200 and suspended line for an active credit line", async () => { createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); + const res = await requestJson(buildApp(), { + method: "POST", + path: `/api/credit/lines/${VALID_ID}/suspend`, + headers: { "x-admin-api-key": ADMIN_KEY }, + body: {}, + }); expect(res.status).toBe(200); - expect(res.body.data.status).toBe("suspended"); - expect(res.body.message).toBe("Credit line suspended."); - }); - - it("response includes the full credit line object", async () => { - createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.body.data).toMatchObject({ - id: VALID_ID, - status: "suspended", - }); - expect(res.body.data.events).toBeDefined(); + expect((res.body as any).data.status).toBe("suspended"); + expect((res.body as any).message).toBe("Credit line suspended."); }); it("returns 404 when the credit line does not exist", async () => { - const res = await request(buildApp()) - .post(`/api/credit/lines/${MISSING_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); + const res = await requestJson(buildApp(), { + method: "POST", + path: `/api/credit/lines/${MISSING_ID}/suspend`, + headers: { "x-admin-api-key": ADMIN_KEY }, + body: {}, + }); expect(res.status).toBe(404); - expect(res.body.error).toContain(MISSING_ID); - }); - - it("returns 409 when the line is already suspended", async () => { - createCreditLine(VALID_ID, "suspended"); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(409); - expect(res.body.error).toMatch(/suspend.*suspended|suspended.*suspend/i); - }); - - it("returns 409 when the line is already closed", async () => { - createCreditLine(VALID_ID, "closed"); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(409); + expect((res.body as any).error).toContain(MISSING_ID); }); }); describe("POST /api/credit/lines/:id/close — authorization", () => { - it("returns 401 when admin auth is denied", async () => { - denyAdmin(); + it("returns 401 when admin key is missing", async () => { createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/close`); + const res = await requestJson(buildApp(), { + method: "POST", + path: `/api/credit/lines/${VALID_ID}/close`, + body: {}, + }); expect(res.status).toBe(401); }); - - it("does not close the line when auth is denied", async () => { - denyAdmin(); - createCreditLine(VALID_ID); - await request(buildApp()).post(`/api/credit/lines/${VALID_ID}/close`); - const { _store } = await import("../../services/creditService.js"); - expect(_store.get(VALID_ID)?.status).toBe("active"); - }); }); - -describe("POST /api/credit/lines/:id/close — business logic", () => { - it("returns 200 and closed line for an active credit line", async () => { - createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(200); - expect(res.body.data.status).toBe("closed"); - expect(res.body.message).toBe("Credit line closed."); - }); - - it("returns 200 and closed line for a suspended credit line", async () => { - createCreditLine(VALID_ID, "suspended"); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(200); - expect(res.body.data.status).toBe("closed"); - }); - - it("response includes the full credit line object with events", async () => { - createCreditLine(VALID_ID); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.body.data.events).toBeDefined(); - expect(res.body.data.events.at(-1).action).toBe("closed"); - }); - - it("returns 404 when the credit line does not exist", async () => { - const res = await request(buildApp()) - .post(`/api/credit/lines/${MISSING_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(404); - expect(res.body.error).toContain(MISSING_ID); - }); - - it("returns 409 when the line is already closed", async () => { - createCreditLine(VALID_ID, "closed"); - const res = await request(buildApp()) - .post(`/api/credit/lines/${VALID_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(409); - expect(res.body.error).toMatch(/close.*closed|closed.*close/i); - }); - - it("full lifecycle: active → suspend → close via HTTP", async () => { - createCreditLine(VALID_ID); - const app = buildApp(); - - await request(app) - .post(`/api/credit/lines/${VALID_ID}/suspend`) - .set("x-admin-api-key", ADMIN_KEY); - - const res = await request(app) - .post(`/api/credit/lines/${VALID_ID}/close`) - .set("x-admin-api-key", ADMIN_KEY); - - expect(res.status).toBe(200); - expect(res.body.data.status).toBe("closed"); - expect(res.body.data.events.map((e: { action: string }) => e.action)).toContain("suspended"); - }); -}); \ No newline at end of file diff --git a/src/__test__/creditService.test.ts b/src/__test__/creditService.test.ts index 6d340e0..3577427 100644 --- a/src/__test__/creditService.test.ts +++ b/src/__test__/creditService.test.ts @@ -1,5 +1,5 @@ -import { beforeEach } from "node:test"; +import { beforeEach, describe, expect, it } from "vitest"; import { createCreditLine, getCreditLine, @@ -10,7 +10,7 @@ import { CreditLineNotFoundError, _resetStore, _store, -} from "../../services/creditService.js"; +} from "../services/creditService.js"; @@ -268,4 +268,4 @@ describe("closeCreditLine()", () => { ]); }); }); -}); \ No newline at end of file +}); diff --git a/src/__test__/horizonListener.test.ts b/src/__test__/horizonListener.test.ts index d694a63..540af1f 100644 --- a/src/__test__/horizonListener.test.ts +++ b/src/__test__/horizonListener.test.ts @@ -1,4 +1,12 @@ - +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from "vitest"; import { start, stop, @@ -17,27 +25,38 @@ import { // --------------------------------------------------------------------------- /** Capture console output without cluttering test output. */ +type ConsoleSpy = MockInstance<[message?: any, ...optionalParams: any[]], void>; +let logSpy: ConsoleSpy | null = null; +let warnSpy: ConsoleSpy | null = null; +let errorSpy: ConsoleSpy | null = null; + function silenceConsole() { - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); } function restoreConsole() { - (console.log as jest.Mock).mockRestore?.(); - (console.warn as jest.Mock).mockRestore?.(); - (console.error as jest.Mock).mockRestore?.(); + logSpy?.mockRestore(); + warnSpy?.mockRestore(); + errorSpy?.mockRestore(); + logSpy = null; + warnSpy = null; + errorSpy = null; } /** Save and restore env vars. */ -function withEnv(vars: Record, fn: () => void) { +async function withEnv( + vars: Record, + fn: () => void | Promise, +): Promise { const original: Record = {}; for (const [k, v] of Object.entries(vars)) { original[k] = process.env[k]; process.env[k] = v; } try { - fn(); + await fn(); } finally { for (const [k] of Object.entries(vars)) { if (original[k] === undefined) { @@ -64,7 +83,7 @@ afterEach(() => { if (isRunning()) stop(); clearEventHandlers(); restoreConsole(); - jest.useRealTimers(); + vi.useRealTimers(); }); // --------------------------------------------------------------------------- @@ -86,22 +105,22 @@ describe("resolveConfig()", () => { expect(config.startLedger).toBe("latest"); }); - it("reads HORIZON_URL from env", () => { - withEnv({ HORIZON_URL: "https://custom-horizon.example.com" }, () => { + it("reads HORIZON_URL from env", async () => { + await withEnv({ HORIZON_URL: "https://custom-horizon.example.com" }, () => { expect(resolveConfig().horizonUrl).toBe( "https://custom-horizon.example.com", ); }); }); - it("parses a single CONTRACT_ID", () => { - withEnv({ CONTRACT_IDS: "CONTRACT_A" }, () => { + it("parses a single CONTRACT_ID", async () => { + await withEnv({ CONTRACT_IDS: "CONTRACT_A" }, () => { expect(resolveConfig().contractIds).toEqual(["CONTRACT_A"]); }); }); - it("parses multiple CONTRACT_IDS separated by commas", () => { - withEnv({ CONTRACT_IDS: "CONTRACT_A,CONTRACT_B,CONTRACT_C" }, () => { + it("parses multiple CONTRACT_IDS separated by commas", async () => { + await withEnv({ CONTRACT_IDS: "CONTRACT_A,CONTRACT_B,CONTRACT_C" }, () => { expect(resolveConfig().contractIds).toEqual([ "CONTRACT_A", "CONTRACT_B", @@ -110,8 +129,8 @@ describe("resolveConfig()", () => { }); }); - it("trims whitespace from CONTRACT_IDS entries", () => { - withEnv({ CONTRACT_IDS: " CONTRACT_A , CONTRACT_B " }, () => { + it("trims whitespace from CONTRACT_IDS entries", async () => { + await withEnv({ CONTRACT_IDS: " CONTRACT_A , CONTRACT_B " }, () => { expect(resolveConfig().contractIds).toEqual([ "CONTRACT_A", "CONTRACT_B", @@ -119,20 +138,20 @@ describe("resolveConfig()", () => { }); }); - it("returns empty contractIds for an empty CONTRACT_IDS string", () => { - withEnv({ CONTRACT_IDS: "" }, () => { + it("returns empty contractIds for an empty CONTRACT_IDS string", async () => { + await withEnv({ CONTRACT_IDS: "" }, () => { expect(resolveConfig().contractIds).toEqual([]); }); }); - it("parses POLL_INTERVAL_MS from env", () => { - withEnv({ POLL_INTERVAL_MS: "2000" }, () => { + it("parses POLL_INTERVAL_MS from env", async () => { + await withEnv({ POLL_INTERVAL_MS: "2000" }, () => { expect(resolveConfig().pollIntervalMs).toBe(2000); }); }); - it("reads HORIZON_START_LEDGER from env", () => { - withEnv({ HORIZON_START_LEDGER: "500" }, () => { + it("reads HORIZON_START_LEDGER from env", async () => { + await withEnv({ HORIZON_START_LEDGER: "500" }, () => { expect(resolveConfig().startLedger).toBe("500"); }); }); @@ -149,7 +168,7 @@ describe("isRunning() / getConfig()", () => { }); it("returns true and a config object after start", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); expect(isRunning()).toBe(true); expect(getConfig()).not.toBeNull(); @@ -157,7 +176,7 @@ describe("isRunning() / getConfig()", () => { }); it("returns false and null config after stop", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); stop(); expect(isRunning()).toBe(false); @@ -171,16 +190,14 @@ describe("isRunning() / getConfig()", () => { describe("start()", () => { it("sets running to true", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); expect(isRunning()).toBe(true); }); it("executes an immediate first poll on start", async () => { - jest.useFakeTimers(); - const pollSpy = jest.fn, [HorizonListenerConfig]>(); - - withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { const received: HorizonEvent[] = []; onEvent((e) => { received.push(e); }); await start(); @@ -190,20 +207,20 @@ describe("start()", () => { }); it("fires handlers on subsequent interval ticks", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "MY_CONTRACT", POLL_INTERVAL_MS: "100" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MY_CONTRACT", POLL_INTERVAL_MS: "100" }, async () => { const received: HorizonEvent[] = []; onEvent((e) => { received.push(e); }); await start(); expect(received.length).toBe(1); - jest.advanceTimersByTime(100); + vi.advanceTimersByTime(100); await Promise.resolve(); expect(received.length).toBe(2); - jest.advanceTimersByTime(200); + vi.advanceTimersByTime(200); await Promise.resolve(); await Promise.resolve(); expect(received.length).toBe(4); @@ -211,22 +228,20 @@ describe("start()", () => { }); it("is a no-op (warns) if called when already running", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); - const warnSpy = console.warn as jest.Mock; - warnSpy.mockClear(); + warnSpy!.mockClear(); await start(); // second call - expect(warnSpy).toHaveBeenCalledWith( + expect(warnSpy!).toHaveBeenCalledWith( expect.stringContaining("Already running"), ); expect(isRunning()).toBe(true); }); it("logs startup config information", async () => { - jest.useFakeTimers(); - const logSpy = console.log as jest.Mock; + vi.useFakeTimers(); await start(); - const calls = logSpy.mock.calls.flat().join(" "); + const calls = logSpy!.mock.calls.flat().join(" "); expect(calls).toContain("Starting with config"); }); }); @@ -237,21 +252,21 @@ describe("start()", () => { describe("stop()", () => { it("sets running to false", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); stop(); expect(isRunning()).toBe(false); }); it("clears the polling interval so no more events fire", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "MY_CONTRACT", POLL_INTERVAL_MS: "100" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MY_CONTRACT", POLL_INTERVAL_MS: "100" }, async () => { const received: HorizonEvent[] = []; onEvent((e) => { received.push(e); }); await start(); stop(); const countAfterStop = received.length; - jest.advanceTimersByTime(500); + vi.advanceTimersByTime(500); await Promise.resolve(); // No new events after stop expect(received.length).toBe(countAfterStop); @@ -259,26 +274,24 @@ describe("stop()", () => { }); it("is a no-op (warns) if called when not running", () => { - const warnSpy = console.warn as jest.Mock; stop(); - expect(warnSpy).toHaveBeenCalledWith( + expect(warnSpy!).toHaveBeenCalledWith( expect.stringContaining("Not running"), ); }); it("logs a stopped message", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); - const logSpy = console.log as jest.Mock; - logSpy.mockClear(); + logSpy!.mockClear(); stop(); - expect((console.log as jest.Mock).mock.calls.flat().join(" ")).toContain( + expect(logSpy!.mock.calls.flat().join(" ")).toContain( "Stopped", ); }); it("allows the listener to be restarted after stop", async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); await start(); stop(); expect(isRunning()).toBe(false); @@ -293,10 +306,12 @@ describe("stop()", () => { describe("onEvent() / clearEventHandlers()", () => { it("registers a handler that receives simulated events", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); await start(); expect(events.length).toBeGreaterThan(0); expect(events[0]!.contractId).toBe("MY_CONTRACT"); @@ -304,12 +319,16 @@ describe("onEvent() / clearEventHandlers()", () => { }); it("supports multiple handlers and invokes all of them", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "MULTI_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MULTI_CONTRACT" }, async () => { const calls1: HorizonEvent[] = []; const calls2: HorizonEvent[] = []; - onEvent((e) => calls1.push(e)); - onEvent((e) => calls2.push(e)); + onEvent((e) => { + calls1.push(e); + }); + onEvent((e) => { + calls2.push(e); + }); await start(); expect(calls1.length).toBe(1); expect(calls2.length).toBe(1); @@ -317,10 +336,12 @@ describe("onEvent() / clearEventHandlers()", () => { }); it("clearEventHandlers() removes all registered handlers", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "MY_CONTRACT" }, async () => { const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); clearEventHandlers(); await start(); @@ -329,26 +350,30 @@ describe("onEvent() / clearEventHandlers()", () => { }); it("catches and logs errors thrown by a handler without stopping dispatch", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "ERROR_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "ERROR_CONTRACT" }, async () => { const goodEvents: HorizonEvent[] = []; onEvent(() => { throw new Error("handler boom"); }); - onEvent((e) => goodEvents.push(e)); + onEvent((e) => { + goodEvents.push(e); + }); await start(); expect(goodEvents.length).toBe(1); - expect((console.error as jest.Mock).mock.calls.flat().join(" ")).toContain( + expect(errorSpy!.mock.calls.flat().join(" ")).toContain( "handler threw an error", ); }); }); it("handles async handlers that reject gracefully", async () => { - jest.useFakeTimers(); - withEnv({ CONTRACT_IDS: "ASYNC_ERROR_CONTRACT" }, async () => { + vi.useFakeTimers(); + await withEnv({ CONTRACT_IDS: "ASYNC_ERROR_CONTRACT" }, async () => { const goodEvents: HorizonEvent[] = []; onEvent(async () => { throw new Error("async handler boom"); }); - onEvent((e) => goodEvents.push(e)); + onEvent((e) => { + goodEvents.push(e); + }); await start(); expect(goodEvents.length).toBe(1); }); @@ -372,10 +397,9 @@ describe("pollOnce()", () => { }); it("logs a polling message on every call", async () => { - const logSpy = console.log as jest.Mock; - logSpy.mockClear(); + logSpy!.mockClear(); await pollOnce(baseConfig); - expect(logSpy).toHaveBeenCalledWith( + expect(logSpy!).toHaveBeenCalledWith( expect.stringContaining("Polling"), ); }); @@ -386,7 +410,9 @@ describe("pollOnce()", () => { contractIds: ["TEST_CONTRACT"], }; const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); await pollOnce(config); expect(events).toHaveLength(1); expect(events[0]!.contractId).toBe("TEST_CONTRACT"); @@ -396,16 +422,17 @@ describe("pollOnce()", () => { it("does not emit events when contractIds is empty", async () => { const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); await pollOnce(baseConfig); expect(events).toHaveLength(0); }); it("logs 'none' for contracts when contractIds is empty", async () => { - const logSpy = console.log as jest.Mock; - logSpy.mockClear(); + logSpy!.mockClear(); await pollOnce(baseConfig); - expect((logSpy.mock.calls.flat() as string[]).join(" ")).toContain("none"); + expect((logSpy!.mock.calls.flat() as string[]).join(" ")).toContain("none"); }); it("includes simulated event data with a walletAddress field", async () => { @@ -414,7 +441,9 @@ describe("pollOnce()", () => { contractIds: ["WALLET_CONTRACT"], }; const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); await pollOnce(config); const data = JSON.parse(events[0]!.data) as { walletAddress: string }; expect(data).toHaveProperty("walletAddress"); @@ -426,9 +455,11 @@ describe("pollOnce()", () => { contractIds: ["TS_CONTRACT"], }; const events: HorizonEvent[] = []; - onEvent((e) => events.push(e)); + onEvent((e) => { + events.push(e); + }); await pollOnce(config); expect(() => new Date(events[0]!.timestamp)).not.toThrow(); expect(new Date(events[0]!.timestamp).getTime()).not.toBeNaN(); }); -}); \ No newline at end of file +}); diff --git a/src/__test__/riskRoute.test.ts b/src/__test__/riskRoute.test.ts index 4665743..edf140f 100644 --- a/src/__test__/riskRoute.test.ts +++ b/src/__test__/riskRoute.test.ts @@ -1,160 +1,109 @@ +import express, { type Express } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import express, { Express } from "express"; -import request from "supertest"; -import { jest } from "@jest/globals"; - -jest.mock("../../services/riskService.js", () => ({ - evaluateWallet: jest.fn(), +vi.mock("../services/riskService.js", () => ({ + evaluateWallet: vi.fn(), })); -import riskRouter from "../../routes/risk.js"; -import { evaluateWallet } from "../../services/riskService.js"; +import riskRouter from "../routes/risk.js"; +import { evaluateWallet } from "../services/riskService.js"; +import { requestJson } from "./testHttp.js"; -const mockEvaluateWallet = evaluateWallet as jest.MockedFunction< - typeof evaluateWallet ->; +const mockEvaluateWallet = vi.mocked(evaluateWallet); function buildApp(): Express { - const app = express(); - app.use(express.json()); - app.use("/api/risk", riskRouter); - return app; + const app = express(); + app.use(express.json()); + app.use("/api/risk", riskRouter); + return app; } - -const VALID_ADDRESS = "GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGZBW3JXDC55CYIXB5NAXMCEKJ"; +const VALID_ADDRESS = "GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGZBW3JXDC55CYIXB5NAXMCEKJA"; const MOCK_RESULT = { - walletAddress: VALID_ADDRESS, - score: null, - riskLevel: null, - message: "Risk evaluation placeholder — engine not yet integrated.", - evaluatedAt: "2026-02-26T00:00:00.000Z", + walletAddress: VALID_ADDRESS, + score: null, + riskLevel: null, + message: "Risk evaluation placeholder — engine not yet integrated.", + evaluatedAt: "2026-02-26T00:00:00.000Z", }; - describe("POST /api/risk/evaluate", () => { - let app: Express; - - beforeEach(() => { - app = buildApp(); - mockEvaluateWallet.mockReset(); - }); - - - it("returns 400 when body is empty", async () => { - const res = await request(app) - .post("/api/risk/evaluate") - .set("Content-Type", "application/json") - .send({}); - - expect(res.status).toBe(400); - expect(res.body).toEqual({ error: "walletAddress is required" }); - }); - - it("returns 400 when walletAddress is missing from body", async () => { - const res = await request(app) - .post("/api/risk/evaluate") - .send({ unrelated: "field" }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("walletAddress is required"); - }); - - it("does NOT call evaluateWallet when walletAddress is absent", async () => { - await request(app).post("/api/risk/evaluate").send({}); - expect(mockEvaluateWallet).not.toHaveBeenCalled(); - }); - - - it("returns 200 with the service result on a valid address", async () => { - mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT); - - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: VALID_ADDRESS }); - - expect(res.status).toBe(200); - expect(res.body).toEqual(MOCK_RESULT); - }); - - it("calls evaluateWallet with the exact walletAddress from the body", async () => { - mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT); - - await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: VALID_ADDRESS }); - - expect(mockEvaluateWallet).toHaveBeenCalledTimes(1); - expect(mockEvaluateWallet).toHaveBeenCalledWith(VALID_ADDRESS); + let app: Express; + + beforeEach(() => { + app = buildApp(); + mockEvaluateWallet.mockReset(); + }); + + it("returns 400 when body is empty", async () => { + const res = await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: {}, }); - it("response body contains all expected fields", async () => { - mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT); - - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: VALID_ADDRESS }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "walletAddress is required" }); + }); - expect(res.body).toHaveProperty("walletAddress"); - expect(res.body).toHaveProperty("score"); - expect(res.body).toHaveProperty("riskLevel"); - expect(res.body).toHaveProperty("message"); - expect(res.body).toHaveProperty("evaluatedAt"); + it("does NOT call evaluateWallet when walletAddress is absent", async () => { + await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: {}, }); + expect(mockEvaluateWallet).not.toHaveBeenCalled(); + }); + it("returns 200 with the service result on a valid address", async () => { + mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT as any); - it("returns 400 when evaluateWallet throws an Error", async () => { - mockEvaluateWallet.mockRejectedValueOnce( - new Error("Invalid wallet address: \"BAD\""), - ); - - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: "BAD" }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("Invalid wallet address"); + const res = await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: { walletAddress: VALID_ADDRESS }, }); - it("returns 400 with the service error message verbatim", async () => { - const errorMsg = 'Invalid wallet address: "TOOLONG". Must start with \'G\''; - mockEvaluateWallet.mockRejectedValueOnce(new Error(errorMsg)); + expect(res.status).toBe(200); + expect(res.body).toEqual(MOCK_RESULT); + }); - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: "TOOLONG" }); + it("returns 400 when evaluateWallet throws an Error", async () => { + mockEvaluateWallet.mockRejectedValueOnce(new Error('Invalid wallet address: "BAD"')); - expect(res.body.error).toBe(errorMsg); + const res = await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: { walletAddress: "BAD" }, }); - it("returns 400 with 'Unknown error' when a non-Error is thrown", async () => { - mockEvaluateWallet.mockRejectedValueOnce("raw string throw"); + expect(res.status).toBe(400); + expect((res.body as any).error).toContain("Invalid wallet address"); + }); - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: VALID_ADDRESS }); + it("returns 400 with 'Unknown error' when a non-Error is thrown", async () => { + mockEvaluateWallet.mockRejectedValueOnce("raw string throw"); - expect(res.status).toBe(400); - expect(res.body.error).toBe("Unknown error"); + const res = await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: { walletAddress: VALID_ADDRESS }, }); + expect(res.status).toBe(400); + expect((res.body as any).error).toBe("Unknown error"); + }); - it("returns JSON content-type on success", async () => { - mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT); + it("returns JSON content-type on success", async () => { + mockEvaluateWallet.mockResolvedValueOnce(MOCK_RESULT as any); - const res = await request(app) - .post("/api/risk/evaluate") - .send({ walletAddress: VALID_ADDRESS }); - - expect(res.headers["content-type"]).toMatch(/application\/json/); + const res = await requestJson(app, { + method: "POST", + path: "/api/risk/evaluate", + body: { walletAddress: VALID_ADDRESS }, }); - it("returns JSON content-type on 400 error", async () => { - const res = await request(app) - .post("/api/risk/evaluate") - .send({}); - - expect(res.headers["content-type"]).toMatch(/application\/json/); - }); -}); \ No newline at end of file + expect(res.headers["content-type"]).toMatch(/application\/json/); + }); +}); diff --git a/src/__test__/riskService.test.ts b/src/__test__/riskService.test.ts index 055491c..16cd48a 100644 --- a/src/__test__/riskService.test.ts +++ b/src/__test__/riskService.test.ts @@ -1,15 +1,17 @@ +import { beforeEach, describe, expect, it } from "vitest"; + import { evaluateWallet, isValidWalletAddress, scoreToRiskLevel, type RiskEvaluationResult, type RiskLevel, -} from "../../services/riskService.js"; +} from "../services/riskService.js"; -const VALID_ADDRESS = "GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGZBW3JXDC55CYIXB5NAXMCEKJ"; +const VALID_ADDRESS = "GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGZBW3JXDC55CYIXB5NAXMCEKJA"; -const VALID_ADDRESS_2 = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const VALID_ADDRESS_2 = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA"; describe("isValidWalletAddress()", () => { @@ -182,4 +184,4 @@ describe("scoreToRiskLevel()", () => { await expect(evaluateWallet("BAD")).rejects.toThrow(/56/); }); }); -}); \ No newline at end of file +}); diff --git a/src/__test__/testHttp.ts b/src/__test__/testHttp.ts new file mode 100644 index 0000000..6772ac7 --- /dev/null +++ b/src/__test__/testHttp.ts @@ -0,0 +1,88 @@ +import type { Application } from "express"; +import { Duplex } from "node:stream"; +import { IncomingMessage, ServerResponse } from "node:http"; + +export interface JsonResponse { + status: number; + headers: Record; + body: T; +} + +export async function requestJson( + app: Application, + opts: { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + headers?: Record; + body?: unknown; + }, +): Promise> { + const bodyChunks: Buffer[] = []; + + const socket = new Duplex({ + read() {}, + write(_chunk, _enc, cb) { + cb(); + }, + }); + + const req = new IncomingMessage(socket as any); + req.method = opts.method; + req.url = opts.path; + req.headers = Object.fromEntries( + Object.entries(opts.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]), + ) as any; + + const bodyBuf = + opts.body !== undefined ? Buffer.from(JSON.stringify(opts.body), "utf8") : null; + if (bodyBuf) { + req.headers["content-type"] = req.headers["content-type"] ?? "application/json"; + req.headers["content-length"] = String(bodyBuf.length); + } else { + req.headers["content-length"] = req.headers["content-length"] ?? "0"; + } + + const res = new ServerResponse(req); + (res as any).assignSocket?.(socket); + (res as any).onSocket?.(socket); + + const originalWrite = res.write.bind(res); + res.write = ((chunk: any, ...rest: any[]) => { + if (chunk !== undefined && chunk !== null && chunk.length !== 0) { + bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return (originalWrite as any)(chunk, ...rest); + }) as any; + + const originalEnd = res.end.bind(res); + res.end = ((chunk: any, ...rest: any[]) => { + if (chunk !== undefined && chunk !== null && chunk.length !== 0) { + bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return (originalEnd as any)(chunk, ...rest); + }) as any; + + const done = new Promise((resolve) => res.once("finish", resolve)); + (app as any).handle(req as any, res as any); + + // Defer body emission until after Express has attached its listeners (e.g. express.json()). + await new Promise((resolve) => setImmediate(resolve)); + if (bodyBuf) req.emit("data", bodyBuf); + req.emit("end"); + await done; + + const raw = Buffer.concat(bodyChunks).toString("utf8"); + const headers = Object.fromEntries( + Object.entries(res.getHeaders()).map(([k, v]) => [ + k, + Array.isArray(v) ? v.map(String) : v === undefined ? "" : String(v), + ]), + ); + + const contentType = String(headers["content-type"] ?? ""); + const body = contentType.includes("application/json") && raw.length > 0 + ? JSON.parse(raw) + : (raw as any); + + return { status: res.statusCode, headers, body: body as T }; +} diff --git a/src/routes/credit.ts b/src/routes/credit.ts index 1bb3bff..aad7717 100644 --- a/src/routes/credit.ts +++ b/src/routes/credit.ts @@ -10,6 +10,8 @@ import { InvalidTransitionError, } from "../services/creditService.js"; +const router = Router(); +export const creditRouter = router; export const creditRouter = Router(); function handleServiceError(err: unknown, res: Response): void { @@ -61,4 +63,7 @@ creditRouter.post( handleServiceError(err, res); } }, -); \ No newline at end of file +); + +export default creditRouter; +); diff --git a/src/routes/risk.ts b/src/routes/risk.ts index 197e0db..df1f67d 100644 --- a/src/routes/risk.ts +++ b/src/routes/risk.ts @@ -2,6 +2,8 @@ import { Router, Request, Response } from "express"; import { evaluateWallet } from "../services/riskService.js"; import { ok, fail } from "../utils/response.js"; +const router = Router(); +export const riskRouter = router; export const riskRouter = Router(); riskRouter.post( @@ -23,3 +25,5 @@ riskRouter.post( } }, ); + +export default riskRouter; diff --git a/src/services/riskService.ts b/src/services/riskService.ts index 3244b54..304221e 100644 --- a/src/services/riskService.ts +++ b/src/services/riskService.ts @@ -49,4 +49,4 @@ export async function evaluateWallet( message: "Risk evaluation placeholder — engine not yet integrated.", evaluatedAt: new Date().toISOString(), }; -} \ No newline at end of file +}