From a25c907e0bc5e8326a16bcdb7c43f19e30ec0781 Mon Sep 17 00:00:00 2001 From: Kaylahray Date: Sun, 26 Apr 2026 04:00:48 +0100 Subject: [PATCH] feat: add full API reference and missing route/job tests --- docs/API_REFERENCE.md | 688 ++++++++++++++++++++++++ docs/DOCUMENTATION_INDEX.md | 58 +- jest.config.ts | 50 +- tests/integration/api/agent.test.ts | 55 ++ tests/integration/api/health.test.ts | 18 + tests/integration/api/protocols.test.ts | 97 ++++ tests/unit/jobs/sessionCleanup.test.ts | 91 ++++ 7 files changed, 1018 insertions(+), 39 deletions(-) create mode 100644 docs/API_REFERENCE.md create mode 100644 tests/integration/api/agent.test.ts create mode 100644 tests/integration/api/health.test.ts create mode 100644 tests/integration/api/protocols.test.ts create mode 100644 tests/unit/jobs/sessionCleanup.test.ts diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..a1e3be4 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,688 @@ +# API Reference + +Comprehensive reference for all backend endpoints defined in src/routes. + +## Base Information + +- Base URL (local): http://localhost:3001 +- Content type: application/json unless otherwise specified +- Auth header format: Authorization: Bearer + +## Authentication and Authorization + +- Public endpoints: GET /health, POST /api/auth/challenge, POST /api/auth/verify, GET /api/whatsapp/webhook, POST /api/whatsapp/webhook, GET /api/vault/state, GET /api/protocols/rates, GET /api/protocols/agent/status, GET /api/agent/status +- Session auth endpoints: endpoints guarded by requireAuth require a valid live session token and reject missing, expired, or inactive sessions with 401 Unauthorized. +- User-scope endpoints: endpoints guarded by enforceUserAccess require the requested userId to match the authenticated userId. +- JWT middleware endpoint: POST /api/auth/logout uses AuthMiddleware.validateJwt and may return 401 with specific JWT/session errors. +- Twilio webhook auth: POST /api/whatsapp/webhook requires x-twilio-signature and TWILIO_AUTH_TOKEN; in production, invalid signatures are rejected with 403 Forbidden. + +## Common Error Response Shapes + +- Validation errors (zod): + { + "error": "Validation error", + "details": { + "formErrors": [], + "fieldErrors": { + "fieldName": ["error message"] + } + } + } +- Unauthorized: + { + "error": "Unauthorized" + } +- Not found: + { + "error": "" + } + +--- + +## Health + +### GET /health + +- Auth: none +- Description: Service health check. +- Request params: none +- Request body: none + +Response 200: +{ +"status": "ok", +"timestamp": "2026-04-26T14:45:00.000Z", +"version": "1.0.0", +"environment": "development" +} + +--- + +## Agent + +### GET /api/agent/status + +- Auth: none +- Description: Returns runtime health and scheduling status for the agent loop. +- Request params: none +- Request body: none + +Response 200: +{ +"success": true, +"data": { +"isRunning": true, +"lastRebalanceAt": "2026-04-26T13:00:00.000Z", +"currentProtocol": "Blend", +"currentApy": "1.25", +"nextScheduledCheck": "2026-04-26T15:00:00.000Z", +"lastError": null, +"healthStatus": "healthy", +"timestamp": "2026-04-26T14:45:00.000Z" +} +} + +Response 500: +{ +"success": false, +"error": "Unknown error" +} + +--- + +## Auth + +### POST /api/auth/challenge + +- Auth: none +- Description: Creates a one-time nonce for Stellar signature verification. +- Request params: none +- Request body schema: + { + "stellarPubKey": "string" + } + +Example request: +{ +"stellarPubKey": "GABCD1234EXAMPLEPUBLICKEY" +} + +Response 200: +{ +"nonce": "nw-auth-", +"expiresAt": "2026-04-26T14:50:00.000Z" +} + +Response 400: +{ +"error": "stellarPubKey is required" +} + +Response 400 (invalid key): +{ +"error": "Invalid Stellar public key" +} + +### POST /api/auth/verify + +- Auth: none +- Description: Verifies signature over nonce, upserts user, creates session, returns token. +- Request params: none +- Request body schema: + { + "stellarPubKey": "string", + "signature": "string" + } + +Example request: +{ +"stellarPubKey": "GABCD1234EXAMPLEPUBLICKEY", +"signature": "base64-signature" +} + +Response 200: +{ +"token": "jwt-token", +"userId": "550e8400-e29b-41d4-a716-446655440004", +"expiresAt": "2026-04-27T14:45:00.000Z" +} + +Response 400: +{ +"error": "stellarPubKey and signature are required" +} + +Response 401 examples: +{ +"error": "No active challenge for this public key" +} +{ +"error": "Challenge nonce has expired" +} +{ +"error": "Invalid signature" +} + +Response 500: +{ +"error": "Internal server error" +} + +### POST /api/auth/logout + +- Auth: required (Authorization Bearer token validated via AuthMiddleware.validateJwt) +- Description: Revokes current session token. +- Request params: none +- Request body: none + +Example request headers: +Authorization: Bearer + +Response 200: +{ +"message": "Logged out successfully" +} + +Response 401 examples (from middleware): +{ +"error": "No token provided" +} +{ +"error": "Invalid Bearer token" +} +{ +"error": "Invalid token" +} +{ +"error": "Session not found" +} +{ +"error": "Session expired" +} + +Response 500: +{ +"error": "Internal server error" +} + +--- + +## WhatsApp + +### GET /api/whatsapp/webhook + +- Auth: none +- Description: Twilio webhook liveness check. +- Request params: none +- Request body: none + +Response 200 (text/plain): +WhatsApp webhook is alive + +### POST /api/whatsapp/webhook + +- Auth: Twilio signature validation +- Description: Receives incoming WhatsApp messages and returns TwiML response XML. +- Required header: x-twilio-signature +- Required environment: TWILIO_AUTH_TOKEN +- Request body (Twilio form payload, common fields): + { + "From": "whatsapp:+15550001234", + "Body": "balance" + } + +Response 200 (text/xml): + +Your formatted assistant reply + + +Response 403: +Forbidden + +Notes: + +- In production, invalid signatures are rejected. +- In non-production, invalid signatures may be tolerated for local testing if signature and auth token are present. + +--- + +## Portfolio + +### GET /api/portfolio/:userId + +- Auth: required (requireAuth + enforceUserAccess) +- Path params: + - userId: uuid string +- Query params: none +- Request body: none + +Response 200: +{ +"userId": "550e8400-e29b-41d4-a716-446655440001", +"totalBalance": 8200, +"totalEarnings": 300, +"activePositions": 1, +"positions": [ +{ +"id": "pos-1", +"protocolName": "Blend", +"assetSymbol": "USDC", +"currentValue": 5200, +"yieldEarned": 200, +"status": "ACTIVE" +} +], +"whatsappReply": "..." +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +### GET /api/portfolio/:userId/history + +- Auth: required (requireAuth + enforceUserAccess) +- Path params: + - userId: uuid string +- Query params: + - period: enum(7d, 30d, 90d), default 30d +- Request body: none + +Example request: +GET /api/portfolio/550e8400-e29b-41d4-a716-446655440001/history?period=30d + +Response 200: +{ +"userId": "550e8400-e29b-41d4-a716-446655440001", +"period": "30d", +"points": [ +{ +"date": "2026-04-25", +"yieldAmount": 5 +} +], +"whatsappReply": "..." +} + +Response 400: +{ +"error": "Validation error", +"details": { +"formErrors": [], +"fieldErrors": { +"period": ["Invalid option"] +} +} +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +### GET /api/portfolio/:userId/earnings + +- Auth: required (requireAuth + enforceUserAccess) +- Path params: + - userId: uuid string +- Query params: none +- Request body: none + +Response 200: +{ +"userId": "550e8400-e29b-41d4-a716-446655440001", +"totalEarnings": 300, +"periodEarnings": 18, +"averageApy": 4.025, +"whatsappReply": "..." +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +--- + +## Transactions + +### GET /api/transactions/detail/:txHash + +- Auth: required (requireAuth) +- Path params: + - txHash: string +- Query params: none +- Request body: none + +Response 200: +{ +"transaction": { +"id": "tx-id-1", +"txHash": "txhash-abc001", +"type": "DEPOSIT", +"status": "CONFIRMED", +"amount": 100, +"assetSymbol": "USDC", +"protocolName": "Blend", +"createdAt": "2026-04-26T14:00:00.000Z" +}, +"whatsappReply": "..." +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "Transaction not found" +} + +### GET /api/transactions/:userId + +- Auth: required (requireAuth + enforceUserAccess) +- Path params: + - userId: uuid string +- Query params: + - page: int >= 1, default 1 + - limit: int between 1 and 50, default 5 +- Request body: none + +Example request: +GET /api/transactions/550e8400-e29b-41d4-a716-446655440002?page=2&limit=10 + +Response 200: +{ +"page": 2, +"limit": 10, +"total": 20, +"transactions": [ +{ +"id": "tx-id-1", +"txHash": "txhash-abc001", +"type": "DEPOSIT", +"status": "CONFIRMED", +"amount": 100, +"assetSymbol": "USDC", +"protocolName": "Blend", +"createdAt": "2026-04-26T14:00:00.000Z" +} +], +"whatsappReply": "..." +} + +Response 400: +{ +"error": "Validation error", +"details": { +"formErrors": [], +"fieldErrors": { +"page": ["Invalid input"] +} +} +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +--- + +## Protocols + +### GET /api/protocols/rates + +- Auth: none +- Description: Returns the latest 10 protocol rates. +- Request params: none +- Request body: none + +Response 200: +{ +"rates": [ +{ +"protocolName": "Blend", +"assetSymbol": "USDC", +"supplyApy": 8.75, +"borrowApy": 4.1, +"tvl": 1200000, +"network": "TESTNET", +"fetchedAt": "2026-04-26T14:00:00.000Z" +} +], +"whatsappReply": "..." +} + +### GET /api/protocols/agent/status + +- Auth: none +- Description: Returns latest persisted agent status record. +- Request params: none +- Request body: none + +Response 200: +{ +"status": "SUCCESS", +"action": "ANALYZE", +"updatedAt": "2026-04-26T14:00:00.000Z", +"whatsappReply": "..." +} + +Response 404: +{ +"error": "Agent status not found" +} + +--- + +## Deposit + +### POST /api/deposit + +- Auth: required (requireAuth) +- Description: Executes an on-chain deposit and persists transaction. +- Request params: none +- Request body schema: + { + "userId": "uuid", + "amount": "number > 0", + "assetSymbol": "string", + "protocolName": "string (optional)", + "memo": "string <= 280 chars (optional)" + } + +Example request: +{ +"userId": "550e8400-e29b-41d4-a716-446655440003", +"amount": 100, +"assetSymbol": "USDC", +"protocolName": "Blend", +"memo": "monthly deposit" +} + +Response 201: +{ +"txHash": "chain-hash-0000000001", +"status": "CONFIRMED", +"transaction": { +"id": "tx-new", +"txHash": "chain-hash-0000000001", +"status": "CONFIRMED", +"amount": 100, +"assetSymbol": "USDC", +"protocolName": "Blend" +}, +"whatsappReply": "..." +} + +Response 400: +{ +"error": "Validation error", +"details": { +"formErrors": [], +"fieldErrors": { +"amount": ["Too small: expected number to be >0"] +} +} +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +Response 409: +{ +"error": "Duplicate transaction hash" +} + +--- + +## Withdraw + +### POST /api/withdraw + +- Auth: required (requireAuth) +- Description: Executes an on-chain withdrawal and persists transaction. +- Request params: none +- Request body schema: + { + "userId": "uuid", + "amount": "number > 0", + "assetSymbol": "string", + "protocolName": "string (optional)", + "memo": "string <= 280 chars (optional)" + } + +Example request: +{ +"userId": "550e8400-e29b-41d4-a716-446655440004", +"amount": 50, +"assetSymbol": "USDC", +"protocolName": "Blend", +"memo": "withdraw to wallet" +} + +Response 201: +{ +"txHash": "withdraw-hash-0001", +"status": "CONFIRMED", +"transaction": { +"id": "withdraw-tx-new", +"txHash": "withdraw-hash-0001", +"status": "CONFIRMED", +"amount": 50, +"assetSymbol": "USDC", +"protocolName": "Blend" +}, +"whatsappReply": "..." +} + +Response 400: +{ +"error": "Validation error", +"details": { +"formErrors": [], +"fieldErrors": { +"amount": ["Too small: expected number to be >0"] +} +} +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +Response 409: +{ +"error": "Duplicate transaction hash" +} + +--- + +## Vault + +### GET /api/vault/state + +- Auth: none +- Description: Returns current on-chain APY and active protocol. +- Request params: none +- Request body: none + +Response 200: +{ +"apy": 8.75, +"activeProtocol": "Blend" +} + +### GET /api/vault/balance + +- Auth: required (requireAuth) +- Description: Returns authenticated user on-chain vault balance and shares. +- Request params: none +- Request body: none + +Response 200: +{ +"balance": 1500.25, +"shares": 1450.1 +} + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + +--- + +## Endpoint Coverage Checklist (src/routes) + +- health.ts: GET /health +- agent.ts: GET /api/agent/status +- auth.ts: POST /api/auth/challenge, POST /api/auth/verify, POST /api/auth/logout +- whatsapp.ts: GET /api/whatsapp/webhook, POST /api/whatsapp/webhook +- portfolio.ts: GET /api/portfolio/:userId, GET /api/portfolio/:userId/history, GET /api/portfolio/:userId/earnings +- transactions.ts: GET /api/transactions/detail/:txHash, GET /api/transactions/:userId +- protocols.ts: GET /api/protocols/rates, GET /api/protocols/agent/status +- deposit.ts: POST /api/deposit +- withdraw.ts: POST /api/withdraw +- vault.ts: GET /api/vault/state, GET /api/vault/balance diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 3f31f7c..744718d 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -3,19 +3,24 @@ ## Quick Navigation ### For Developers + - **[QUICK_REFERENCE.md](QUICK_REFERENCE.md)** - Start here for quick overview and usage - **[CODE_STRUCTURE.md](CODE_STRUCTURE.md)** - Understand code organization and design - **[IMPLEMENTATION_DETAILS.md](IMPLEMENTATION_DETAILS.md)** - Deep dive into implementation +- **[API_REFERENCE.md](API_REFERENCE.md)** - Complete backend endpoint reference ### For DevOps/Deployment + - **[DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md)** - Step-by-step deployment instructions - **[IMPLEMENTATION_CHECKLIST.md](IMPLEMENTATION_CHECKLIST.md)** - Verification checklist ### For Project Managers + - **[FINAL_SUMMARY.md](FINAL_SUMMARY.md)** - Executive summary and status - **[PR_DESCRIPTION.md](PR_DESCRIPTION.md)** - PR summary for code review ### For Reference + - **[IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md)** - High-level overview - **[DOCUMENTATION_INDEX.md](DOCUMENTATION_INDEX.md)** - This file @@ -24,8 +29,10 @@ ## Document Descriptions ### QUICK_REFERENCE.md + **Purpose**: Quick lookup guide for developers **Contents**: + - What was implemented - Key files and their purposes - How it works (startup, polling, event processing) @@ -39,8 +46,10 @@ --- ### CODE_STRUCTURE.md + **Purpose**: Detailed code organization and design decisions **Contents**: + - File organization - Core implementation functions - Database schema details @@ -57,8 +66,10 @@ --- ### IMPLEMENTATION_DETAILS.md + **Purpose**: Comprehensive technical documentation **Contents**: + - Problem statement and solution overview - Database schema changes - Event persistence logic @@ -77,8 +88,10 @@ --- ### DEPLOYMENT_GUIDE.md + **Purpose**: Step-by-step deployment instructions **Contents**: + - Pre-deployment checklist - Deployment steps (migration, testing, verification) - Rollback procedure @@ -96,8 +109,10 @@ --- ### IMPLEMENTATION_CHECKLIST.md + **Purpose**: Verification checklist for implementation **Contents**: + - Requirements completed - Acceptance criteria met - Code quality checks @@ -116,8 +131,10 @@ --- ### FINAL_SUMMARY.md + **Purpose**: Executive summary and project status **Contents**: + - Executive summary - What was delivered - Key features @@ -142,8 +159,10 @@ --- ### PR_DESCRIPTION.md + **Purpose**: PR summary for code review **Contents**: + - Summary of changes - Changes made - Acceptance criteria @@ -154,8 +173,10 @@ --- ### IMPLEMENTATION_SUMMARY.md + **Purpose**: High-level overview of implementation **Contents**: + - Overview - Changes made (schema, migration, implementation, tests) - Acceptance criteria met @@ -171,6 +192,7 @@ ## Implementation Files ### Core Implementation + - **src/stellar/events.ts** - Event persistence implementation (350+ lines) - Event parsing functions - Event handlers (deposit, withdraw, rebalance) @@ -179,6 +201,7 @@ - Event fetching and polling ### Database + - **prisma/schema.prisma** - Updated schema with new models - EventCursor model - ProcessedEvent model @@ -189,6 +212,7 @@ - Adds indexes ### Tests + - **tests/unit/stellar/events.test.ts** - Unit tests (200+ lines) - Event persistence tests - Idempotency tests @@ -204,21 +228,25 @@ ## Key Concepts ### Idempotency + Events are processed exactly once, even if the listener restarts or events are replayed. **Implementation**: Unique constraint on (contractId, txHash, eventType, ledger) ### Deduplication + Prevents duplicate event processing by checking ProcessedEvent table before processing. **Implementation**: Query ProcessedEvent before handling, mark as processed after ### Cursor Persistence + Stores last processed ledger in database for recovery on restart. **Implementation**: EventCursor table with one record per contract ### Event Handlers + Separate handlers for each event type (deposit, withdraw, rebalance). **Implementation**: handleDepositEvent, handleWithdrawEvent, handleRebalanceEvent @@ -228,6 +256,7 @@ Separate handlers for each event type (deposit, withdraw, rebalance). ## Quick Commands ### Deployment + ```bash # Apply migration npx prisma migrate deploy @@ -240,6 +269,7 @@ npm run build ``` ### Monitoring + ```bash # Check cursor status psql $DATABASE_URL -c "SELECT * FROM event_cursors;" @@ -252,6 +282,7 @@ psql $DATABASE_URL -c "SELECT * FROM transactions ORDER BY createdAt DESC LIMIT ``` ### Troubleshooting + ```bash # Check listener status grep "Event Listener" logs/*.log @@ -268,22 +299,26 @@ grep "RPC" logs/*.log ## Document Reading Order ### For New Developers + 1. QUICK_REFERENCE.md - Get oriented 2. CODE_STRUCTURE.md - Understand architecture 3. IMPLEMENTATION_DETAILS.md - Deep dive 4. Review src/stellar/events.ts - Read the code ### For DevOps + 1. DEPLOYMENT_GUIDE.md - Deployment steps 2. IMPLEMENTATION_CHECKLIST.md - Verification 3. QUICK_REFERENCE.md - Troubleshooting ### For Project Managers + 1. FINAL_SUMMARY.md - Status and metrics 2. PR_DESCRIPTION.md - Changes summary 3. IMPLEMENTATION_CHECKLIST.md - Verification ### For Code Review + 1. PR_DESCRIPTION.md - Summary 2. CODE_STRUCTURE.md - Architecture 3. IMPLEMENTATION_DETAILS.md - Technical details @@ -304,6 +339,7 @@ grep "RPC" logs/*.log ## Support For questions or issues: + 1. Check QUICK_REFERENCE.md for common questions 2. Review IMPLEMENTATION_DETAILS.md for technical details 3. Check DEPLOYMENT_GUIDE.md for deployment issues @@ -325,17 +361,17 @@ For questions or issues: ## File Statistics -| Document | Lines | Purpose | -|----------|-------|---------| -| QUICK_REFERENCE.md | 150 | Quick lookup | -| CODE_STRUCTURE.md | 350 | Architecture | -| IMPLEMENTATION_DETAILS.md | 400 | Technical details | -| DEPLOYMENT_GUIDE.md | 350 | Deployment | -| IMPLEMENTATION_CHECKLIST.md | 200 | Verification | -| FINAL_SUMMARY.md | 300 | Executive summary | -| PR_DESCRIPTION.md | 30 | PR summary | -| IMPLEMENTATION_SUMMARY.md | 100 | Overview | -| **Total Documentation** | **1880** | **Complete** | +| Document | Lines | Purpose | +| --------------------------- | -------- | ----------------- | +| QUICK_REFERENCE.md | 150 | Quick lookup | +| CODE_STRUCTURE.md | 350 | Architecture | +| IMPLEMENTATION_DETAILS.md | 400 | Technical details | +| DEPLOYMENT_GUIDE.md | 350 | Deployment | +| IMPLEMENTATION_CHECKLIST.md | 200 | Verification | +| FINAL_SUMMARY.md | 300 | Executive summary | +| PR_DESCRIPTION.md | 30 | PR summary | +| IMPLEMENTATION_SUMMARY.md | 100 | Overview | +| **Total Documentation** | **1880** | **Complete** | --- diff --git a/jest.config.ts b/jest.config.ts index 3074801..639ee34 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,42 +1,36 @@ -import type { Config } from 'jest'; +import type { Config } from "jest"; const config: Config = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src', '/tests'], - testMatch: ['**/?(*.)+(test).ts'], - setupFiles: ['/tests/setupEnv.ts'], + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src", "/tests"], + testMatch: ["**/?(*.)+(test).ts"], + setupFiles: ["/tests/setupEnv.ts"], transform: { - '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + "^.+\\.ts$": ["ts-jest", { tsconfig: "/tsconfig.test.json" }], }, - moduleFileExtensions: ['ts', 'js'], + moduleFileExtensions: ["ts", "js"], // Prevent Jest from hanging due to open PrismaClient connections and // setInterval handles left by scanner.ts / sessionCleanup.ts forceExit: true, collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.test.ts', - '!src/**/__tests__/**', - '!src/index.ts', - // ── Routes without dedicated tests ───────────────────────────────────── - '!src/routes/health.ts', - '!src/routes/agent.ts', - '!src/routes/protocols.ts', - '!src/routes/withdraw.ts', + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/__tests__/**", + "!src/index.ts", // ── Complex async infrastructure — tested end-to-end, not unit-testable ─ - '!src/agent/loop.ts', - '!src/agent/snapshotter.ts', - '!src/jobs/sessionCleanup.ts', + "!src/agent/loop.ts", + "!src/agent/snapshotter.ts", // ── On-chain bindings — no unit-testable logic without full Stellar stack - '!src/stellar/contract.ts', - '!src/stellar/events.ts', - '!src/stellar/wallet.ts', - '!src/stellar/index.ts', + "!src/stellar/contract.ts", + "!src/stellar/events.ts", + "!src/stellar/wallet.ts", + "!src/stellar/index.ts", // ── Thin infrastructure: singletons, re-exports, type declarations ────── - '!src/db/index.ts', - '!src/middleware/index.ts', - '!src/config/jwt-adapter.ts', - '!src/types/express.d.ts', + "!src/db/index.ts", + "!src/middleware/index.ts", + "!src/config/jwt-adapter.ts", + "!src/types/express.d.ts", ], coverageThreshold: { global: { diff --git a/tests/integration/api/agent.test.ts b/tests/integration/api/agent.test.ts new file mode 100644 index 0000000..f4c7166 --- /dev/null +++ b/tests/integration/api/agent.test.ts @@ -0,0 +1,55 @@ +const mockGetAgentStatus = jest.fn(); + +jest.mock("../../../src/agent/loop", () => ({ + getAgentStatus: () => mockGetAgentStatus(), +})); + +import request from "supertest"; +import app from "../../../src/index"; + +describe("Agent route", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 200 with normalized status payload", async () => { + mockGetAgentStatus.mockReturnValue({ + isRunning: true, + lastRebalanceAt: new Date("2026-04-26T10:00:00.000Z"), + currentProtocol: "Blend", + currentApy: 7.1234, + nextScheduledCheck: new Date("2026-04-26T11:00:00.000Z"), + lastError: null, + healthStatus: "healthy", + }); + + const res = await request(app).get("/api/agent/status"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + success: true, + data: { + isRunning: true, + currentProtocol: "Blend", + currentApy: "7.12", + lastError: null, + healthStatus: "healthy", + }, + }); + expect(res.body.data.timestamp).toEqual(expect.any(String)); + }); + + it("returns 500 when status provider throws", async () => { + mockGetAgentStatus.mockImplementation(() => { + throw new Error("status unavailable"); + }); + + const res = await request(app).get("/api/agent/status"); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ + success: false, + error: "status unavailable", + }); + }); +}); diff --git a/tests/integration/api/health.test.ts b/tests/integration/api/health.test.ts new file mode 100644 index 0000000..bb79db3 --- /dev/null +++ b/tests/integration/api/health.test.ts @@ -0,0 +1,18 @@ +import request from "supertest"; +import app from "../../../src/index"; + +describe("Health route", () => { + it("returns 200 with health payload", async () => { + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "ok", + version: "1.0.0", + environment: expect.any(String), + timestamp: expect.any(String), + }); + + expect(new Date(res.body.timestamp).toString()).not.toBe("Invalid Date"); + }); +}); diff --git a/tests/integration/api/protocols.test.ts b/tests/integration/api/protocols.test.ts new file mode 100644 index 0000000..efd4e41 --- /dev/null +++ b/tests/integration/api/protocols.test.ts @@ -0,0 +1,97 @@ +const mockDb = { + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, + session: { findUnique: jest.fn() }, + user: { findUnique: jest.fn() }, + position: { findMany: jest.fn() }, + yieldSnapshot: { findMany: jest.fn() }, + transaction: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, +}; + +jest.mock("../../../src/db", () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from "supertest"; +import app from "../../../src/index"; + +describe("Protocols routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /api/protocols/rates", () => { + it("returns latest rates with normalized number fields", async () => { + mockDb.protocolRate.findMany.mockResolvedValue([ + { + protocolName: "Blend", + assetSymbol: "USDC", + supplyApy: "8.75", + borrowApy: "4.10", + tvl: "1200000", + network: "TESTNET", + fetchedAt: new Date("2026-04-26T12:00:00.000Z"), + }, + ]); + + const res = await request(app).get("/api/protocols/rates"); + + expect(res.status).toBe(200); + expect(res.body.rates).toEqual([ + { + protocolName: "Blend", + assetSymbol: "USDC", + supplyApy: 8.75, + borrowApy: 4.1, + tvl: 1200000, + network: "TESTNET", + fetchedAt: "2026-04-26T12:00:00.000Z", + }, + ]); + expect(typeof res.body.whatsappReply).toBe("string"); + expect(mockDb.protocolRate.findMany).toHaveBeenCalledWith({ + orderBy: { fetchedAt: "desc" }, + take: 10, + }); + }); + }); + + describe("GET /api/protocols/agent/status", () => { + it("returns 404 when no status exists", async () => { + mockDb.agentLog.findFirst.mockResolvedValue(null); + + const res = await request(app).get("/api/protocols/agent/status"); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: "Agent status not found" }); + }); + + it("returns latest persisted agent status", async () => { + mockDb.agentLog.findFirst.mockResolvedValue({ + status: "SUCCESS", + action: "ANALYZE", + createdAt: new Date("2026-04-26T13:00:00.000Z"), + }); + + const res = await request(app).get("/api/protocols/agent/status"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + status: "SUCCESS", + action: "ANALYZE", + updatedAt: "2026-04-26T13:00:00.000Z", + whatsappReply: expect.any(String), + }); + expect(mockDb.agentLog.findFirst).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + }); + }); + }); +}); diff --git a/tests/unit/jobs/sessionCleanup.test.ts b/tests/unit/jobs/sessionCleanup.test.ts new file mode 100644 index 0000000..28d64f9 --- /dev/null +++ b/tests/unit/jobs/sessionCleanup.test.ts @@ -0,0 +1,91 @@ +const mockDeleteMany = jest.fn(); +const mockLogger = { + info: jest.fn(), + error: jest.fn(), +}; + +jest.mock("@prisma/client", () => ({ + PrismaClient: jest.fn(() => ({ + session: { + deleteMany: (...args: unknown[]) => mockDeleteMany(...args), + }, + })), +})); + +jest.mock("../../../src/utils/logger", () => ({ + logger: mockLogger, +})); + +import { config } from "../../../src/config/env"; +import { + cleanupExpiredSessions, + scheduleSessionCleanup, +} from "../../../src/jobs/sessionCleanup"; + +describe("sessionCleanup job", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("deletes expired sessions and logs when rows were removed", async () => { + mockDeleteMany.mockResolvedValue({ count: 2 }); + + await cleanupExpiredSessions(); + + expect(mockDeleteMany).toHaveBeenCalledTimes(1); + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { expiresAt: { lt: expect.any(Date) } }, + }); + expect(mockLogger.info).toHaveBeenCalledWith( + "[SessionCleanup] Removed 2 expired session(s)", + ); + }); + + it("does not log removal message when nothing was deleted", async () => { + mockDeleteMany.mockResolvedValue({ count: 0 }); + + await cleanupExpiredSessions(); + + expect(mockDeleteMany).toHaveBeenCalledTimes(1); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining("Removed"), + ); + }); + + it("logs an error when deleteMany fails", async () => { + const error = new Error("db unavailable"); + mockDeleteMany.mockRejectedValue(error); + + await cleanupExpiredSessions(); + + expect(mockLogger.error).toHaveBeenCalledWith( + "[SessionCleanup] Failed to clean up sessions:", + error, + ); + }); + + it("runs immediately and schedules recurring cleanup with configured interval", () => { + mockDeleteMany.mockResolvedValue({ count: 0 }); + + const setIntervalSpy = jest.spyOn(global, "setInterval"); + + const handle = scheduleSessionCleanup(); + + expect(mockDeleteMany).toHaveBeenCalledTimes(1); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + config.jwt.interval_ms, + ); + expect(mockLogger.info).toHaveBeenCalledWith( + "[SessionCleanup] Daily cleanup scheduled", + ); + + clearInterval(handle); + setIntervalSpy.mockRestore(); + }); +});