Skip to content

Commit 4027cf3

Browse files
authored
Merge pull request #197 from williamsokperez-arch/integration
feat: built a comprehensive integration testing suite for the FuTuRe backend
2 parents a035701 + f7b79e0 commit 4027cf3

11 files changed

Lines changed: 559 additions & 7 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Integration Tests
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
branches: [ main, master ]
8+
9+
jobs:
10+
integration-test:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
postgres:
15+
image: postgres:16-alpine
16+
env:
17+
POSTGRES_DB: future_test
18+
POSTGRES_USER: future_admin
19+
POSTGRES_PASSWORD: test_password
20+
ports:
21+
- 5432:5432
22+
options: >-
23+
--health-cmd pg_isready
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 5
27+
28+
steps:
29+
- uses: actions/checkout@v4
30+
31+
- name: Use Node.js
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: '20'
35+
cache: 'npm'
36+
37+
- name: Install dependencies
38+
run: npm install
39+
40+
- name: Run Prisma Migrations
41+
run: npx prisma migrate deploy
42+
working-directory: ./backend
43+
env:
44+
DATABASE_URL: postgresql://future_admin:test_password@localhost:5432/future_test
45+
46+
- name: Run Integration Tests
47+
run: npm run test:integration:all
48+
working-directory: ./backend
49+
env:
50+
DATABASE_URL: postgresql://future_admin:test_password@localhost:5432/future_test
51+
NODE_ENV: test
52+
JWT_SECRET: test-secret
53+
STELLAR_NETWORK: testnet
54+
HORIZON_URL: https://horizon-testnet.stellar.org
55+
56+
- name: Upload Test Reports
57+
uses: actions/upload-artifact@v4
58+
if: always()
59+
with:
60+
name: integration-test-reports
61+
path: backend/test-reports/
62+
retention-days: 30

TESTING.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,39 @@ To run tests in watch mode (best for development):
2828
npx vitest
2929
```
3030

31-
To run tests in watch mode:
31+
## 3. Integration Testing Suite
32+
33+
We maintain a comprehensive integration testing suite that covers multiple layers:
34+
35+
- **Service Integration**: Tests internal service logic and interactions (e.g., `TransactionService` + `Cache` + `EventStore`).
36+
- **Third-party Integration**: Verifies interactions with external APIs like CoinGecko and Stellar Horizon.
37+
- **API Integration**: End-to-end testing of REST endpoints, including authentication and validation.
38+
- **Database Integration**: Real Prisma/Postgres testing for data persistence and schema integrity.
39+
40+
### Running Integration Tests
41+
42+
To run all integration tests:
3243
```bash
33-
npx vitest
44+
cd backend
45+
npm run test:integration:all
46+
```
47+
48+
### Environment Setup
49+
50+
Database integration tests require a running Postgres instance. You can start one using Docker:
51+
```bash
52+
docker-compose -f docker-compose.test.yml up -d
3453
```
54+
Then, ensure your `DATABASE_URL` environment variable points to the test database:
55+
`postgresql://future_admin:test_password@localhost:5433/future_test`
56+
57+
### Automated Testing (CI)
58+
59+
Integration tests are automatically run on every Pull Request to `main` or `master` via GitHub Actions. The CI environment handles the database setup and migration automatically.
60+
61+
---
3562

36-
## 3. Testing Best Practices
63+
## 4. Testing Best Practices
3764

