diff --git a/README.md b/README.md index cf5b2e6..a36cf5e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Ensure you have the following installed on your system: ## Development Setup ### First-time setup + ```bash # 1. Copy environment config cp .env.example .env @@ -44,6 +45,7 @@ bun run dev ``` ### Daily workflow + ```bash make db # start database bun run dev # start API @@ -53,6 +55,7 @@ make db-reset # wipe all data and start fresh ``` ### Migrations + ```bash # Run pending migrations make migrate @@ -229,9 +232,9 @@ Request body: ```json { - "campaign_ref": "ABCDE", - "target_amount": "1000", - "donation_token": "0x1" + "campaign_ref": "ABCDE", + "target_amount": "1000", + "donation_token": "0x1" } ``` @@ -261,5 +264,70 @@ For local development without a chain connection: --- +## API: List Wallets + +**GET** `/api/v1/wallets` (JWT + admin required) + +Returns a list of all wallets in the system. + +Request: + +```bash +curl -H "Authorization: Bearer " http://localhost:3000/api/v1/wallets +``` + +Response (Success - 200): + +```json +{ + "success": true, + "data": [ + { + "id": "wallet-uuid", + "address": "0x123abc...", + "network": "ETHEREUM", + "chainId": "1", + "chainName": "Ethereum", + "balance": "100.50", + "createdAt": "2026-06-29T10:00:00.000Z", + "updatedAt": "2026-06-29T10:00:00.000Z" + } + ] +} +``` + +Response (Error - 401 Unauthorized): + +```json +{ + "success": false, + "error": { + "code": "AUTH_MISSING_TOKEN", + "message": "Missing authentication token", + "details": {} + } +} +``` + +Response (Error - 500 Internal Server Error): + +```json +{ + "success": false, + "error": { + "code": "WALLET_LIST_FAILED", + "message": "Failed to retrieve wallets", + "details": { "reason": "..." } + } +} +``` + +Notes: + +- Requires valid JWT token in `Authorization: Bearer ` header +- Returns empty array if no wallets exist +- Includes wallet address, network, chain info, and balance + +--- ### Any challenges? Reach out! diff --git a/src/__tests__/wallet.controller.test.ts b/src/__tests__/wallet.controller.test.ts new file mode 100644 index 0000000..c2b73d8 --- /dev/null +++ b/src/__tests__/wallet.controller.test.ts @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import type { Response } from 'express'; + +import { requireAdminApi } from '../appMiddlewares/jwtAuth.api'; +import { listWallets } from '../components/v1/wallet/wallet.controller'; +import walletRoutes from '../components/v1/wallet/wallet.routes'; +import walletRepository from '../components/v1/wallet/wallet.services'; +import type { IRequest } from '../types/global'; + +type MockResponse = { + statusCode: number; + jsonData: any; + status: (code: number) => MockResponse; + json: (data: any) => MockResponse; +}; + +const createMockResponse = (): MockResponse => { + const response: MockResponse = { + statusCode: 200, + jsonData: null, + status(code: number) { + this.statusCode = code; + return this; + }, + json(data: any) { + this.jsonData = data; + return this; + }, + }; + return response; +}; + +test('listWallets returns wallet data when the repository succeeds', async () => { + const mockWallets = [ + { + id: 'wallet-1', + address: '0x123abc', + network: 'ETHEREUM', + chainId: '1', + chainName: 'Ethereum', + balance: '100.5', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const res = createMockResponse(); + const req = { auth: { userId: 'user-123' } } as IRequest; + const originalFind = walletRepository.find.bind(walletRepository); + + try { + ( + walletRepository as unknown as { + find: typeof walletRepository.find; + } + ).find = async () => mockWallets as any; + + await listWallets(req, res as unknown as Response); + + assert.equal(res.statusCode, 200); + assert.equal(res.jsonData.success, true); + assert.deepEqual(res.jsonData.data, mockWallets); + } finally { + ( + walletRepository as unknown as { + find: typeof walletRepository.find; + } + ).find = originalFind; + } +}); + +test('wallet routes reject unauthenticated requests', async () => { + const app = express(); + app.use('/wallets', walletRoutes); + + const server = app.listen(0); + await new Promise((resolve) => + server.once('listening', () => resolve()) + ); + + try { + const address = server.address() as AddressInfo; + const response = await fetch( + `http://127.0.0.1:${address.port}/wallets` + ); + const payload = await response.json(); + + assert.equal(response.status, 401); + assert.equal(payload.success, false); + assert.equal(payload.error.code, 'AUTH_MISSING_TOKEN'); + } finally { + server.close(); + } +}); + +test('requireAdminApi rejects non-admin requests', async () => { + const res = createMockResponse(); + const req = { + auth: { + userId: 'user-123', + claims: { role: 'user' }, + }, + } as IRequest; + + const next = () => { + throw new Error('next should not be called'); + }; + + const result = requireAdminApi(req, res as unknown as Response, next); + + assert.equal(result, undefined); + assert.equal(res.statusCode, 403); + assert.equal(res.jsonData.success, false); + assert.equal(res.jsonData.error.code, 'FORBIDDEN'); +}); + +test('listWallets returns DB_NOT_READY when the data source is not initialized', async () => { + const res = createMockResponse(); + const req = { auth: { userId: 'user-123' } } as IRequest; + + await listWallets(req, res as unknown as Response); + + assert.equal(res.statusCode, 500); + assert.equal(res.jsonData.success, false); + assert.equal(res.jsonData.error.code, 'DB_NOT_READY'); + assert.match(res.jsonData.error.message, /Database not initialized/); +}); diff --git a/src/__tests__/wallet.routes.test.ts b/src/__tests__/wallet.routes.test.ts new file mode 100644 index 0000000..7e3ebc6 --- /dev/null +++ b/src/__tests__/wallet.routes.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { Response } from 'express'; + +import type { IRequest } from '../types/global'; +import { listWallets } from '../components/v1/wallet/wallet.controller'; + +const createMockResponse = () => { + const response: Partial & { + statusCode: number; + jsonData: any; + status: (code: number) => any; + json: (data: any) => any; + } = { + statusCode: 200, + jsonData: null, + status(code: number) { + this.statusCode = code; + return this; + }, + json(data: any) { + this.jsonData = data; + return this; + }, + }; + return response; +}; + +test('listWallets returns DB_NOT_READY when data source is not initialized', async () => { + const res = createMockResponse(); + const req = { auth: { userId: 'user-123' } } as IRequest; + + await listWallets(req, res as Response); + + assert.equal(res.statusCode, 500); + assert.equal(res.jsonData.success, false); + assert.equal(res.jsonData.error.code, 'DB_NOT_READY'); + assert.match(res.jsonData.error.message, /Database not initialized/); +}); + +test('wallet routes are mounted on the API v1 router', async () => { + const router = await import('../components/v1/routes.api.v1'); + assert.ok(router.default); +}); + +test('wallet route module exports a router', async () => { + const router = await import('../components/v1/wallet/wallet.routes'); + assert.ok(router.default); +}); diff --git a/src/components/v1/routes.api.v1.ts b/src/components/v1/routes.api.v1.ts index 6a7668c..fb66b6c 100644 --- a/src/components/v1/routes.api.v1.ts +++ b/src/components/v1/routes.api.v1.ts @@ -7,6 +7,7 @@ import { import campaignRoutes from './campaign/campaign.routes'; import donationRoutes from './Donation/donation.routes'; +import walletRoutes from './wallet/wallet.routes'; import { listCampaignDonationsQuerySchema, listUserDonationsQuerySchema, @@ -21,6 +22,7 @@ const router = new EnhancedRouter(); router.use('/campaigns', campaignRoutes); router.use('/donations', donationRoutes); +router.use('/wallets', walletRoutes); router.get( '/campaigns/:campaignId/donations', diff --git a/src/components/v1/routes.v1.ts b/src/components/v1/routes.v1.ts index 8f847cf..d7b11a9 100644 --- a/src/components/v1/routes.v1.ts +++ b/src/components/v1/routes.v1.ts @@ -1,10 +1,9 @@ import EnhancedRouter from '../../utils/enhancedRouter'; -import distributionRoutes from "./distribution/distrubtion.routes" - +import distributionRoutes from './distribution/distrubtion.routes'; const routerV1 = new EnhancedRouter(); -routerV1.use("/distributions", distributionRoutes) +routerV1.use('/distributions', distributionRoutes); export default routerV1.getRouter(); diff --git a/src/components/v1/wallet/wallet.controller.ts b/src/components/v1/wallet/wallet.controller.ts index f159d35..735b3c3 100644 --- a/src/components/v1/wallet/wallet.controller.ts +++ b/src/components/v1/wallet/wallet.controller.ts @@ -1,15 +1,27 @@ -import { Response } from 'express'; +import type { Response } from 'express'; +import AppDataSource from '../../../config/persistence/data-source'; +import { sendError, sendSuccess } from '../../../utils/apiResponse'; +import type { IRequest } from '../../../types/global'; import walletRepository from './wallet.services'; -import { handleResponse } from '../../../utils/helper'; -import { IRequest } from '../../../types/global'; -export const listWallets = async (req: IRequest, res: Response) => { - const queryObject = {}; +export const listWallets = async (_req: IRequest, res: Response) => { + try { + if (!AppDataSource.isInitialized) { + return sendError(res, 500, { + code: 'DB_NOT_READY', + message: 'Database not initialized', + }); + } - const wallets = await walletRepository.findBy(queryObject); - - return handleResponse(res, { - data: wallets, - }); + const wallets = await walletRepository.find(); + return sendSuccess(res, wallets); + } catch (error) { + const err = error as Error; + return sendError(res, 500, { + code: 'WALLET_LIST_FAILED', + message: 'Failed to retrieve wallets', + details: { reason: err.message }, + }); + } }; diff --git a/src/components/v1/wallet/wallet.routes.ts b/src/components/v1/wallet/wallet.routes.ts index 9473f0a..8451c75 100644 --- a/src/components/v1/wallet/wallet.routes.ts +++ b/src/components/v1/wallet/wallet.routes.ts @@ -1,9 +1,14 @@ -import EnhancedRouter from '../../../utils/enhancedRouter'; +import { Router } from 'express'; +import { + requireAdminApi, + requireJwtAuthApi, +} from '../../../appMiddlewares/jwtAuth.api'; import { listWallets } from './wallet.controller'; -const walletRouter = new EnhancedRouter(); +const router = Router(); -// walletRouter.get('/'); +// List all wallets (JWT + admin required) +router.get('/', requireJwtAuthApi, requireAdminApi, listWallets); -export default walletRouter.getRouter(); +export default router;