diff --git a/src/modules/reputation/reputation.controller.ts b/src/modules/reputation/reputation.controller.ts index 899f0a7..ca1cd44 100644 --- a/src/modules/reputation/reputation.controller.ts +++ b/src/modules/reputation/reputation.controller.ts @@ -22,6 +22,27 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; export class ReputationController { constructor(private readonly reputationService: ReputationService) { } + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get reputation score for the authenticated user' }) + @ApiResponse({ + status: 200, + description: 'Reputation data retrieved successfully', + type: ReputationResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' }) + async getMyScore(@Request() req: any) { + const wallet = req.user?.wallet; + const data = await this.reputationService.getReputationScore(wallet); + + return { + success: true, + data, + message: 'Your reputation data retrieved successfully', + }; + } + @Get(':wallet') @ApiOperation({ summary: 'Get reputation score for a specific wallet address' }) @ApiParam({ @@ -52,25 +73,4 @@ export class ReputationController { message: 'Reputation data retrieved successfully', }; } - - @Get('me') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ summary: 'Get reputation score for the authenticated user' }) - @ApiResponse({ - status: 200, - description: 'Reputation data retrieved successfully', - type: ReputationResponseDto, - }) - @ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' }) - async getMyScore(@Request() req: any) { - const wallet = req.user?.wallet; - const data = await this.reputationService.getReputationScore(wallet); - - return { - success: true, - data, - message: 'Your reputation data retrieved successfully', - }; - } } diff --git a/test/__mocks__/stellar-sdk.js b/test/__mocks__/stellar-sdk.js index dd9ad6e..7a3098c 100644 --- a/test/__mocks__/stellar-sdk.js +++ b/test/__mocks__/stellar-sdk.js @@ -9,11 +9,21 @@ module.exports = { fromSecret: jest.fn(() => ({ publicKey: jest.fn(() => 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'), })), + fromPublicKey: jest.fn(() => ({ + verify: jest.fn(() => true), + publicKey: jest.fn(() => 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'), + })), random: jest.fn(() => ({ + sign: jest.fn(() => Buffer.alloc(64)), + verify: jest.fn(() => true), publicKey: jest.fn(() => 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'), secret: jest.fn(() => 'SABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ'), })), }, + Account: jest.fn((publicKey, sequence) => ({ + accountId: () => publicKey, + sequenceNumber: () => sequence, + })), StrKey: { isValidEd25519PublicKey: jest.fn(() => true), isValidEd25519SecretSeed: jest.fn(() => true), @@ -32,6 +42,17 @@ module.exports = { getTransaction: jest.fn(), })), }, + Horizon: { + Server: jest.fn(() => ({ + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + transactions: jest.fn(() => ({ + transaction: jest.fn(() => ({ + call: jest.fn().mockResolvedValue({}), + })), + })), + })), + }, BASE_FEE: '100', Networks: { PUBLIC: 'Public Global Stellar Network ; September 2015', diff --git a/test/e2e/helpers/test-setup.ts b/test/e2e/helpers/test-setup.ts new file mode 100644 index 0000000..e916e3b --- /dev/null +++ b/test/e2e/helpers/test-setup.ts @@ -0,0 +1,408 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ThrottlerModule } from '@nestjs/throttler'; + +import { AuthModule } from '../../../src/modules/auth/auth.module'; +import { LearnersModule } from '../../../src/modules/learners/learners.module'; +import { LoansModule } from '../../../src/modules/loans/loans.module'; +import { VendorsModule } from '../../../src/modules/vendors/vendors.module'; +import { ReputationModule } from '../../../src/modules/reputation/reputation.module'; +import { UsersModule } from '../../../src/modules/users/users.module'; +import { CreditScoringModule } from '../../../src/modules/credit-scoring/credit-scoring.module'; +import { SupabaseService } from '../../../src/database/supabase.client'; +import { SorobanService } from '../../../src/blockchain/soroban/soroban.service'; +import { CreditLineContractClient } from '../../../src/stellar/contracts/clients/creditline.client'; +import { ReputationContractClient } from '../../../src/stellar/contracts/clients/reputation.client'; +import { LiquidityPoolContractClient } from '../../../src/stellar/contracts/clients/liquidity-pool.client'; +import { VendorRegistryContractClient } from '../../../src/stellar/contracts/clients/vendor-registry.client'; +import { ParametersContractClient } from '../../../src/stellar/contracts/clients/parameters.client'; +import { randomUUID } from 'crypto'; +import { createTestKeypair } from './test-wallet'; + +type Row = Record; + +export class InMemoryStore { + private tables = new Map(); + + getRows(table: string): Row[] { + if (!this.tables.has(table)) this.tables.set(table, []); + return this.tables.get(table)!; + } + + seed(table: string, rows: Row | Row[]): void { + const arr = Array.isArray(rows) ? rows : [rows]; + this.getRows(table).push(...arr); + } + + clear(): void { + this.tables.clear(); + } + + dump(table: string): Row[] { + return [...this.getRows(table)]; + } +} + +class MockQueryBuilder { + private filters: Array< + | { type: 'eq'; col: string; val: any } + | { type: 'is'; col: string; val: any } + | { type: 'in'; col: string; vals: any[] } + > = []; + private sortCol: string | null = null; + private sortAsc = true; + private rangeFrom: number | null = null; + private rangeTo: number | null = null; + private limitCount: number | null = null; + private countExact = false; + private operation: + | { kind: 'select' } + | { kind: 'insert'; data: any } + | { kind: 'update'; data: any } + | { kind: 'upsert'; data: any; onConflict?: string } + | { kind: 'delete' } + | null = null; + + constructor( + private store: InMemoryStore, + private table: string, + ) { + this.operation = { kind: 'select' }; + } + + select(_columns?: string, opts?: { count?: 'exact' | 'planned' | 'estimated' }): this { + if (!this.operation) { + this.operation = { kind: 'select' }; + } + this.countExact = opts?.count === 'exact'; + return this; + } + + insert(data: any): this { + this.operation = { kind: 'insert', data }; + return this; + } + + update(data: any): this { + this.operation = { kind: 'update', data }; + return this; + } + + upsert(data: any, opts?: { onConflict?: string }): this { + this.operation = { kind: 'upsert', data, onConflict: opts?.onConflict }; + return this; + } + + delete(): this { + this.operation = { kind: 'delete' }; + return this; + } + + eq(col: string, val: any): this { + this.filters.push({ type: 'eq', col, val } as any); + return this; + } + + is(col: string, val: any): this { + this.filters.push({ type: 'is', col, val } as any); + return this; + } + + in(col: string, vals: any[]): this { + this.filters.push({ type: 'in', col, vals } as any); + return this; + } + + order(col: string, opts?: { ascending?: boolean }): this { + this.sortCol = col; + this.sortAsc = opts?.ascending ?? true; + return this; + } + + range(from: number, to: number): this { + this.rangeFrom = from; + this.rangeTo = to; + return this; + } + + limit(n: number): this { + this.limitCount = n; + return this; + } + + single(): Promise<{ data: Row | null; error: { message: string } | null; count?: number }> { + if (this.operation && this.operation.kind !== 'select') { + return this.executeQuery().then((r) => ({ + data: r.data ?? null, + error: r.error ?? null, + count: r.count, + })); + } + const results = this.apply(); + if (results.length === 0) { + return Promise.resolve({ data: null, error: { message: 'No rows found' } }); + } + if (results.length > 1) { + return Promise.resolve({ data: null, error: { message: 'Query returned multiple rows when single was expected' } }); + } + return Promise.resolve({ data: results[0], error: null, count: this.countExact ? 1 : undefined }); + } + + maybeSingle(): Promise<{ data: Row | null; error: { message: string } | null; count?: number }> { + if (this.operation && this.operation.kind !== 'select') { + return this.executeQuery().then((r) => ({ + data: r.data ?? null, + error: r.error ?? null, + count: r.count, + })); + } + const results = this.apply(); + return Promise.resolve({ data: results[0] ?? null, error: null, count: this.countExact ? results.length : undefined }); + } + + then( + onfulfilled?: ((value: any) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.executeQuery().then(onfulfilled, onrejected); + } + + private apply(): Row[] { + let rows = this.store.getRows(this.table); + + for (const f of this.filters) { + if (f.type === 'eq') { + rows = rows.filter((r) => r[f.col] === f.val || (r[f.col] == null && f.val == null)); + } else if (f.type === 'is') { + rows = rows.filter((r) => r[f.col] === f.val || (f.val === null && r[f.col] == null)); + } else if (f.type === 'in') { + rows = rows.filter((r) => f.vals.includes(r[f.col])); + } + } + + if (this.sortCol) { + rows = [...rows].sort((a, b) => { + const av = a[this.sortCol] ?? ''; + const bv = b[this.sortCol] ?? ''; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return this.sortAsc ? cmp : -cmp; + }); + } + + if (this.rangeFrom != null) { + rows = rows.slice(this.rangeFrom, (this.rangeTo ?? rows.length) + 1); + } + if (this.limitCount != null) { + rows = rows.slice(0, this.limitCount); + } + + return rows; + } + + private executeQuery(): Promise { + const op = this.operation; + if (!op) { + return Promise.resolve({ data: null, error: null }); + } + + if (op.kind === 'select') { + const results = this.apply(); + return Promise.resolve({ + data: results, + error: null, + count: this.countExact ? results.length : undefined, + }); + } + + if (op.kind === 'insert') { + const rows = this.store.getRows(this.table); + const entry = { ...op.data, id: op.data.id ?? randomUUID() }; + rows.push(entry); + return Promise.resolve({ data: entry, error: null }); + } + + if (op.kind === 'upsert') { + const rows = this.store.getRows(this.table); + const conflictCol = op.onConflict ?? 'id'; + const existingIdx = rows.findIndex((r) => r[conflictCol] === op.data[conflictCol]); + const entry = { ...op.data, id: op.data.id ?? randomUUID() }; + + if (existingIdx >= 0) { + rows[existingIdx] = { ...rows[existingIdx], ...op.data }; + } else { + rows.push(entry); + } + + const result = existingIdx >= 0 ? rows[existingIdx] : entry; + return Promise.resolve({ data: result, error: null }); + } + + if (op.kind === 'update') { + const targets = this.apply(); + const store = this.store.getRows(this.table); + for (const target of targets) { + const idx = store.indexOf(target); + if (idx >= 0) { + store[idx] = { ...store[idx], ...op.data }; + } + } + return Promise.resolve({ + data: targets.map((t) => ({ ...t, ...op.data })), + error: null, + }); + } + + if (op.kind === 'delete') { + const targets = this.apply(); + const store = this.store.getRows(this.table); + for (const target of targets) { + const idx = store.indexOf(target); + if (idx >= 0) store.splice(idx, 1); + } + return Promise.resolve({ data: targets, error: null }); + } + + return Promise.resolve({ data: null, error: null }); + } +} + +export async function buildTestApp(): Promise<{ + app: INestApplication; + mockDb: InMemoryStore; +}> { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-for-e2e-min32chars'; + process.env.JWT_REFRESH_SECRET = + process.env.JWT_REFRESH_SECRET || 'test-jwt-refresh-secret-for-e2e'; + process.env.SUPABASE_URL = process.env.SUPABASE_URL || 'https://test.supabase.co'; + process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'test-anon-key'; + process.env.SUPABASE_SERVICE_ROLE_KEY = + process.env.SUPABASE_SERVICE_ROLE_KEY || 'test-service-role-key'; + process.env.CREDIT_LINE_CONTRACT_ID = 'C_CREDIT_LINE_E2E'; + process.env.REPUTATION_CONTRACT_ID = 'C_REPUTATION_E2E'; + process.env.LIQUIDITY_POOL_CONTRACT_ID = 'C_LIQUIDITY_POOL_E2E'; + process.env.VENDOR_REGISTRY_CONTRACT_ID = 'C_VENDOR_REGISTRY_E2E'; + process.env.PARAMETERS_CONTRACT_ID = 'C_PARAMETERS_E2E'; + process.env.STELLAR_SOROBAN_URL = 'https://testnet.stellar.org'; + process.env.REDIS_URL = ''; + + const store = new InMemoryStore(); + + const builder = (table: string) => new MockQueryBuilder(store, table); + + const makeClient = () => ({ + from: jest.fn((table: string) => builder(table)), + storage: { + from: jest.fn(() => ({ + upload: jest.fn().mockResolvedValue({ error: null }), + getPublicUrl: jest.fn(() => ({ data: { publicUrl: 'https://example.com/avatar.png' } })), + })), + }, + }); + + const mockSupabaseService = { + getClient: jest.fn(makeClient), + getServiceRoleClient: jest.fn(makeClient), + }; + + const mockSorobanService = { + getServer: jest.fn().mockReturnValue({ + getAccount: jest.fn().mockResolvedValue({}), + prepareTransaction: jest.fn().mockResolvedValue({ toXDR: () => 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' }), + simulateTransaction: jest.fn().mockResolvedValue({ result: { retval: {} } }), + }), + getNetworkPassphrase: jest.fn().mockReturnValue('Test SDF Network ; September 2015'), + simulateContractCall: jest.fn().mockResolvedValue({}), + }; + + const mockContractClient = { + buildCreateLoanTransaction: jest + .fn() + .mockResolvedValue('AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='), + buildRepayLoanTx: jest + .fn() + .mockResolvedValue('AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='), + getScore: jest.fn().mockResolvedValue(75), + getPoolStats: jest.fn().mockResolvedValue(null), + getVendor: jest.fn().mockResolvedValue(null), + getParameters: jest.fn().mockResolvedValue(null), + }; + + const mockCacheManager = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }), + ThrottlerModule.forRoot([{ ttl: 60000, limit: 1000 }]), + AuthModule, + LearnersModule, + LoansModule, + VendorsModule, + ReputationModule, + UsersModule, + CreditScoringModule, + ], + }) + .overrideProvider(SupabaseService) + .useValue(mockSupabaseService) + .overrideProvider(SorobanService) + .useValue(mockSorobanService) + .overrideProvider(CreditLineContractClient) + .useValue(mockContractClient) + .overrideProvider(ReputationContractClient) + .useValue(mockContractClient) + .overrideProvider(LiquidityPoolContractClient) + .useValue(mockContractClient) + .overrideProvider(VendorRegistryContractClient) + .useValue(mockContractClient) + .overrideProvider(ParametersContractClient) + .useValue(mockContractClient) + .overrideProvider('CACHE_MANAGER') + .useValue(mockCacheManager) + .compile(); + + const app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + return { app, mockDb: store }; +} + +export async function seedVendor( + mockDb: InMemoryStore, + overrides?: Partial<{ + id: string; + name: string; + type: string; + verified: boolean; + }>, +): Promise { + const vendor = { + id: overrides?.id ?? randomUUID(), + wallet_address: createTestKeypair().publicKey(), + name: overrides?.name ?? 'Test Vendor', + type: overrides?.type ?? 'electronics', + verified: overrides?.verified ?? true, + website: 'https://testvendor.com', + country: 'Nigeria', + city: 'Lagos', + created_at: new Date().toISOString(), + }; + mockDb.seed('vendors', vendor); + return vendor; +} + +export async function closeTestApp(app: INestApplication): Promise { + await app.close(); +} diff --git a/test/e2e/helpers/test-wallet.ts b/test/e2e/helpers/test-wallet.ts new file mode 100644 index 0000000..2331617 --- /dev/null +++ b/test/e2e/helpers/test-wallet.ts @@ -0,0 +1,9 @@ +export const TEST_WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + +export const createTestKeypair = () => ({ + publicKey: () => TEST_WALLET, + secret: () => 'SABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ', +}); + +export const signMessage = (_keypair: any, _message: string): string => + 'AAAA' + 'A'.repeat(124); diff --git a/test/e2e/loan-lifecycle.e2e-spec.ts b/test/e2e/loan-lifecycle.e2e-spec.ts new file mode 100644 index 0000000..3a47f83 --- /dev/null +++ b/test/e2e/loan-lifecycle.e2e-spec.ts @@ -0,0 +1,254 @@ +import * as request from 'supertest'; +import { + buildTestApp, + closeTestApp, + seedVendor, + InMemoryStore, +} from './helpers/test-setup'; +import { createTestKeypair, signMessage } from './helpers/test-wallet'; + +describe('Loan Lifecycle (e2e)', () => { + let app: any; + let mockDb: InMemoryStore; + let authToken: string; + let wallet: string; + let vendorId: string; + let loanUuid: string; + let totalRepayment: number; + let monthlyPayment: number; + + beforeAll(async () => { + const ctx = await buildTestApp(); + app = ctx.app; + mockDb = ctx.mockDb; + }); + + afterAll(async () => { + await closeTestApp(app); + }); + + describe('1. Auth Flow', () => { + it('should generate nonce and verify signature to receive JWT', async () => { + const keypair = createTestKeypair(); + wallet = keypair.publicKey(); + + const nonceRes = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + expect(nonceRes.body).toHaveProperty('nonce'); + expect(nonceRes.body).toHaveProperty('expiresAt'); + expect(nonceRes.body.nonce).toHaveLength(64); + + const signature = signMessage(keypair, nonceRes.body.nonce); + + const verifyRes = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce: nonceRes.body.nonce, signature }) + .expect(200); + + expect(verifyRes.body).toHaveProperty('accessToken'); + expect(verifyRes.body).toHaveProperty('refreshToken'); + expect(verifyRes.body.tokenType).toBe('Bearer'); + expect(verifyRes.body.expiresIn).toBe(900); + + authToken = verifyRes.body.accessToken; + }); + + it('should reject verify with invalid signature format', async () => { + await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce: 'a'.repeat(64), signature: 'AAAA' }) + .expect(401); + }); + }); + + describe('2. Learner Profile', () => { + it('should upsert and retrieve learner profile', async () => { + const profileData = { + school: 'University of Lagos', + program: 'Computer Science', + programType: 'university', + incomeType: 'student', + monthlyIncome: 500, + country: 'Nigeria', + city: 'Lagos', + }; + + const patchRes = await request(app.getHttpServer()) + .patch('/learners/me') + .set('Authorization', `Bearer ${authToken}`) + .send(profileData) + .expect(200); + + expect(patchRes.body.walletAddress).toBe(wallet); + expect(patchRes.body.school).toBe('University of Lagos'); + + const getRes = await request(app.getHttpServer()) + .get('/learners/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(getRes.body.walletAddress).toBe(wallet); + expect(getRes.body.school).toBe('University of Lagos'); + }); + + it('should return 401 without auth token', async () => { + await request(app.getHttpServer()) + .get('/learners/me') + .expect(401); + }); + }); + + describe('3. Vendor Setup', () => { + it('should list available vendors', async () => { + const vendor = await seedVendor(mockDb); + vendorId = vendor.id; + + const res = await request(app.getHttpServer()) + .get('/vendors') + .expect(200); + + expect(Array.isArray(res.body)).toBe(true); + const found = res.body.find((v: any) => v.id === vendorId); + expect(found).toBeDefined(); + expect(found.name).toBe('Test Vendor'); + expect(found.verified).toBe(true); + }); + }); + + describe('4. Loan Application', () => { + it('should get a loan quote', async () => { + const res = await request(app.getHttpServer()) + .post('/loans/quote') + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: 500, vendor: vendorId, term: 4 }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.amount).toBe(500); + expect(res.body.data.guarantee).toBe(100); + expect(res.body.data.loanAmount).toBe(400); + expect(res.body.data.term).toBe(4); + expect(res.body.data.schedule).toHaveLength(4); + + totalRepayment = res.body.data.totalRepayment; + monthlyPayment = res.body.data.monthlyPayment; + }); + + it('should create a pending loan', async () => { + const res = await request(app.getHttpServer()) + .post('/loans/create') + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: 500, vendor: vendorId, term: 4 }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('loanId'); + expect(res.body.data).toHaveProperty('xdr'); + expect(res.body.data.assessment.decision).toBe('approved'); + expect(res.body.data.terms.amount).toBe(500); + + const provisionalLoanId = res.body.data.loanId; + expect(provisionalLoanId).toMatch(/^pending-/); + + const loans = mockDb.dump('loans'); + const created = loans.find((l: any) => l.loan_id === provisionalLoanId); + expect(created).toBeDefined(); + expect(created.status).toBe('pending'); + expect(created.user_wallet).toBe(wallet); + + loanUuid = created.id; + }); + }); + + describe('5. Loan Approval', () => { + it('should assess and approve the pending loan', async () => { + const res = await request(app.getHttpServer()) + .post(`/loans/${loanUuid}/assess`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.assessment.decision).toBe('approved'); + expect(res.body.data.previousStatus).toBe('pending'); + expect(res.body.data.currentStatus).toBe('pending'); + }); + }); + + describe('6. Installment Repayment', () => { + it('should reject repayment on a pending loan', async () => { + await request(app.getHttpServer()) + .post(`/loans/${loanUuid}/pay`) + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: monthlyPayment }) + .expect(400); + }); + + it('should process a partial repayment after loan is active', async () => { + const loans = mockDb.dump('loans'); + const loan = loans.find((l: any) => l.id === loanUuid); + loan.status = 'active'; + loan.updated_at = new Date().toISOString(); + + const res = await request(app.getHttpServer()) + .post(`/loans/${loanUuid}/pay`) + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: monthlyPayment }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('unsignedXdr'); + expect(res.body.data.preview.paymentAmount).toBe(monthlyPayment); + expect(res.body.data.preview.currentBalance).toBe(totalRepayment); + expect(res.body.data.preview.newBalance).toBeGreaterThan(0); + expect(res.body.data.preview.willComplete).toBe(false); + + // Simulate blockchain indexer updating remaining_balance after payment + loan.remaining_balance = res.body.data.preview.newBalance; + }); + }); + + describe('7. Reputation Check', () => { + it('should return reputation for the authenticated user', async () => { + const res = await request(app.getHttpServer()) + .get('/reputation/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.wallet).toBe(wallet); + expect(res.body.data.score).toBe(75); + expect(res.body.data.tier).toBe('silver'); + expect(res.body.data.maxCredit).toBe(3000); + }); + + it('should return reputation for a wallet address', async () => { + const res = await request(app.getHttpServer()) + .get(`/reputation/${wallet}`) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.wallet).toBe(wallet); + }); + }); + + describe('8. Full Repayment', () => { + it('should process full repayment to complete the loan', async () => { + const remainingBalance = totalRepayment - monthlyPayment; + const roundedRemaining = Math.round(remainingBalance * 100) / 100; + + const res = await request(app.getHttpServer()) + .post(`/loans/${loanUuid}/pay`) + .set('Authorization', `Bearer ${authToken}`) + .send({ amount: roundedRemaining }) + .expect(200); + + expect(res.body.success).toBe(true); + expect(res.body.data.preview.paymentAmount).toBe(roundedRemaining); + expect(res.body.data.preview.newBalance).toBe(0); + expect(res.body.data.preview.willComplete).toBe(true); + }); + }); +});