3865
- **AAA Pattern**: Follow the **Arrange, Act, Assert** structure for all tests.
3966
- **Isolation**: Each test should be independent. Do not rely on the state from a previous test.

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"load-test:endpoints": "k6 run load-tests/k6/scenarios/api-endpoints.js",
1212
"load-test:concurrent": "k6 run load-tests/k6/scenarios/concurrent-users.js",
1313
"test:integration": "vitest run tests/stellar.integration.test.js",
14+
"test:db": "vitest run tests/database.framework.test.js",
15+
"test:integration:all": "vitest run tests/*.integration.test.js"
1416
"test:db": "vitest run tests/database.framework.test.js"
1517
},
1618
"dependencies": {

backend/src/services/stellar.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ export async function sendPayment(sourceSecret, destination, amount, assetCode =
106106
let result;
107107
try {
108108
result = await getHorizonServer().submitTransaction(transaction);
109-
result = await server.submitTransaction(transaction);
110109
} catch (err) {
111110
logger.error('stellar.sendPayment.failed', { source: sourcePublicKey, destination, amount, assetCode, error: err.message });
112111
throw err;
@@ -242,15 +241,15 @@ export async function getTransactions(publicKey, { cursor, limit = 10, type, dat
242241
}
243242

244243
export async function getFeeStats() {
245-
const stats = await server.feeStats();
244+
const stats = await getHorizonServer().feeStats();
246245
const feeStroops = parseInt(stats.fee_charged?.p50 ?? StellarSDK.BASE_FEE);
247246
const feeXLM = feeStroops / 1e7;
248247

249248
// Fetch XLM/USD price via Stellar SDEX (XLM/USDC order book)
250249
let xlmUsd = null;
251250
try {
252251
const usdc = new StellarSDK.Asset('USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN');
253-
const book = await server.orderbook(StellarSDK.Asset.native(), usdc).limit(1).call();
252+
const book = await getHorizonServer().orderbook(StellarSDK.Asset.native(), usdc).limit(1).call();
254253
const ask = parseFloat(book.asks?.[0]?.price);
255254
if (ask > 0) xlmUsd = ask;
256255
} catch (_) {}
@@ -290,7 +289,7 @@ export async function getExchangeRate(from, to) {
290289
try {
291290
const fromAsset = from === 'XLM' ? StellarSDK.Asset.native() : new StellarSDK.Asset(from, getIssuer(from));
292291
const toAsset = to === 'XLM' ? StellarSDK.Asset.native() : new StellarSDK.Asset(to, getIssuer(to));
293-
const orderbook = await server.orderbook(fromAsset, toAsset).call();
292+
const orderbook = await getHorizonServer().orderbook(fromAsset, toAsset).call();
294293
const bestAsk = orderbook.asks?.[0]?.price;
295294
return bestAsk ? parseFloat(bestAsk) : null;
296295
} catch (err) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import request from 'supertest';
3+
import app from './helpers/full-app.js';
4+
5+
describe('API Integration: Auth + User Flows', () => {
6+
const testUser = {
7+
username: 'testuser' + Math.floor(Math.random() * 10000),
8+
password: 'Password123!',
9+
};
10+
11+
let accessToken = '';
12+
13+
it('should register a new user', async () => {
14+
const res = await request(app)
15+
.post('/api/auth/register')
16+
.send(testUser);
17+
18+
expect(res.status).toBe(201);
19+
expect(res.body.user).toHaveProperty('id');
20+
expect(res.body.user.username).toBe(testUser.username);
21+
});
22+
23+
it('should login and return access/refresh tokens', async () => {
24+
const res = await request(app)
25+
.post('/api/auth/login')
26+
.send(testUser);
27+
28+
expect(res.status).toBe(200);
29+
expect(res.body).toHaveProperty('accessToken');
30+
expect(res.body).toHaveProperty('refreshToken');
31+
accessToken = res.body.accessToken;
32+
});
33+
34+
it('should access protected profile with a valid token', async () => {
35+
const res = await request(app)
36+
.get('/api/auth/profile')
37+
.set('Authorization', `Bearer ${accessToken}`);
38+
39+
expect(res.status).toBe(200);
40+
expect(res.body.username).toBe(testUser.username);
41+
});
42+
43+
it('should return 401 for accessing profile without token', async () => {
44+
const res = await request(app).get('/api/auth/profile');
45+
expect(res.status).toBe(401);
46+
});
47+
48+
it('should return 422 for invalid registration data', async () => {
49+
const res = await request(app)
50+
.post('/api/auth/register')
51+
.send({ username: 'tu', password: '1' }); // too short
52+
53+
expect(res.status).toBe(422);
54+
expect(res.body.errors).toBeInstanceOf(Array);
55+
});
56+
});
57+
58+
describe('API Integration: Health & Network', () => {
59+
it('should return health status', async () => {
60+
const res = await request(app).get('/health');
61+
expect(res.status).toBe(200);
62+
expect(res.body.status).toBe('ok');
63+
});
64+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2+
import prisma, { connectDB, disconnectDB } from '../src/db/client.js';
3+
4+
// Skip if no DATABASE_URL is set
5+
const hasDb = !!process.env.DATABASE_URL;
6+
7+
describe.runIf(hasDb)('Database Integration: Prisma + Postgres', () => {
8+
beforeAll(async () => {
9+
await connectDB();
10+
// In a real integration test, we might run migrations here or use a shadow DB
11+
});
12+
13+
afterAll(async () => {
14+
await disconnectDB();
15+
});
16+
17+
it('should create and retrieve a user', async () => {
18+
const publicKey = 'G' + Math.random().toString(36).substring(2, 12).toUpperCase();
19+
20+
const user = await prisma.user.create({
21+
data: { publicKey }
22+
});
23+
24+
expect(user).toHaveProperty('id');
25+
expect(user.publicKey).toBe(publicKey);
26+
27+
const retrieved = await prisma.user.findUnique({
28+
where: { id: user.id }
29+
});
30+
expect(retrieved.publicKey).toBe(publicKey);
31+
});
32+
33+
it('should manage user settings with cascade delete', async () => {
34+
const user = await prisma.user.create({
35+
data: {
36+
publicKey: 'G' + Math.random().toString(36).substring(2, 12).toUpperCase(),
37+
settings: {
38+
create: { defaultAsset: 'USDC' }
39+
}
40+
},
41+
include: { settings: true }
42+
});
43+
44+
expect(user.settings.defaultAsset).toBe('USDC');
45+
46+
// Delete user and verify settings are gone
47+
await prisma.user.delete({ where: { id: user.id } });
48+
49+
const settings = await prisma.setting.findUnique({
50+
where: { userId: user.id }
51+
});
52+
expect(settings).toBeNull();
53+
});
54+
55+
it('should record a transaction between two users', async () => {
56+
const sender = await prisma.user.create({ data: { publicKey: 'GSENDER' + Math.random().toString(36).substring(2, 12).toUpperCase() } });
57+
const recipient = await prisma.user.create({ data: { publicKey: 'GRECIPIENT' + Math.random().toString(36).substring(2, 12).toUpperCase() } });
58+
59+
const txHash = 'hash' + Math.random().toString(36).substring(2, 12);
60+
61+
const tx = await prisma.transaction.create({
62+
data: {
63+
hash: txHash,
64+
amount: 100.50,
65+
senderId: sender.id,
66+
recipientId: recipient.id,
67+
successful: true
68+
}
69+
});
70+
71+
expect(tx.senderId).toBe(sender.id);
72+
expect(tx.recipientId).toBe(recipient.id);
73+
expect(tx.amount.toString()).toBe('100.5');
74+
});
75+
});
76+
77+
if (!hasDb) {
78+
describe('Database Integration: Prisma + Postgres', () => {
79+
it.skip('Skipped: DATABASE_URL not set', () => {});
80+
});
81+
}

backend/tests/helpers/full-app.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import express from 'express';
2+
import cors from 'cors';
3+
import stellarRoutes from '../../src/routes/stellar.js';
4+
import authRoutes from '../../src/routes/auth.js';
5+
import transactionRoutes from '../../src/routes/transactions.js';
6+
7+
// Setup environment for testing
8+
process.env.NODE_ENV = 'test';
9+
process.env.JWT_SECRET = 'test-secret';
10+
process.env.STELLAR_NETWORK = 'testnet';
11+
process.env.HORIZON_URL = 'https://horizon-testnet.stellar.org';
12+
13+
const app = express();
14+
app.use(cors());
15+
app.use(express.json());
16+
17+
// Mount routes
18+
app.use('/api/stellar', stellarRoutes);
19+
app.use('/api/auth', authRoutes);
20+
app.use('/api/transactions', transactionRoutes);
21+
22+
// Health check
23+
app.get('/health', (_req, res) => res.json({ status: 'ok', network: 'testnet' }));
24+
25+
// Global error handler for tests
26+
app.use((err, req, res, next) => {
27+
res.status(err.status || 500).json({ error: err.message });
28+
});
29+
30+
export default app;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { fileURLToPath } from 'url';
2+
import { dirname, resolve } from 'path';
3+
import fs from 'fs';
4+
5+
export default class IntegrationReporter {
6+
constructor() {
7+
this.results = [];
8+
this.startTime = Date.now();
9+
}
10+
11+
onTestFinished(test) {
12+
this.results.push({
13+
name: test.name,
14+
fullName: test.result?.state === 'fail' ? test.suite?.name + ' > ' + test.name : test.name,
15+
status: test.result?.state,
16+
duration: test.result?.duration,
17+
error: test.result?.errors?.[0]?.message
18+
});
19+
}
20+
21+
onFinished(files) {
22+
const duration = (Date.now() - this.startTime) / 1000;
23+
const passed = this.results.filter(r => r.status === 'pass').length;
24+
const failed = this.results.filter(r => r.status === 'fail').length;
25+
const skipped = this.results.filter(r => r.status === 'skip').length;
26+
27+
const summary = {
28+
timestamp: new Date().toISOString(),
29+
durationSeconds: duration,
30+
total: this.results.length,
31+
passed,
32+
failed,
33+
skipped,
34+
details: this.results
35+
};
36+
37+
const __dirname = dirname(fileURLToPath(import.meta.url));
38+
const reportDir = resolve(__dirname, '../../test-reports');
39+
40+
if (!fs.existsSync(reportDir)) {
41+
fs.mkdirSync(reportDir, { recursive: true });
42+
}
43+
44+
fs.writeFileSync(
45+
resolve(reportDir, 'integration-report.json'),
46+
JSON.stringify(summary, null, 2)
47+
);
48+
49+
console.log('\n\x1b[36mIntegration Test Summary:\x1b[39m');
50+
console.log(`\x1b[32mPassed: ${passed}\x1b[39m`);
51+
console.log(`\x1b[31mFailed: ${failed}\x1b[39m`);
52+
console.log(`\x1b[34mSkipped: ${skipped}\x1b[39m`);
53+
console.log(`Duration: ${duration.toFixed(2)}s\n`);
54+
55+
if (failed > 0) {
56+
console.log('\x1b[31mFailures:\x1b[39m');
57+
this.results.filter(r => r.status === 'fail').forEach(r => {
58+
console.log(` - ${r.fullName}: \x1b[31m${r.error}\x1b[39m`);
59+
});
60+
console.log('\n');
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)