Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules
.env
dist
logs/*.log
coverage/


.agents/
Expand Down
16 changes: 0 additions & 16 deletions jest.config.js

This file was deleted.

49 changes: 49 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Config } from 'jest';

const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/?(*.)+(test).ts'],
setupFiles: ['<rootDir>/tests/setupEnv.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/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;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 5 additions & 7 deletions src/controllers/auth-controller.ts
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -110,13 +108,13 @@ export async function verify(req: Request, res: Response): Promise<void> {

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,
Expand All @@ -143,7 +141,7 @@ export async function verify(req: Request, res: Response): Promise<void> {
}

// 7. Persist session
await prisma.session.create({
await db.session.create({
data: {
userId: user.id,
token,
Expand Down Expand Up @@ -179,7 +177,7 @@ export async function logout(req: Request, res: Response): Promise<void> {
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) {
Expand Down
8 changes: 3 additions & 5 deletions src/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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 },
});
Expand All @@ -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;
}
Expand Down
103 changes: 103 additions & 0 deletions tests/helpers/factories.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<string, unknown> = {},
) {
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<string, unknown> = {},
) {
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<string, unknown> = {},
) {
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<string, unknown> = {},
) {
return {
id: crypto.randomUUID(),
positionId,
apy: 4.25,
yieldAmount: 10,
principalAmount: 1000,
snapshotAt: new Date(),
...overrides,
};
}
101 changes: 101 additions & 0 deletions tests/helpers/testDb.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaClient> {
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<void> {
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<unknown>[]) =>
Promise.all(ops),
),
};
}
Loading