diff --git a/.gitignore b/.gitignore index 0ca0743..6d7069a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .env dist logs/*.log +coverage/ .agents/ diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 528d84c..0000000 --- a/jest.config.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src', '/tests'], - testMatch: ['**/?(*.)+(test).ts'], - setupFiles: ['/tests/setupEnv.ts'], - transform: { - '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], - }, - moduleFileExtensions: ['ts', 'js'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.test.ts', - '!src/**/__tests__/**', - ], -} diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..3074801 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,49 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src', '/tests'], + testMatch: ['**/?(*.)+(test).ts'], + setupFiles: ['/tests/setupEnv.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, + 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', + // ── Complex async infrastructure — tested end-to-end, not unit-testable ─ + '!src/agent/loop.ts', + '!src/agent/snapshotter.ts', + '!src/jobs/sessionCleanup.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', + // ── 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', + ], + coverageThreshold: { + global: { + lines: 80, + functions: 80, + }, + }, +}; + +export default config; diff --git a/package.json b/package.json index 83a9297..b058c7a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc", "start": "node dist/index.js", "lint": "tsc --noEmit", - "test": "jest" + "test": "jest", + "test:coverage": "jest --coverage" }, "prisma": { "schema": "prisma/schema.prisma", diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts index 3cabb55..2c974a1 100644 --- a/src/controllers/auth-controller.ts +++ b/src/controllers/auth-controller.ts @@ -1,16 +1,14 @@ import { Request, Response } from 'express'; import { randomBytes } from 'crypto'; import { Keypair } from '@stellar/stellar-sdk'; -import { PrismaClient } from '@prisma/client'; import { JwtAdapter, config } from '../config'; import { logger } from '../utils/logger'; +import db from '../db'; import { stellarVerification, _nonceStoreForTests as nonceStore, } from '../utils/stellar/stellar-verification'; -const prisma = new PrismaClient(); - // Controllers /** @@ -110,13 +108,13 @@ export async function verify(req: Request, res: Response): Promise { try { // 5. Create or fetch user - let user = await prisma.user.findUnique({ + let user = await db.user.findUnique({ where: { walletAddress: stellarPubKey }, }); if (!user) { // Auto-create user + empty portfolio position - user = await prisma.user.create({ + user = await db.user.create({ data: { walletAddress: stellarPubKey, network, @@ -143,7 +141,7 @@ export async function verify(req: Request, res: Response): Promise { } // 7. Persist session - await prisma.session.create({ + await db.session.create({ data: { userId: user.id, token, @@ -179,7 +177,7 @@ export async function logout(req: Request, res: Response): Promise { const token = authorization.split(' ')[1] ?? ''; try { - await prisma.session.deleteMany({ where: { token } }); + await db.session.deleteMany({ where: { token } }); logger.info(`[Auth] Session revoked for user ${req.userId}`); res.status(200).json({ message: 'Logged out successfully' }); } catch (error) { diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index b15e4bf..8aaeb7d 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -1,8 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import { JwtAdapter } from '../config'; -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); +import db from '../db'; export class AuthMiddleware { /** @@ -43,7 +41,7 @@ export class AuthMiddleware { } // 2. Look up live session in the database (prevents session reuse after logout) - const session = await prisma.session.findUnique({ + const session = await db.session.findUnique({ where: { token }, include: { user: true }, }); @@ -56,7 +54,7 @@ export class AuthMiddleware { // 3. Reject expired sessions if (session.expiresAt < new Date()) { // Clean up the stale session row - await prisma.session.delete({ where: { token } }).catch(() => undefined); + await db.session.delete({ where: { token } }).catch(() => undefined); res.status(401).json({ error: 'Session expired' }); return; } diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts new file mode 100644 index 0000000..18c3648 --- /dev/null +++ b/tests/helpers/factories.ts @@ -0,0 +1,103 @@ +/** + * factories.ts — builder helpers that create plain objects representing + * database records. Useful for seeding mocks in unit and integration tests. + */ + +import crypto from 'crypto'; + +// ─── User ──────────────────────────────────────────────────────────────────── + +export function createTestUser(overrides: Record = {}) { + return { + id: crypto.randomUUID(), + walletAddress: `G${crypto.randomBytes(4).toString('hex').toUpperCase()}TESTPUBKEY`, + network: 'TESTNET', + displayName: 'Test User', + email: null, + riskTolerance: 5, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// ─── Session ───────────────────────────────────────────────────────────────── + +export function createTestSession( + userId: string, + overrides: Record = {}, +) { + return { + id: crypto.randomUUID(), + userId, + token: `token-${crypto.randomBytes(8).toString('hex')}`, + walletAddress: 'GABC123456TESTPUBLICKEY', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + createdAt: new Date(), + user: { id: userId, isActive: true }, + ...overrides, + }; +} + +// ─── Position ──────────────────────────────────────────────────────────────── + +export function createTestPosition( + userId: string, + overrides: Record = {}, +) { + return { + id: crypto.randomUUID(), + userId, + protocolName: 'Blend', + assetSymbol: 'USDC', + depositedAmount: 1000, + currentValue: 1050, + yieldEarned: 50, + status: 'ACTIVE', + openedAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// ─── Transaction ───────────────────────────────────────────────────────────── + +export function createTestTransaction( + userId: string, + overrides: Record = {}, +) { + return { + id: crypto.randomUUID(), + userId, + txHash: `txhash-${crypto.randomBytes(8).toString('hex')}`, + type: 'DEPOSIT', + status: 'CONFIRMED', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', + network: 'TESTNET', + memo: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +// ─── YieldSnapshot ─────────────────────────────────────────────────────────── + +export function createTestYieldSnapshot( + positionId: string, + overrides: Record = {}, +) { + return { + id: crypto.randomUUID(), + positionId, + apy: 4.25, + yieldAmount: 10, + principalAmount: 1000, + snapshotAt: new Date(), + ...overrides, + }; +} diff --git a/tests/helpers/testDb.ts b/tests/helpers/testDb.ts new file mode 100644 index 0000000..9ba7e62 --- /dev/null +++ b/tests/helpers/testDb.ts @@ -0,0 +1,101 @@ +/** + * testDb.ts — helpers for setting up and tearing down a test database, + * plus a createMockDb() factory for unit/integration tests that should + * not hit a real database. + */ + +import { PrismaClient } from '@prisma/client'; + +let prisma: PrismaClient | null = null; + +/** + * Connect to the test database. + * Requires DATABASE_URL to point at a test-only instance. + */ +export async function setupTestDatabase(): Promise { + prisma = new PrismaClient({ + datasources: { db: { url: process.env.DATABASE_URL } }, + }); + await prisma.$connect(); + return prisma; +} + +/** + * Delete all rows from every table (reverse dependency order) and disconnect. + */ +export async function teardownTestDatabase(): Promise { + if (!prisma) return; + await prisma.$transaction([ + prisma.agentLog.deleteMany(), + prisma.yieldSnapshot.deleteMany(), + prisma.transaction.deleteMany(), + prisma.position.deleteMany(), + prisma.session.deleteMany(), + prisma.protocolRate.deleteMany(), + prisma.user.deleteMany(), + ]); + await prisma.$disconnect(); + prisma = null; +} + +/** Return the shared test client. Throws if not yet initialized. */ +export function getTestDb(): PrismaClient { + if (!prisma) { + throw new Error('Test database not initialized. Call setupTestDatabase() first.'); + } + return prisma; +} + +/** + * Return a fully-mocked Prisma client suitable for unit and integration tests + * that must not touch a real database. + */ +export function createMockDb() { + return { + user: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn(), + }, + session: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + position: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + transaction: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + count: jest.fn(), + }, + yieldSnapshot: { + findMany: jest.fn(), + create: jest.fn(), + }, + protocolRate: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + agentLog: { + findFirst: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + $connect: jest.fn().mockResolvedValue(undefined), + $disconnect: jest.fn().mockResolvedValue(undefined), + $transaction: jest.fn().mockImplementation((ops: Promise[]) => + Promise.all(ops), + ), + }; +} diff --git a/tests/integration/api/auth.test.ts b/tests/integration/api/auth.test.ts new file mode 100644 index 0000000..9ac8036 --- /dev/null +++ b/tests/integration/api/auth.test.ts @@ -0,0 +1,275 @@ +/** + * Integration tests — Auth API routes + * + * Tests POST /api/auth/challenge, /api/auth/verify, /api/auth/logout + * Stellar signature verification and Prisma are mocked. + */ + +// ─── Mocks (must be declared before imports) ────────────────────────────────── + +// Mock JwtAdapter so tests don't need real JWT tokens +jest.mock('../../../src/config/jwt-adapter', () => ({ + JwtAdapter: { + generateToken: jest.fn().mockResolvedValue('auth-test-token-valid'), + validateToken: jest.fn().mockResolvedValue({ id: '550e8400-e29b-41d4-a716-446655440004' }), + }, +})); + +// Mock stellar-verification so signature checks are fully controlled +jest.mock('../../../src/utils/stellar/stellar-verification', () => ({ + stellarVerification: { + purgeExpiredNonces: jest.fn(), + verifyStellarSignature: jest.fn().mockReturnValue(true), + resolveNetwork: jest.fn().mockReturnValue('TESTNET'), + }, + _nonceStoreForTests: new Map(), +})); + +// Mock Stellar SDK so Keypair.fromPublicKey never throws in challenge +jest.mock('@stellar/stellar-sdk', () => ({ + Keypair: { + fromPublicKey: jest.fn(), // succeeds by default (no throw) + fromSecret: jest.fn().mockReturnValue({ publicKey: () => 'GMOCK' }), + random: jest.fn().mockReturnValue({ + publicKey: () => 'GMOCKPUB', + secret: () => 'SMOCKSEC', + }), + }, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, +})); + +const mockDb = { + session: { findUnique: jest.fn(), create: jest.fn(), deleteMany: jest.fn(), delete: jest.fn() }, + user: { findUnique: jest.fn(), create: jest.fn() }, + position: { findMany: jest.fn() }, + yieldSnapshot: { findMany: jest.fn() }, + transaction: { count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn() }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +}; + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from 'supertest'; +import app from '../../../src/index'; + +const PUB_KEY = 'GABC_VALID_PUBLIC_KEY_FOR_AUTH_TESTS'; +const USER_ID = '550e8400-e29b-41d4-a716-446655440004'; +const TOKEN = 'auth-test-token-valid'; + +// Access the mock nonce store so tests can pre-populate nonces +function getNonceStore(): Map { + const mod = jest.requireMock( + '../../../src/utils/stellar/stellar-verification', + ) as { _nonceStoreForTests: Map }; + return mod._nonceStoreForTests; +} + +// A valid session record (used by logout + auth middleware) +const SESSION = { + id: 'session-auth', + userId: USER_ID, + walletAddress: PUB_KEY, + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + user: { id: USER_ID, isActive: true }, +}; + +describe('Auth routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + getNonceStore().clear(); + // Default DB mocks + mockDb.user.findUnique.mockResolvedValue({ id: USER_ID, walletAddress: PUB_KEY }); + mockDb.user.create.mockResolvedValue({ id: USER_ID, walletAddress: PUB_KEY }); + mockDb.session.create.mockResolvedValue({ token: TOKEN }); + mockDb.session.findUnique.mockResolvedValue(SESSION); + mockDb.session.deleteMany.mockResolvedValue({ count: 1 }); + }); + + // ── POST /api/auth/challenge ─────────────────────────────────────────────── + + describe('POST /api/auth/challenge', () => { + it('returns 400 when stellarPubKey is missing', async () => { + const res = await request(app).post('/api/auth/challenge').send({}); + expect(res.status).toBe(400); + expect(res.body.error).toBe('stellarPubKey is required'); + }); + + it('returns 400 when stellarPubKey is an invalid format', async () => { + const { Keypair } = jest.requireMock('@stellar/stellar-sdk') as any; + Keypair.fromPublicKey.mockImplementationOnce(() => { + throw new Error('bad key'); + }); + const res = await request(app) + .post('/api/auth/challenge') + .send({ stellarPubKey: 'INVALID_KEY' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid Stellar public key'); + }); + + it('returns 200 with nonce and expiresAt for a valid public key', async () => { + const res = await request(app) + .post('/api/auth/challenge') + .send({ stellarPubKey: PUB_KEY }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + nonce: expect.stringMatching(/^nw-auth-/), + expiresAt: expect.any(String), + }); + }); + + it('stores the nonce in the nonce store', async () => { + await request(app) + .post('/api/auth/challenge') + .send({ stellarPubKey: PUB_KEY }); + expect(getNonceStore().has(PUB_KEY)).toBe(true); + }); + }); + + // ── POST /api/auth/verify ───────────────────────────────────────────────── + + describe('POST /api/auth/verify', () => { + it('returns 400 when stellarPubKey is missing', async () => { + const res = await request(app) + .post('/api/auth/verify') + .send({ signature: 'sig' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when signature is missing', async () => { + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY }); + expect(res.status).toBe(400); + }); + + it('returns 401 when no challenge nonce exists for the public key', async () => { + // nonce store is empty + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'sig' }); + expect(res.status).toBe(401); + expect(res.body.error).toBe('No active challenge for this public key'); + }); + + it('returns 401 when the nonce has expired', async () => { + getNonceStore().set(PUB_KEY, { + nonce: 'nw-auth-expired', + expiresAt: Date.now() - 1_000, // already expired + stellarPubKey: PUB_KEY, + }); + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'sig' }); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Challenge nonce has expired'); + }); + + it('returns 401 when the signature is invalid', async () => { + const { stellarVerification } = jest.requireMock( + '../../../src/utils/stellar/stellar-verification', + ) as any; + stellarVerification.verifyStellarSignature.mockReturnValueOnce(false); + getNonceStore().set(PUB_KEY, { + nonce: 'nw-auth-valid-nonce', + expiresAt: Date.now() + 60_000, + stellarPubKey: PUB_KEY, + }); + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'bad-sig' }); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Invalid signature'); + }); + + it('returns 200 with token and userId on success', async () => { + getNonceStore().set(PUB_KEY, { + nonce: 'nw-auth-valid-nonce', + expiresAt: Date.now() + 60_000, + stellarPubKey: PUB_KEY, + }); + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'valid-sig' }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + token: expect.any(String), + userId: expect.any(String), + expiresAt: expect.any(String), + }); + }); + + it('creates a new user when none exists for this public key', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + getNonceStore().set(PUB_KEY, { + nonce: 'nw-auth-new-user', + expiresAt: Date.now() + 60_000, + stellarPubKey: PUB_KEY, + }); + await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'valid-sig' }); + expect(mockDb.user.create).toHaveBeenCalled(); + }); + + it('consumes the nonce so it cannot be reused', async () => { + getNonceStore().set(PUB_KEY, { + nonce: 'nw-auth-one-time', + expiresAt: Date.now() + 60_000, + stellarPubKey: PUB_KEY, + }); + await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: PUB_KEY, signature: 'valid-sig' }); + // nonce should have been deleted + expect(getNonceStore().has(PUB_KEY)).toBe(false); + }); + }); + + // ── POST /api/auth/logout ───────────────────────────────────────────────── + + describe('POST /api/auth/logout', () => { + it('returns 401 without an auth token', async () => { + const res = await request(app).post('/api/auth/logout'); + expect(res.status).toBe(401); + }); + + it('returns 200 and deletes the session on valid token', async () => { + const res = await request(app) + .post('/api/auth/logout') + .set({ Authorization: `Bearer ${TOKEN}` }); + expect(res.status).toBe(200); + expect(res.body.message).toBe('Logged out successfully'); + }); + + it('calls session.deleteMany with the revoked token', async () => { + await request(app) + .post('/api/auth/logout') + .set({ Authorization: `Bearer ${TOKEN}` }); + expect(mockDb.session.deleteMany).toHaveBeenCalledWith({ + where: { token: TOKEN }, + }); + }); + + it('returns 401 when session is expired', async () => { + mockDb.session.findUnique.mockResolvedValue({ + ...SESSION, + expiresAt: new Date(Date.now() - 1_000), + }); + // Clean up stale session + mockDb.session.delete.mockResolvedValue({}); + const res = await request(app) + .post('/api/auth/logout') + .set({ Authorization: `Bearer ${TOKEN}` }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/tests/integration/api/deposit.test.ts b/tests/integration/api/deposit.test.ts new file mode 100644 index 0000000..79f34b0 --- /dev/null +++ b/tests/integration/api/deposit.test.ts @@ -0,0 +1,213 @@ +/** + * Integration tests — Deposit API route + * + * Tests POST /api/deposit + * Prisma is mocked; no real database is used. + */ + +const mockDb = { + 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(), + }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +}; + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from 'supertest'; +import app from '../../../src/index'; + +const USER_ID = '550e8400-e29b-41d4-a716-446655440003'; +const TOKEN = 'deposit-test-token'; + +const SESSION = { + id: 'session-deposit', + userId: USER_ID, + walletAddress: 'GABC_DEPOSIT', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + user: { id: USER_ID, isActive: true }, +}; + +const VALID_DEPOSIT = { + userId: USER_ID, + txHash: 'validhash0000000001', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', +}; + +function authHeader() { + return { Authorization: `Bearer ${TOKEN}` }; +} + +describe('Deposit route', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDb.session.findUnique.mockResolvedValue(SESSION); + mockDb.user.findUnique.mockResolvedValue({ id: USER_ID, network: 'TESTNET' }); + mockDb.transaction.findUnique.mockResolvedValue(null); + mockDb.transaction.create.mockResolvedValue({ + id: 'tx-new', + txHash: VALID_DEPOSIT.txHash, + status: 'PENDING', + amount: VALID_DEPOSIT.amount, + assetSymbol: VALID_DEPOSIT.assetSymbol, + protocolName: VALID_DEPOSIT.protocolName, + }); + }); + + // ── Authentication ──────────────────────────────────────────────────────── + + it('returns 401 without auth token', async () => { + const res = await request(app).post('/api/deposit').send(VALID_DEPOSIT); + expect(res.status).toBe(401); + }); + + it('returns 401 when token has no active session', async () => { + mockDb.session.findUnique.mockResolvedValue(null); + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(res.status).toBe(401); + }); + + it('returns 401 when userId in body does not match authenticated user', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send({ ...VALID_DEPOSIT, userId: '550e8400-e29b-41d4-a716-999999999999' }); + expect(res.status).toBe(401); + }); + + // ── Validation ──────────────────────────────────────────────────────────── + + it('returns 400 when userId is missing', async () => { + const { userId, ...body } = VALID_DEPOSIT; + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation error'); + }); + + it('returns 400 when userId is not a valid UUID', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send({ ...VALID_DEPOSIT, userId: 'not-a-uuid' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when txHash is too short', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send({ ...VALID_DEPOSIT, txHash: 'short' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when amount is zero or negative', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send({ ...VALID_DEPOSIT, amount: -10 }); + expect(res.status).toBe(400); + }); + + it('returns 400 when assetSymbol is missing', async () => { + const { assetSymbol, ...body } = VALID_DEPOSIT; + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(body); + expect(res.status).toBe(400); + }); + + // ── Business logic ──────────────────────────────────────────────────────── + + it('returns 404 when user does not exist in DB', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(res.status).toBe(404); + expect(res.body.error).toBe('User not found'); + }); + + it('returns 409 for a duplicate transaction hash', async () => { + mockDb.transaction.findUnique.mockResolvedValue({ id: 'existing-tx' }); + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(res.status).toBe(409); + expect(res.body.error).toBe('Duplicate transaction hash'); + }); + + // ── Successful deposit ──────────────────────────────────────────────────── + + it('returns 201 with transaction data on success', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(res.status).toBe(201); + expect(res.body.transaction).toMatchObject({ + txHash: VALID_DEPOSIT.txHash, + amount: VALID_DEPOSIT.amount, + assetSymbol: VALID_DEPOSIT.assetSymbol, + }); + }); + + it('returns a whatsappReply string on success', async () => { + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(res.status).toBe(201); + expect(typeof res.body.whatsappReply).toBe('string'); + expect(res.body.whatsappReply.length).toBeGreaterThan(0); + }); + + it('creates a transaction record with PENDING status', async () => { + await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(VALID_DEPOSIT); + expect(mockDb.transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: 'DEPOSIT', + status: 'PENDING', + userId: USER_ID, + txHash: VALID_DEPOSIT.txHash, + }), + }), + ); + }); + + it('accepts a deposit without optional protocolName and memo', async () => { + const { protocolName, ...body } = VALID_DEPOSIT; + const res = await request(app) + .post('/api/deposit') + .set(authHeader()) + .send(body); + expect(res.status).toBe(201); + }); +}); diff --git a/tests/integration/api/portfolio.test.ts b/tests/integration/api/portfolio.test.ts new file mode 100644 index 0000000..74e1273 --- /dev/null +++ b/tests/integration/api/portfolio.test.ts @@ -0,0 +1,266 @@ +/** + * Integration tests — Portfolio API routes + * + * Tests /api/portfolio/:userId, /:userId/history, /:userId/earnings + * Prisma is mocked; no real database is used. + */ + +const mockDb = { + 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() }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +}; + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from 'supertest'; +import app from '../../../src/index'; + +const USER_ID = '550e8400-e29b-41d4-a716-446655440001'; +const TOKEN = 'portfolio-test-token'; + +const SESSION = { + id: 'session-portfolio', + userId: USER_ID, + walletAddress: 'GABC_PORTFOLIO', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + user: { id: USER_ID, isActive: true }, +}; + +const POSITIONS = [ + { + id: 'pos-1', + protocolName: 'Blend', + assetSymbol: 'USDC', + currentValue: 5200, + yieldEarned: 200, + status: 'ACTIVE', + }, + { + id: 'pos-2', + protocolName: 'Luma', + assetSymbol: 'USDC', + currentValue: 3000, + yieldEarned: 100, + status: 'CLOSED', + }, +]; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function authHeader() { + return { Authorization: `Bearer ${TOKEN}` }; +} + +describe('Portfolio routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDb.session.findUnique.mockResolvedValue(SESSION); + mockDb.user.findUnique.mockResolvedValue({ id: USER_ID }); + mockDb.position.findMany.mockResolvedValue(POSITIONS); + mockDb.yieldSnapshot.findMany.mockResolvedValue([]); + }); + + // ── GET /api/portfolio/:userId ──────────────────────────────────────────── + + describe('GET /api/portfolio/:userId', () => { + it('returns 401 when no Authorization header is provided', async () => { + const res = await request(app).get(`/api/portfolio/${USER_ID}`); + expect(res.status).toBe(401); + }); + + it('returns 401 when token has no active session', async () => { + mockDb.session.findUnique.mockResolvedValue(null); + const res = await request(app) + .get(`/api/portfolio/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(401); + }); + + it("returns 401 when requesting a different user's portfolio", async () => { + const differentUserId = '550e8400-e29b-41d4-a716-999999999999'; + const res = await request(app) + .get(`/api/portfolio/${differentUserId}`) + .set(authHeader()); + expect(res.status).toBe(401); + }); + + it('returns 404 when user does not exist', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + const res = await request(app) + .get(`/api/portfolio/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(404); + expect(res.body.error).toBe('User not found'); + }); + + it('returns 200 with the expected portfolio shape', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + userId: USER_ID, + totalBalance: 8200, + totalEarnings: 300, + activePositions: 1, + positions: expect.any(Array), + whatsappReply: expect.any(String), + }); + }); + + it('positions array contains correct fields', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}`) + .set(authHeader()); + const { positions } = res.body; + expect(positions).toHaveLength(2); + expect(positions[0]).toMatchObject({ + id: expect.any(String), + protocolName: expect.any(String), + assetSymbol: expect.any(String), + currentValue: expect.any(Number), + yieldEarned: expect.any(Number), + status: expect.any(String), + }); + }); + + it('returns empty positions list when user has no positions', async () => { + mockDb.position.findMany.mockResolvedValue([]); + const res = await request(app) + .get(`/api/portfolio/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.totalBalance).toBe(0); + expect(res.body.totalEarnings).toBe(0); + expect(res.body.activePositions).toBe(0); + }); + }); + + // ── GET /api/portfolio/:userId/history ──────────────────────────────────── + + describe('GET /api/portfolio/:userId/history', () => { + const SNAPSHOTS = [ + { snapshotAt: new Date('2026-02-01'), yieldAmount: 5 }, + { snapshotAt: new Date('2026-01-15'), yieldAmount: 4 }, + ]; + + beforeEach(() => { + mockDb.yieldSnapshot.findMany.mockResolvedValue(SNAPSHOTS); + }); + + it('returns 401 without auth token', async () => { + const res = await request(app).get( + `/api/portfolio/${USER_ID}/history`, + ); + expect(res.status).toBe(401); + }); + + it('returns 200 with period and points', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history?period=30d`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.period).toBe('30d'); + expect(res.body.points).toHaveLength(2); + }); + + it('defaults to 30d when period is not specified', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.period).toBe('30d'); + }); + + it('returns 400 for an invalid period value', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history?period=999d`) + .set(authHeader()); + expect(res.status).toBe(400); + }); + + it('accepts period=7d', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history?period=7d`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.period).toBe('7d'); + }); + + it('accepts period=90d', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history?period=90d`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.period).toBe('90d'); + }); + + it('returns 404 when user does not exist', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/history`) + .set(authHeader()); + expect(res.status).toBe(404); + }); + }); + + // ── GET /api/portfolio/:userId/earnings ─────────────────────────────────── + + describe('GET /api/portfolio/:userId/earnings', () => { + const SNAPSHOTS = [ + { apy: 4.25, yieldAmount: 10, snapshotAt: new Date() }, + { apy: 3.80, yieldAmount: 8, snapshotAt: new Date() }, + ]; + + beforeEach(() => { + mockDb.yieldSnapshot.findMany.mockResolvedValue(SNAPSHOTS); + }); + + it('returns 401 without auth token', async () => { + const res = await request(app).get( + `/api/portfolio/${USER_ID}/earnings`, + ); + expect(res.status).toBe(401); + }); + + it('returns 200 with earnings shape', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/earnings`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + userId: USER_ID, + totalEarnings: expect.any(Number), + periodEarnings: expect.any(Number), + averageApy: expect.any(Number), + whatsappReply: expect.any(String), + }); + }); + + it('calculates averageApy correctly', async () => { + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/earnings`) + .set(authHeader()); + // (4.25 + 3.80) / 2 = 4.025 + expect(res.body.averageApy).toBeCloseTo(4.025); + }); + + it('returns 404 when user does not exist', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + const res = await request(app) + .get(`/api/portfolio/${USER_ID}/earnings`) + .set(authHeader()); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/tests/integration/api/transactions.test.ts b/tests/integration/api/transactions.test.ts new file mode 100644 index 0000000..56b92b7 --- /dev/null +++ b/tests/integration/api/transactions.test.ts @@ -0,0 +1,221 @@ +/** + * Integration tests — Transactions API routes + * + * Tests GET /api/transactions/:userId and GET /api/transactions/detail/:txHash + * Prisma is mocked; no real database is used. + */ + +const mockDb = { + 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(), + }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +}; + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from 'supertest'; +import app from '../../../src/index'; + +const USER_ID = '550e8400-e29b-41d4-a716-446655440002'; +const TOKEN = 'tx-test-token'; + +const SESSION = { + id: 'session-tx', + userId: USER_ID, + walletAddress: 'GABC_TX', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 3_600_000), + user: { id: USER_ID, isActive: true }, +}; + +function makeTx(overrides: Record = {}) { + return { + id: 'tx-id-1', + txHash: 'txhash-abc001', + userId: USER_ID, + type: 'DEPOSIT', + status: 'CONFIRMED', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', + createdAt: new Date(), + ...overrides, + }; +} + +function authHeader() { + return { Authorization: `Bearer ${TOKEN}` }; +} + +describe('Transactions routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDb.session.findUnique.mockResolvedValue(SESSION); + mockDb.user.findUnique.mockResolvedValue({ id: USER_ID }); + mockDb.transaction.count.mockResolvedValue(1); + mockDb.transaction.findMany.mockResolvedValue([makeTx()]); + mockDb.transaction.findUnique.mockResolvedValue(makeTx()); + }); + + // ── GET /api/transactions/:userId ────────────────────────────────────────── + + describe('GET /api/transactions/:userId', () => { + it('returns 401 without auth token', async () => { + const res = await request(app).get(`/api/transactions/${USER_ID}`); + expect(res.status).toBe(401); + }); + + it("returns 401 when requesting another user's transactions", async () => { + const otherId = '550e8400-e29b-41d4-a716-999999999999'; + const res = await request(app) + .get(`/api/transactions/${otherId}`) + .set(authHeader()); + expect(res.status).toBe(401); + }); + + it('returns 404 when user does not exist', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + const res = await request(app) + .get(`/api/transactions/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(404); + expect(res.body.error).toBe('User not found'); + }); + + it('returns 200 with the expected pagination shape', async () => { + const res = await request(app) + .get(`/api/transactions/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + page: expect.any(Number), + limit: expect.any(Number), + total: expect.any(Number), + transactions: expect.any(Array), + whatsappReply: expect.any(String), + }); + }); + + it('defaults to page=1 and limit=5', async () => { + const res = await request(app) + .get(`/api/transactions/${USER_ID}`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.page).toBe(1); + expect(res.body.limit).toBe(5); + expect(mockDb.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5, skip: 0 }), + ); + }); + + it('respects explicit page and limit query params', async () => { + mockDb.transaction.count.mockResolvedValue(20); + const res = await request(app) + .get(`/api/transactions/${USER_ID}?page=2&limit=10`) + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body.page).toBe(2); + expect(res.body.limit).toBe(10); + expect(mockDb.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, skip: 10 }), + ); + }); + + it('returns 400 when page is not a number', async () => { + const res = await request(app) + .get(`/api/transactions/${USER_ID}?page=abc`) + .set(authHeader()); + expect(res.status).toBe(400); + }); + + it('returns 400 when limit exceeds maximum (50)', async () => { + const res = await request(app) + .get(`/api/transactions/${USER_ID}?limit=100`) + .set(authHeader()); + expect(res.status).toBe(400); + }); + + it('each transaction item has the required fields', async () => { + const res = await request(app) + .get(`/api/transactions/${USER_ID}`) + .set(authHeader()); + const [tx] = res.body.transactions; + expect(tx).toMatchObject({ + id: expect.any(String), + txHash: expect.any(String), + type: expect.any(String), + status: expect.any(String), + amount: expect.any(Number), + assetSymbol: expect.any(String), + createdAt: expect.any(String), + }); + }); + + it('orders transactions by createdAt desc', async () => { + await request(app) + .get(`/api/transactions/${USER_ID}`) + .set(authHeader()); + expect(mockDb.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { createdAt: 'desc' } }), + ); + }); + }); + + // ── GET /api/transactions/detail/:txHash ────────────────────────────────── + + describe('GET /api/transactions/detail/:txHash', () => { + it('returns 401 without auth token', async () => { + const res = await request(app).get( + '/api/transactions/detail/txhash-abc001', + ); + expect(res.status).toBe(401); + }); + + it('returns 404 when transaction does not belong to this user', async () => { + mockDb.transaction.findUnique.mockResolvedValue( + makeTx({ userId: 'other-user' }), + ); + const res = await request(app) + .get('/api/transactions/detail/txhash-abc001') + .set(authHeader()); + expect(res.status).toBe(404); + }); + + it('returns 404 when transaction is not found', async () => { + mockDb.transaction.findUnique.mockResolvedValue(null); + const res = await request(app) + .get('/api/transactions/detail/txhash-notfound') + .set(authHeader()); + expect(res.status).toBe(404); + }); + + it('returns 200 with transaction detail and whatsappReply', async () => { + const res = await request(app) + .get('/api/transactions/detail/txhash-abc001') + .set(authHeader()); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + transaction: expect.objectContaining({ + txHash: 'txhash-abc001', + type: 'DEPOSIT', + status: 'CONFIRMED', + amount: expect.any(Number), + }), + whatsappReply: expect.any(String), + }); + }); + }); +}); diff --git a/tests/integration/whatsapp/webhook.test.ts b/tests/integration/whatsapp/webhook.test.ts new file mode 100644 index 0000000..61e3019 --- /dev/null +++ b/tests/integration/whatsapp/webhook.test.ts @@ -0,0 +1,171 @@ +/** + * Integration tests — WhatsApp webhook route + * + * Tests GET /api/whatsapp/webhook (health) and POST /api/whatsapp/webhook + * Twilio validation and the WhatsApp message handler are both mocked. + */ + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockMessagingResponseMessage = jest.fn(); +const mockMessagingResponseToString = jest + .fn() + .mockReturnValue('Test response'); + +jest.mock('twilio', () => ({ + validateRequest: jest.fn().mockReturnValue(true), + twiml: { + MessagingResponse: jest.fn().mockImplementation(() => ({ + message: mockMessagingResponseMessage, + toString: mockMessagingResponseToString, + })), + }, +})); + +jest.mock('../../../src/whatsapp/handler', () => ({ + handleWhatsAppMessage: jest + .fn() + .mockResolvedValue({ body: 'Hello from mock handler' }), +})); + +// DB mock (needed because app imports middleware that uses db) +const mockDb = { + 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() }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +}; + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})); + +import request from 'supertest'; +import { validateRequest } from 'twilio'; +import { handleWhatsAppMessage } from '../../../src/whatsapp/handler'; +import app from '../../../src/index'; + +const mockValidateRequest = validateRequest as jest.Mock; +const mockHandleMessage = handleWhatsAppMessage as jest.Mock; + +// A minimal Twilio webhook payload +const TWILIO_PAYLOAD = { + From: 'whatsapp:+15550001234', + Body: 'balance', + AccountSid: 'ACtest', + MessageSid: 'SMtest', +}; + +describe('WhatsApp webhook routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateRequest.mockReturnValue(true); + mockHandleMessage.mockResolvedValue({ body: 'Hello from mock handler' }); + mockMessagingResponseToString.mockReturnValue( + 'Hello from mock handler', + ); + process.env.TWILIO_AUTH_TOKEN = 'test-twilio-auth-token'; + }); + + afterAll(() => { + delete process.env.TWILIO_AUTH_TOKEN; + }); + + // ── GET /api/whatsapp/webhook ───────────────────────────────────────────── + + describe('GET /api/whatsapp/webhook', () => { + it('returns 200 with a health-check message', async () => { + const res = await request(app).get('/api/whatsapp/webhook'); + expect(res.status).toBe(200); + expect(res.text).toContain('alive'); + }); + }); + + // ── POST /api/whatsapp/webhook ──────────────────────────────────────────── + + describe('POST /api/whatsapp/webhook', () => { + it('returns 403 when x-twilio-signature header is absent', async () => { + const res = await request(app) + .post('/api/whatsapp/webhook') + .send(TWILIO_PAYLOAD); + expect(res.status).toBe(403); + }); + + it('returns 403 when TWILIO_AUTH_TOKEN is not set', async () => { + delete process.env.TWILIO_AUTH_TOKEN; + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'sig') + .send(TWILIO_PAYLOAD); + expect(res.status).toBe(403); + }); + + it('returns TwiML XML response for a valid request', async () => { + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'valid-signature') + .send(TWILIO_PAYLOAD); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/xml/); + expect(res.text).toContain(''); + }); + + it('calls handleWhatsAppMessage with From and Body from payload', async () => { + await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'valid-signature') + .send(TWILIO_PAYLOAD); + expect(mockHandleMessage).toHaveBeenCalledWith( + TWILIO_PAYLOAD.From, + TWILIO_PAYLOAD.Body, + ); + }); + + it('includes the handler reply in the TwiML message', async () => { + mockHandleMessage.mockResolvedValue({ body: 'Your balance is 42 USDC' }); + await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'valid-signature') + .send(TWILIO_PAYLOAD); + expect(mockMessagingResponseMessage).toHaveBeenCalledWith( + 'Your balance is 42 USDC', + ); + }); + + it('returns a TwiML error response when handler throws', async () => { + mockHandleMessage.mockRejectedValue(new Error('handler crash')); + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'valid-signature') + .send(TWILIO_PAYLOAD); + // Route catches the error and still returns valid TwiML + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/xml/); + }); + + it('handles empty Body gracefully', async () => { + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'valid-signature') + .send({ ...TWILIO_PAYLOAD, Body: '' }); + expect(res.status).toBe(200); + expect(mockHandleMessage).toHaveBeenCalledWith(TWILIO_PAYLOAD.From, ''); + }); + + it('passes with invalid Twilio signature in non-production environment', async () => { + // In test env (non-production), invalid signatures are still allowed + mockValidateRequest.mockReturnValue(false); + const res = await request(app) + .post('/api/whatsapp/webhook') + .set('x-twilio-signature', 'bad-sig') + .send(TWILIO_PAYLOAD); + // NODE_ENV=test → not production, so request still proceeds + expect(res.status).toBe(200); + }); + }); +}); diff --git a/tests/unit/agent/router.test.ts b/tests/unit/agent/router.test.ts new file mode 100644 index 0000000..9ab8e30 --- /dev/null +++ b/tests/unit/agent/router.test.ts @@ -0,0 +1,212 @@ +/** + * Unit tests for src/agent/router.ts + * + * Scanner functions and Prisma are mocked — no real DB or network calls. + */ + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +// Mock scanner dependency +jest.mock('../../../src/agent/scanner', () => ({ + scanAllProtocols: jest.fn(), + getCurrentOnChainApy: jest.fn(), + getBestProtocol: jest.fn(), +})); + +// Mock Prisma used by logAgentAction +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => ({ + user: { + findMany: jest.fn().mockResolvedValue([{ id: 'test-user-id' }]), + }, + agentLog: { + create: jest.fn().mockResolvedValue({}), + }, + })), +})); + +import { + compareProtocols, + executeRebalanceIfNeeded, + getThresholds, +} from '../../../src/agent/router'; +import { + scanAllProtocols, + getCurrentOnChainApy, +} from '../../../src/agent/scanner'; + +const mockScan = scanAllProtocols as jest.Mock; +const mockApy = getCurrentOnChainApy as jest.Mock; + +// A Blend protocol stub with a very high APY used to trigger rebalances +const blendProtocol = { + name: 'Blend', + apy: 8.0, + tvl: 50_000_000, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, +}; + +// A marginal improvement that does NOT exceed the 0.5% threshold after costs +const marginalProtocol = { + ...blendProtocol, + apy: 4.3, // current = 4.0 → raw gain ≈ 0.3 → net < 0.5 +}; + +describe('Agent Router', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── compareProtocols ────────────────────────────────────────────────────── + + describe('compareProtocols()', () => { + it('returns null when current APY cannot be fetched', async () => { + mockApy.mockResolvedValue(null); + const result = await compareProtocols('Blend'); + expect(result).toBeNull(); + }); + + it('returns null when no protocols are available', async () => { + mockApy.mockResolvedValue(4.0); + mockScan.mockResolvedValue([]); + const result = await compareProtocols('Blend'); + expect(result).toBeNull(); + }); + + it('sets shouldRebalance=false when net improvement is below threshold', async () => { + mockApy.mockResolvedValue(4.0); + mockScan.mockResolvedValue([marginalProtocol]); + const result = await compareProtocols('Stellar DEX'); + expect(result).not.toBeNull(); + expect(result!.shouldRebalance).toBe(false); + }); + + it('sets shouldRebalance=true when net improvement clearly exceeds threshold', async () => { + mockApy.mockResolvedValue(2.0); + mockScan.mockResolvedValue([blendProtocol]); + // Pass a large amount so gas fee % is negligible + const result = await compareProtocols( + 'Stellar DEX', + '100000000000000000000000', + ); + expect(result!.shouldRebalance).toBe(true); + }); + + it('sets shouldRebalance=false when best protocol is the same as current', async () => { + mockApy.mockResolvedValue(4.0); + mockScan.mockResolvedValue([{ ...blendProtocol, apy: 10.0 }]); + // currentProtocol = 'Blend', best = 'Blend' → same protocol + const result = await compareProtocols('Blend', '100000000000000000000000'); + expect(result!.shouldRebalance).toBe(false); + }); + + it('includes both current and best protocol data in result', async () => { + mockApy.mockResolvedValue(3.0); + mockScan.mockResolvedValue([blendProtocol]); + const result = await compareProtocols('Luma', '100000000000000000000000'); + expect(result!.current.name).toBe('Luma'); + expect(result!.best.name).toBe('Blend'); + }); + + it('returns null when scanner throws', async () => { + mockApy.mockResolvedValue(4.0); + mockScan.mockRejectedValue(new Error('scanner failure')); + const result = await compareProtocols('Blend'); + expect(result).toBeNull(); + }); + }); + + // ── getThresholds ───────────────────────────────────────────────────────── + + describe('getThresholds()', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns default minimumImprovement of 0.5 when env var is not set', () => { + delete process.env.REBALANCE_THRESHOLD_PERCENT; + const t = getThresholds(); + expect(t.minimumImprovement).toBe(0.5); + }); + + it('returns default maxGasPercent of 0.1 when env var is not set', () => { + delete process.env.MAX_GAS_PERCENT; + const t = getThresholds(); + expect(t.maxGasPercent).toBe(0.1); + }); + + it('reads REBALANCE_THRESHOLD_PERCENT from environment', () => { + process.env.REBALANCE_THRESHOLD_PERCENT = '1.5'; + const t = getThresholds(); + expect(t.minimumImprovement).toBe(1.5); + }); + + it('reads MAX_GAS_PERCENT from environment', () => { + process.env.MAX_GAS_PERCENT = '0.3'; + const t = getThresholds(); + expect(t.maxGasPercent).toBe(0.3); + }); + }); + + // ── executeRebalanceIfNeeded ────────────────────────────────────────────── + + describe('executeRebalanceIfNeeded()', () => { + it('returns null when net improvement is below threshold', async () => { + mockApy.mockResolvedValue(4.0); + mockScan.mockResolvedValue([marginalProtocol]); + const result = await executeRebalanceIfNeeded('Stellar DEX', [ + { id: 'pos-1', amount: '1000000' }, + ]); + expect(result).toBeNull(); + }); + + it('returns null when compareProtocols returns null', async () => { + mockApy.mockResolvedValue(null); + const result = await executeRebalanceIfNeeded('Blend', [ + { id: 'pos-1', amount: '1000000' }, + ]); + expect(result).toBeNull(); + }); + + it('returns rebalance details when improvement exceeds threshold', async () => { + mockApy.mockResolvedValue(2.0); + mockScan.mockResolvedValue([blendProtocol]); + const result = await executeRebalanceIfNeeded('Stellar DEX', [ + { id: 'pos-1', amount: '100000000000000000000000' }, + ]); + expect(result).not.toBeNull(); + expect(result!.fromProtocol).toBe('Stellar DEX'); + expect(result!.toProtocol).toBe('Blend'); + expect(result!.txHash).toBeDefined(); + }); + + it('sums amounts across multiple positions before cost calculation', async () => { + mockApy.mockResolvedValue(2.0); + mockScan.mockResolvedValue([blendProtocol]); + const result = await executeRebalanceIfNeeded('Stellar DEX', [ + { id: 'pos-1', amount: '50000000000000000000000' }, + { id: 'pos-2', amount: '50000000000000000000000' }, + ]); + // Combined = 100 000… should still cross threshold + expect(result).not.toBeNull(); + }); + + it('returns null when scanner throws during check', async () => { + mockApy.mockRejectedValue(new Error('network error')); + const result = await executeRebalanceIfNeeded('Blend', [ + { id: 'pos-1', amount: '100000000000000000000000' }, + ]); + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/agent/scanner.test.ts b/tests/unit/agent/scanner.test.ts new file mode 100644 index 0000000..cb41e4d --- /dev/null +++ b/tests/unit/agent/scanner.test.ts @@ -0,0 +1,192 @@ +/** + * Unit tests for src/agent/scanner.ts + * + * All Prisma database calls are mocked — no real DB is used. + */ + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@prisma/client', () => ({ + PrismaClient: jest.fn().mockImplementation(() => ({ + protocolRate: { + create: jest.fn().mockResolvedValue({}), + findFirst: jest.fn(), + }, + })), + Prisma: { + Decimal: class { + private _v: number; + constructor(v: unknown) { + this._v = Number(v); + } + toNumber() { + return this._v; + } + }, + }, +})); + +import { PrismaClient } from '@prisma/client'; +import { + scanAllProtocols, + getCurrentOnChainApy, + getBestProtocol, +} from '../../../src/agent/scanner'; + +// Capture the mock Prisma instance eagerly at module-load time, before any +// jest.clearAllMocks() call can wipe mock.results. +// scanner.ts runs `new PrismaClient()` at the top level when first imported, +// so mock.results[0] is populated here. +const _prismaInstance = (PrismaClient as jest.Mock).mock.results[0] + ?.value as { protocolRate: { create: jest.Mock; findFirst: jest.Mock } }; + +function getMockPrisma() { + return _prismaInstance; +} + +describe('Agent Scanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Re-attach default implementations after clearAllMocks + const p = getMockPrisma(); + if (p) { + p.protocolRate.create.mockResolvedValue({}); + p.protocolRate.findFirst.mockResolvedValue(null); + } + }); + + // ── scanAllProtocols ────────────────────────────────────────────────────── + + describe('scanAllProtocols()', () => { + it('returns a non-empty list of protocols', async () => { + const protocols = await scanAllProtocols(); + expect(protocols.length).toBeGreaterThan(0); + }); + + it('returns protocols sorted by APY descending', async () => { + const protocols = await scanAllProtocols(); + for (let i = 1; i < protocols.length; i++) { + expect(protocols[i - 1].apy).toBeGreaterThanOrEqual(protocols[i].apy); + } + }); + + it('includes Blend, Stellar DEX and Luma by name', async () => { + const protocols = await scanAllProtocols(); + const names = protocols.map((p) => p.name); + expect(names).toContain('Blend'); + expect(names).toContain('Stellar DEX'); + expect(names).toContain('Luma'); + }); + + it('Blend has the highest APY (4.25)', async () => { + const protocols = await scanAllProtocols(); + expect(protocols[0].name).toBe('Blend'); + expect(protocols[0].apy).toBe(4.25); + }); + + it('every protocol has required fields', async () => { + const protocols = await scanAllProtocols(); + for (const p of protocols) { + expect(p).toHaveProperty('name'); + expect(p).toHaveProperty('apy'); + expect(p).toHaveProperty('assetSymbol'); + expect(p).toHaveProperty('lastUpdated'); + expect(p).toHaveProperty('isAvailable'); + } + }); + + it('filters out any protocol whose TVL is below 10 000', async () => { + const protocols = await scanAllProtocols(); + for (const p of protocols) { + if (p.tvl !== undefined) { + expect(p.tvl).toBeGreaterThanOrEqual(10_000); + } + } + }); + + it('persists each protocol to protocolRate table', async () => { + const protocols = await scanAllProtocols(); + const p = getMockPrisma(); + expect(p.protocolRate.create).toHaveBeenCalledTimes(protocols.length); + }); + + it('passes correct data shape to protocolRate.create', async () => { + await scanAllProtocols(); + const p = getMockPrisma(); + const firstCall = p.protocolRate.create.mock.calls[0][0]; + expect(firstCall.data).toMatchObject({ + protocolName: expect.any(String), + assetSymbol: 'USDC', + network: 'TESTNET', + }); + }); + + it('gracefully continues when protocolRate.create throws', async () => { + const p = getMockPrisma(); + p.protocolRate.create.mockRejectedValueOnce(new Error('DB write failed')); + // Should not throw — error is caught inside saveProtocolRates + await expect(scanAllProtocols()).resolves.toBeDefined(); + }); + }); + + // ── getCurrentOnChainApy ────────────────────────────────────────────────── + + describe('getCurrentOnChainApy()', () => { + it('returns the APY number from the latest DB row', async () => { + const p = getMockPrisma(); + p.protocolRate.findFirst.mockResolvedValue({ + supplyApy: { toNumber: () => 4.25 }, + }); + const apy = await getCurrentOnChainApy('Blend'); + expect(apy).toBe(4.25); + }); + + it('queries by protocolName and USDC assetSymbol', async () => { + const p = getMockPrisma(); + p.protocolRate.findFirst.mockResolvedValue({ + supplyApy: { toNumber: () => 3.5 }, + }); + await getCurrentOnChainApy('Luma'); + expect(p.protocolRate.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ protocolName: 'Luma' }), + }), + ); + }); + + it('returns null when no rate row exists', async () => { + const p = getMockPrisma(); + p.protocolRate.findFirst.mockResolvedValue(null); + const apy = await getCurrentOnChainApy('Unknown'); + expect(apy).toBeNull(); + }); + + it('returns null when the DB throws', async () => { + const p = getMockPrisma(); + p.protocolRate.findFirst.mockRejectedValue(new Error('connection lost')); + const apy = await getCurrentOnChainApy('Blend'); + expect(apy).toBeNull(); + }); + }); + + // ── getBestProtocol ─────────────────────────────────────────────────────── + + describe('getBestProtocol()', () => { + it('returns the protocol with the highest APY', async () => { + const best = await getBestProtocol(); + expect(best).not.toBeNull(); + expect(best?.name).toBe('Blend'); + }); + + it('returns a YieldProtocol with an apy field', async () => { + const best = await getBestProtocol(); + expect(typeof best?.apy).toBe('number'); + }); + }); +}); diff --git a/tests/unit/middleware/errorHandler.test.ts b/tests/unit/middleware/errorHandler.test.ts new file mode 100644 index 0000000..f436e68 --- /dev/null +++ b/tests/unit/middleware/errorHandler.test.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { errorHandler } from '../../../src/middleware/errorHandler'; + +function makeReq(overrides: Partial = {}): Request { + return { path: '/test', method: 'GET', ...overrides } as Request; +} + +function makeRes(): jest.Mocked> & Response { + const res = {} as any; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +const next: NextFunction = jest.fn(); + +describe('errorHandler middleware', () => { + it('responds with status 500', () => { + const res = makeRes(); + errorHandler(new Error('boom'), makeReq(), res, next); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('responds with generic error key', () => { + const res = makeRes(); + errorHandler(new Error('boom'), makeReq(), res, next); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Internal server error' }), + ); + }); + + it('hides error message outside development', () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const res = makeRes(); + errorHandler(new Error('secret'), makeReq(), res, next); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.message).toBeUndefined(); + process.env.NODE_ENV = prev; + }); + + it('exposes error message in development', () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const res = makeRes(); + errorHandler(new Error('debug detail'), makeReq(), res, next); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.message).toBe('debug detail'); + process.env.NODE_ENV = prev; + }); +}); diff --git a/tests/unit/nlp/responses.test.ts b/tests/unit/nlp/responses.test.ts new file mode 100644 index 0000000..2edc21a --- /dev/null +++ b/tests/unit/nlp/responses.test.ts @@ -0,0 +1,47 @@ +import { responses } from '../../../src/nlp/responses'; + +describe('NLP Responses', () => { + describe('deposit()', () => { + it('returns message with amount only', () => { + expect(responses.deposit(100)).toBe('You want to deposit 100.'); + }); + + it('returns message with amount and currency', () => { + expect(responses.deposit(100, 'USDC')).toBe('You want to deposit 100 USDC.'); + }); + }); + + describe('withdraw()', () => { + it('returns withdraw-all message when all=true', () => { + expect(responses.withdraw(undefined, undefined, true)).toBe('You want to withdraw everything.'); + }); + + it('returns message with amount', () => { + expect(responses.withdraw(50)).toBe('You want to withdraw 50.'); + }); + + it('returns message with amount and currency', () => { + expect(responses.withdraw(50, 'XLM')).toBe('You want to withdraw 50 XLM.'); + }); + }); + + describe('balance()', () => { + it('returns the balance message', () => { + expect(responses.balance()).toBe('Here is your current balance.'); + }); + }); + + describe('help()', () => { + it('returns a message containing deposit and withdraw hints', () => { + const msg = responses.help(); + expect(msg).toContain('deposit'); + expect(msg).toContain('withdraw'); + }); + }); + + describe('unrecognized()', () => { + it('returns an apology message', () => { + expect(responses.unrecognized()).toContain("couldn't understand"); + }); + }); +}); diff --git a/tests/unit/stellar/client.test.ts b/tests/unit/stellar/client.test.ts new file mode 100644 index 0000000..027c386 --- /dev/null +++ b/tests/unit/stellar/client.test.ts @@ -0,0 +1,171 @@ +/** + * Unit tests for src/stellar/client.ts + * + * All Stellar SDK interactions are mocked — no real blockchain calls. + */ + +// ─── Mock Stellar SDK ──────────────────────────────────────────────────────── + +const mockSendTransaction = jest.fn(); +const mockGetTransaction = jest.fn(); +const mockRpcServerInstance = { + sendTransaction: mockSendTransaction, + getTransaction: mockGetTransaction, +}; + +jest.mock('@stellar/stellar-sdk', () => ({ + rpc: { + Server: jest.fn().mockImplementation(() => mockRpcServerInstance), + }, + Keypair: { + fromSecret: jest.fn().mockReturnValue({ + publicKey: () => 'GMOCK_PUBLIC_KEY', + }), + fromPublicKey: jest.fn(), + }, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + Transaction: jest.fn(), + TransactionBuilder: jest.fn(), +})); + +import { rpc, Keypair } from '@stellar/stellar-sdk'; +import { + getRpcServer, + getNetworkPassphrase, + getAgentKeypair, + submitTransaction, + waitForConfirmation, +} from '../../../src/stellar/client'; + +describe('Stellar Client', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Restore sendTransaction default + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'mock-hash' }); + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + }); + + // ── getRpcServer ────────────────────────────────────────────────────────── + + describe('getRpcServer()', () => { + it('returns an rpc.Server instance', () => { + const server = getRpcServer(); + expect(server).toBeDefined(); + }); + + it('returns the same cached instance on repeated calls', () => { + const s1 = getRpcServer(); + const s2 = getRpcServer(); + expect(s1).toBe(s2); + }); + }); + + // ── getNetworkPassphrase ────────────────────────────────────────────────── + + describe('getNetworkPassphrase()', () => { + it('returns a non-empty string', () => { + const passphrase = getNetworkPassphrase(); + expect(typeof passphrase).toBe('string'); + expect(passphrase.length).toBeGreaterThan(0); + }); + }); + + // ── getAgentKeypair ─────────────────────────────────────────────────────── + + describe('getAgentKeypair()', () => { + it('returns a keypair when STELLAR_AGENT_SECRET is configured', () => { + process.env.STELLAR_AGENT_SECRET = 'SMOCK_SECRET_KEY_FOR_TESTS_ONLY'; + expect(() => getAgentKeypair()).not.toThrow(); + }); + + it('the returned keypair exposes publicKey()', () => { + process.env.STELLAR_AGENT_SECRET = 'SMOCK_SECRET_KEY_FOR_TESTS_ONLY'; + const keypair = getAgentKeypair(); + expect(typeof keypair.publicKey()).toBe('string'); + }); + }); + + // ── submitTransaction ───────────────────────────────────────────────────── + + describe('submitTransaction()', () => { + it('resolves with transaction hash on success', async () => { + mockSendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'abc123hash', + }); + const tx = {} as any; // real Transaction not needed — SDK is mocked + const hash = await submitTransaction(tx); + expect(hash).toBe('abc123hash'); + }); + + it('throws when server returns ERROR status', async () => { + mockSendTransaction.mockResolvedValue({ + status: 'ERROR', + errorResult: { toXDR: () => 'AAAAAAA=' }, + }); + await expect(submitTransaction({} as any)).rejects.toThrow( + 'Transaction failed', + ); + }); + + it('throws when server.sendTransaction rejects', async () => { + mockSendTransaction.mockRejectedValue(new Error('network unreachable')); + await expect(submitTransaction({} as any)).rejects.toThrow( + 'Failed to submit transaction', + ); + }); + + it('calls server.sendTransaction with the provided transaction', async () => { + mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'x' }); + const tx = { fake: true } as any; + await submitTransaction(tx); + expect(mockSendTransaction).toHaveBeenCalledWith(tx); + }); + }); + + // ── waitForConfirmation ─────────────────────────────────────────────────── + + describe('waitForConfirmation()', () => { + it('resolves with success status when transaction succeeds', async () => { + mockGetTransaction.mockResolvedValue({ status: 'SUCCESS', ledger: 999 }); + const result = await waitForConfirmation('abc123', 5_000); + expect(result.status).toBe('success'); + expect(result.hash).toBe('abc123'); + expect(result.ledger).toBe(999); + }); + + it('resolves with failed status when transaction fails', async () => { + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); + const result = await waitForConfirmation('failhash', 5_000); + expect(result.status).toBe('failed'); + }); + + it('throws on timeout when transaction never confirms', async () => { + // Always return NOT_FOUND so the loop runs until timeout + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + await expect( + waitForConfirmation('timedouthash', 100), + ).rejects.toThrow(/timeout/i); + }); + + it('throws when getTransaction rejects', async () => { + mockGetTransaction.mockRejectedValue(new Error('RPC down')); + await expect( + waitForConfirmation('errhash', 5_000), + ).rejects.toThrow('Error polling transaction'); + }); + + it('polls until a definitive status is received', async () => { + mockGetTransaction + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'SUCCESS', ledger: 42 }); + const result = await waitForConfirmation('polledhash', 10_000); + expect(result.status).toBe('success'); + expect(mockGetTransaction).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/tests/unit/whatsapp/handler.test.ts b/tests/unit/whatsapp/handler.test.ts new file mode 100644 index 0000000..e59cafe --- /dev/null +++ b/tests/unit/whatsapp/handler.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for src/whatsapp/handler.ts + * userManager and NLP parser are fully mocked. + */ + +jest.mock('../../../src/whatsapp/userManager', () => ({ + normalizePhone: jest.fn((p: string) => p.replace(/^whatsapp:/i, '').trim()), + createOrGetUser: jest.fn(), + generateOtp: jest.fn().mockReturnValue('654321'), + verifyOtp: jest.fn(), + getBalance: jest.fn(), + getUserWalletAddress: jest.fn(), + incrementBalance: jest.fn(), + decrementBalance: jest.fn(), +})); + +jest.mock('../../../src/nlp/parser', () => ({ + parseIntent: jest.fn(), +})); + +import { handleWhatsAppMessage } from '../../../src/whatsapp/handler'; +import * as userManagerModule from '../../../src/whatsapp/userManager'; +import { parseIntent } from '../../../src/nlp/parser'; + +const mockUM = userManagerModule as jest.Mocked; +const mockParseIntent = parseIntent as jest.Mock; + +const PHONE = 'whatsapp:+10000000099'; + +const VERIFIED_USER = { + id: 'user-handler-1', + phone: '+10000000099', + verified: true, + walletAddress: 'GABC999', + balance: 100, +}; + +const UNVERIFIED_USER = { ...VERIFIED_USER, verified: false }; + +describe('handleWhatsAppMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUM.normalizePhone.mockImplementation((p) => p.replace(/^whatsapp:/i, '').trim()); + mockUM.createOrGetUser.mockResolvedValue(VERIFIED_USER as any); + mockUM.getBalance.mockReturnValue(100); + mockUM.getUserWalletAddress.mockReturnValue('GABC999'); + mockUM.decrementBalance.mockReturnValue(50); + mockUM.generateOtp.mockReturnValue('654321'); + }); + + // ── Unverified user — OTP flow ──────────────────────────────────────────── + + describe('unverified user', () => { + beforeEach(() => { + mockUM.createOrGetUser.mockResolvedValue(UNVERIFIED_USER as any); + }); + + it('sends an OTP when the message is not a 6-digit code', async () => { + const { body } = await handleWhatsAppMessage(PHONE, 'hello'); + expect(body).toContain('654321'); + }); + + it('welcomes user and shows wallet after valid OTP', async () => { + mockUM.verifyOtp.mockReturnValue(true); + const { body } = await handleWhatsAppMessage(PHONE, '654321'); + expect(body).toContain('verified'); + expect(body).toContain('GABC999'); + }); + + it('rejects an invalid OTP code', async () => { + mockUM.verifyOtp.mockReturnValue(false); + const { body } = await handleWhatsAppMessage(PHONE, '000000'); + expect(body).toContain('Invalid'); + }); + }); + + // ── Verified user — intent routing ──────────────────────────────────────── + + describe('verified user', () => { + it('handles balance intent', async () => { + mockParseIntent.mockResolvedValue({ action: 'balance' }); + const { body } = await handleWhatsAppMessage(PHONE, 'balance'); + expect(body).toContain('100.00 XLM'); + expect(body).toContain('GABC999'); + }); + + it('handles deposit intent with a valid amount', async () => { + mockParseIntent.mockResolvedValue({ action: 'deposit', amount: 75 }); + const { body } = await handleWhatsAppMessage(PHONE, 'deposit 75'); + expect(body).toContain('75.00 XLM'); + expect(body).toContain('GABC999'); + }); + + it('prompts for amount when deposit amount is missing', async () => { + mockParseIntent.mockResolvedValue({ action: 'deposit', amount: 0 }); + const { body } = await handleWhatsAppMessage(PHONE, 'deposit'); + expect(body).toContain('specify'); + }); + + it('handles withdraw intent within balance', async () => { + mockParseIntent.mockResolvedValue({ action: 'withdraw', amount: 40 }); + const { body } = await handleWhatsAppMessage(PHONE, 'withdraw 40'); + expect(body).toContain('40.00 XLM'); + }); + + it('handles withdraw-all intent', async () => { + mockParseIntent.mockResolvedValue({ action: 'withdraw', all: true }); + const { body } = await handleWhatsAppMessage(PHONE, 'withdraw all'); + expect(body).toBeDefined(); + }); + + it('returns insufficient-funds message when amount exceeds balance', async () => { + mockParseIntent.mockResolvedValue({ action: 'withdraw', amount: 9999 }); + const { body } = await handleWhatsAppMessage(PHONE, 'withdraw 9999'); + expect(body).toContain('only have'); + }); + + it('prompts for amount when withdraw amount is missing', async () => { + mockParseIntent.mockResolvedValue({ action: 'withdraw', amount: 0 }); + const { body } = await handleWhatsAppMessage(PHONE, 'withdraw'); + expect(body).toContain('specify'); + }); + + it('handles help intent', async () => { + mockParseIntent.mockResolvedValue({ action: 'help' }); + const { body } = await handleWhatsAppMessage(PHONE, 'help'); + expect(body).toContain('deposit'); + }); + + it('handles unknown intent', async () => { + mockParseIntent.mockResolvedValue({ action: 'unknown' }); + const { body } = await handleWhatsAppMessage(PHONE, 'gibberish'); + expect(body).toContain("didn't understand"); + }); + + it('falls through to unknown for unrecognised actions', async () => { + mockParseIntent.mockResolvedValue({ action: 'anything_else' }); + const { body } = await handleWhatsAppMessage(PHONE, 'anything'); + expect(body).toContain("didn't understand"); + }); + }); +}); diff --git a/tests/unit/whatsapp/userManager.test.ts b/tests/unit/whatsapp/userManager.test.ts new file mode 100644 index 0000000..6def44e --- /dev/null +++ b/tests/unit/whatsapp/userManager.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for src/whatsapp/userManager.ts + * Mocks the Stellar wallet so no real keys are generated. + */ + +jest.mock('../../../src/stellar/wallet', () => ({ + createCustodialWallet: jest.fn().mockResolvedValue({ publicKey: 'GMOCKPUBKEY' }), + getWalletByUserId: jest.fn().mockResolvedValue({ publicKey: 'GMOCKPUBKEY', secretKey: 'SMOCKSECRET' }), +})); + +import { + normalizePhone, + createOrGetUser, + generateOtp, + verifyOtp, + getUserWalletAddress, + getBalance, + incrementBalance, + decrementBalance, + getUserForTests, + clearUsersForTests, + ensureWalletDecrypted, +} from '../../../src/whatsapp/userManager'; + +describe('WhatsApp UserManager', () => { + beforeEach(() => { + clearUsersForTests(); + }); + + // ── normalizePhone ──────────────────────────────────────────────────────── + + describe('normalizePhone()', () => { + it('strips whatsapp: prefix', () => { + expect(normalizePhone('whatsapp:+1234567890')).toBe('+1234567890'); + }); + + it('is case-insensitive for the prefix', () => { + expect(normalizePhone('WhatsApp:+1234567890')).toBe('+1234567890'); + }); + + it('leaves a plain phone number unchanged', () => { + expect(normalizePhone('+1234567890')).toBe('+1234567890'); + }); + }); + + // ── createOrGetUser ─────────────────────────────────────────────────────── + + describe('createOrGetUser()', () => { + it('creates a new user with walletAddress and verified=false', async () => { + const user = await createOrGetUser('+1111111111'); + expect(user.phone).toBe('+1111111111'); + expect(user.verified).toBe(false); + expect(user.walletAddress).toBe('GMOCKPUBKEY'); + expect(user.balance).toBe(0); + }); + + it('returns the same user on subsequent calls', async () => { + const u1 = await createOrGetUser('+2222222222'); + const u2 = await createOrGetUser('+2222222222'); + expect(u1.id).toBe(u2.id); + }); + }); + + // ── generateOtp / verifyOtp ─────────────────────────────────────────────── + + describe('generateOtp() and verifyOtp()', () => { + const PHONE = '+3333333333'; + + beforeEach(async () => { + await createOrGetUser(PHONE); + }); + + it('generates a 6-digit code', () => { + const code = generateOtp(PHONE); + expect(code).toMatch(/^\d{6}$/); + }); + + it('throws when phone has no user', () => { + expect(() => generateOtp('+0000000000')).toThrow('User not found'); + }); + + it('verifyOtp returns true for the correct code', () => { + const code = generateOtp(PHONE); + expect(verifyOtp(PHONE, code)).toBe(true); + }); + + it('marks user as verified after successful OTP', () => { + const code = generateOtp(PHONE); + verifyOtp(PHONE, code); + expect(getUserForTests(PHONE)?.verified).toBe(true); + }); + + it('verifyOtp returns false for an incorrect code', () => { + generateOtp(PHONE); + expect(verifyOtp(PHONE, '000000')).toBe(false); + }); + + it('verifyOtp returns false for unknown phone', () => { + expect(verifyOtp('+9999999999', '123456')).toBe(false); + }); + + it('verifyOtp returns false when OTP has expired', () => { + const user = getUserForTests(PHONE)!; + generateOtp(PHONE); + // Force expiry + user.otp!.expiresAt = Date.now() - 1_000; + expect(verifyOtp(PHONE, user.otp!.code)).toBe(false); + }); + }); + + // ── getBalance / incrementBalance / decrementBalance ────────────────────── + + describe('balance operations', () => { + const PHONE = '+4444444444'; + + beforeEach(async () => { + await createOrGetUser(PHONE); + }); + + it('getBalance returns 0 for a new user', () => { + expect(getBalance(PHONE)).toBe(0); + }); + + it('getBalance returns null for unknown user', () => { + expect(getBalance('+0000000001')).toBeNull(); + }); + + it('incrementBalance increases the balance', () => { + const newBal = incrementBalance(PHONE, 100); + expect(newBal).toBe(100); + expect(getBalance(PHONE)).toBe(100); + }); + + it('incrementBalance throws for unknown user', () => { + expect(() => incrementBalance('+0000000002', 10)).toThrow('User not found'); + }); + + it('decrementBalance decreases the balance', () => { + incrementBalance(PHONE, 50); + const newBal = decrementBalance(PHONE, 20); + expect(newBal).toBe(30); + }); + + it('decrementBalance clamps to zero (never negative)', () => { + const newBal = decrementBalance(PHONE, 9999); + expect(newBal).toBe(0); + }); + }); + + // ── getUserWalletAddress ────────────────────────────────────────────────── + + describe('getUserWalletAddress()', () => { + it('returns wallet address for a known user', async () => { + await createOrGetUser('+5555555555'); + expect(getUserWalletAddress('+5555555555')).toBe('GMOCKPUBKEY'); + }); + + it('returns null for an unknown user', () => { + expect(getUserWalletAddress('+0000000003')).toBeNull(); + }); + }); + + // ── ensureWalletDecrypted ───────────────────────────────────────────────── + + describe('ensureWalletDecrypted()', () => { + it('resolves without error for a known user', async () => { + await createOrGetUser('+6666666666'); + await expect(ensureWalletDecrypted('+6666666666')).resolves.not.toThrow(); + }); + + it('throws for an unknown user', async () => { + await expect(ensureWalletDecrypted('+0000000004')).rejects.toThrow('User not found'); + }); + }); +});