diff --git a/backend/src/docs/openapi.ts b/backend/src/docs/openapi.ts index 435c6ec9..dac32e99 100644 --- a/backend/src/docs/openapi.ts +++ b/backend/src/docs/openapi.ts @@ -58,6 +58,7 @@ export const openApiDocument: OpenApiDocument = { { name: 'Profiles', description: 'Creator profile management' }, { name: 'Tips', description: 'On-chain tipping operations' }, { name: 'Leaderboard', description: 'Creator tip leaderboard with time windows' }, + { name: 'Withdrawals', description: 'Withdrawal operations and balance queries' }, { name: 'Notifications', description: 'In-app notifications for users' }, ], components: { diff --git a/backend/src/modules/notifications/notifications.routes.ts b/backend/src/modules/notifications/notifications.routes.ts index 256ee712..ce311169 100644 --- a/backend/src/modules/notifications/notifications.routes.ts +++ b/backend/src/modules/notifications/notifications.routes.ts @@ -14,6 +14,7 @@ notificationsRouter.get('/preferences', notificationsController.getPreferences); notificationsRouter.patch('/preferences', notificationsController.updatePreferences); notificationsRouter.get('/:id', notificationsController.getById); notificationsRouter.patch('/:id/read', notificationsController.markRead); +notificationsRouter.post('/:id/read', notificationsController.markRead); notificationsRouter.post('/read-all', notificationsController.markAllRead); const base = `${env.API_BASE_PATH}/notifications`; @@ -246,6 +247,36 @@ mergeOpenApiPaths({ '404': { description: 'Notification not found' }, }, }, + post: { + tags: ['Notifications'], + summary: 'Mark a notification as read (POST)', + description: 'POST alias for PATCH /notifications/:id/read.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: 'Notification marked as read', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: notificationSchema }, + required: ['data'], + }, + }, + }, + }, + '401': { description: 'Unauthorized' }, + '404': { description: 'Notification not found' }, + }, + }, }, [`${base}/read-all`]: { post: { diff --git a/backend/src/modules/notifications/notifications.test.ts b/backend/src/modules/notifications/notifications.test.ts index 4a16c1ba..b13e5035 100644 --- a/backend/src/modules/notifications/notifications.test.ts +++ b/backend/src/modules/notifications/notifications.test.ts @@ -338,6 +338,98 @@ describe('PATCH /api/v1/notifications/:id/read', () => { expect(res.status).toBe(200); expect(res.body.data.readAt).toBe(readAt.toISOString()); }); + + it('returns 404 for unknown notification', async () => { + mockFindFirst.mockResolvedValue(null); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .patch('/api/v1/notifications/unknown/read') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(404); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await request(app).patch('/api/v1/notifications/notif-1/read'); + + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/v1/notifications/:id/read (#960)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks notification as read via POST', async () => { + const createdAt = new Date('2026-07-24T12:00:00.000Z'); + mockFindFirst.mockResolvedValue({ + id: 'notif-1', + type: 'tip_received', + payload: {}, + readAt: null, + createdAt, + }); + const readAt = new Date('2026-07-25T12:00:00.000Z'); + mockUpdate.mockResolvedValue({ + id: 'notif-1', + type: 'tip_received', + payload: {}, + readAt, + createdAt, + }); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .post('/api/v1/notifications/notif-1/read') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data.readAt).toBe(readAt.toISOString()); + expect(mockFindFirst).toHaveBeenCalledWith({ + where: { id: 'notif-1', userId: 'user-1', deletedAt: null }, + }); + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'notif-1' }, + data: { readAt: expect.any(Date) }, + }); + }); + + it('returns 404 for unknown notification', async () => { + mockFindFirst.mockResolvedValue(null); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .post('/api/v1/notifications/unknown/read') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await request(app).post('/api/v1/notifications/notif-1/read'); + + expect(res.status).toBe(401); + }); + + it('returns 404 for notification owned by another user', async () => { + mockFindFirst.mockResolvedValue(null); + + const app = createApp(); + const token = mockAuth(); + const res = await request(app) + .post('/api/v1/notifications/notif-other/read') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(404); + }); }); describe('POST /api/v1/notifications/read-all', () => { diff --git a/backend/src/modules/withdrawals/withdrawals.routes.ts b/backend/src/modules/withdrawals/withdrawals.routes.ts index e43d443a..148e6e1d 100644 --- a/backend/src/modules/withdrawals/withdrawals.routes.ts +++ b/backend/src/modules/withdrawals/withdrawals.routes.ts @@ -1,5 +1,7 @@ import { Router } from 'express'; import { requireAuth } from '../../common/middleware/requireAuth.js'; +import { env } from '../../config/env.js'; +import { mergeOpenApiPaths } from '../../docs/openapi.js'; import * as withdrawalsController from './withdrawals.controller.js'; export const withdrawalsRouter = Router(); @@ -10,3 +12,200 @@ withdrawalsRouter.post('/submit', requireAuth, withdrawalsController.submitWithd export const balancesRouter = Router(); balancesRouter.get('/me', requireAuth, withdrawalsController.getMyBalance); + +const wdBase = `${env.API_BASE_PATH}/withdrawals`; +const balBase = `${env.API_BASE_PATH}/balances`; + +const withdrawalSchema = { + type: 'object', + properties: { + id: { type: 'string', example: 'clxx1234567890abcdef' }, + amount: { type: 'string', example: '1000000' }, + fee: { type: 'string', example: '20000' }, + txHash: { type: 'string', nullable: true, example: 'tx-hash-abc123' }, + status: { type: 'string', enum: ['PENDING', 'CONFIRMED', 'FAILED'], example: 'PENDING' }, + requestedAt: { type: 'string', format: 'date-time' }, + confirmedAt: { type: 'string', format: 'date-time', nullable: true, example: null }, + }, + required: ['id', 'amount', 'fee', 'txHash', 'status', 'requestedAt', 'confirmedAt'], +}; + +const balanceSchema = { + type: 'object', + properties: { + stellarAddress: { type: 'string', example: 'GA...ADDRESS' }, + totalReceived: { type: 'string', example: '5000000' }, + totalWithdrawn: { type: 'string', example: '1000000' }, + withdrawableBalance: { type: 'string', example: '4000000' }, + }, + required: ['stellarAddress', 'totalReceived', 'totalWithdrawn', 'withdrawableBalance'], +}; + +const preparedWithdrawalSchema = { + type: 'object', + properties: { + unsignedTxXdr: { type: 'string', example: 'AAAAAgAAAAA...' }, + destination: { type: 'string', example: 'GA...ADDRESS' }, + amount: { type: 'string', example: '1000000' }, + fee: { type: 'string', example: '20000' }, + netAmount: { type: 'string', example: '980000' }, + contractId: { type: 'string', example: 'C...CONTRACT' }, + networkPassphrase: { type: 'string', example: 'Test SDF Network ; September 2015' }, + }, + required: ['unsignedTxXdr', 'destination', 'amount', 'fee', 'netAmount', 'contractId', 'networkPassphrase'], +}; + +const submittedWithdrawalSchema = { + type: 'object', + properties: { + id: { type: 'string', example: 'clxx1234567890abcdef' }, + txHash: { type: 'string', example: 'tx-hash-abc123' }, + status: { type: 'string', enum: ['PENDING', 'CONFIRMED', 'FAILED'], example: 'PENDING' }, + amount: { type: 'string', example: '1000000' }, + fee: { type: 'string', example: '20000' }, + netAmount: { type: 'string', example: '980000' }, + }, + required: ['id', 'txHash', 'status', 'amount', 'fee', 'netAmount'], +}; + +mergeOpenApiPaths({ + [`${wdBase}/me`]: { + get: { + tags: ['Withdrawals'], + summary: 'Get withdrawal history', + description: 'Returns paginated withdrawal history for the authenticated user.', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + }, + { + name: 'offset', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 0, default: 0 }, + }, + ], + responses: { + '200': { + description: 'Paginated withdrawal history', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: withdrawalSchema }, + }, + required: ['data'], + }, + }, + }, + }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + [`${wdBase}/prepare`]: { + post: { + tags: ['Withdrawals'], + summary: 'Prepare a withdrawal', + description: 'Builds an unsigned Soroban transaction for the authenticated user to sign with their wallet.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + amount: { type: 'string', description: 'Amount in stroops (string of digits)' }, + }, + required: ['amount'], + }, + }, + }, + }, + responses: { + '200': { + description: 'Prepared unsigned transaction', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: preparedWithdrawalSchema }, + required: ['data'], + }, + }, + }, + }, + '400': { description: 'Invalid amount or insufficient balance' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + [`${wdBase}/submit`]: { + post: { + tags: ['Withdrawals'], + summary: 'Submit a signed withdrawal', + description: 'Broadcasts a wallet-signed withdrawal transaction and records it as PENDING. Idempotent by txHash.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + amount: { type: 'string', description: 'Amount in stroops (string of digits)' }, + signedTxXdr: { type: 'string', description: 'Base64-encoded signed transaction XDR' }, + }, + required: ['amount', 'signedTxXdr'], + }, + }, + }, + }, + responses: { + '200': { + description: 'Withdrawal submitted or already recorded (idempotent)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: submittedWithdrawalSchema }, + required: ['data'], + }, + }, + }, + }, + '400': { description: 'Invalid input, insufficient balance, or network rejection' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + [`${balBase}/me`]: { + get: { + tags: ['Withdrawals'], + summary: 'Get withdrawable balance', + description: 'Returns the withdrawable balance for the authenticated user.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Withdrawable balance details', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { data: balanceSchema }, + required: ['data'], + }, + }, + }, + }, + '401': { description: 'Unauthorized' }, + }, + }, + }, +}); diff --git a/backend/src/modules/withdrawals/withdrawals.test.ts b/backend/src/modules/withdrawals/withdrawals.test.ts index 750c24bf..4d799945 100644 --- a/backend/src/modules/withdrawals/withdrawals.test.ts +++ b/backend/src/modules/withdrawals/withdrawals.test.ts @@ -132,6 +132,61 @@ describe('GET /api/v1/withdrawals/me', () => { take: 20, }); }); + + it('returns an empty array when the user has no withdrawals', async () => { + mockAuth(); + mockFindMany.mockResolvedValue([]); + + const app = createApp(); + const res = await request(app) + .get('/api/v1/withdrawals/me') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + }); + + it('applies custom limit and offset from query params', async () => { + mockAuth(); + mockFindMany.mockResolvedValue([]); + + const app = createApp(); + const res = await request(app) + .get('/api/v1/withdrawals/me?limit=5&offset=10') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + orderBy: { requestedAt: 'desc' }, + skip: 10, + take: 5, + }); + }); + + it('serializes null confirmedAt as null', async () => { + mockAuth(); + mockFindMany.mockResolvedValue([ + { + id: 'wd-2', + amount: BigInt(500_000), + fee: BigInt(10_000), + txHash: null, + status: 'PENDING', + requestedAt: new Date('2024-06-01T00:00:00.000Z'), + confirmedAt: null, + }, + ]); + + const app = createApp(); + const res = await request(app) + .get('/api/v1/withdrawals/me') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.data[0].confirmedAt).toBeNull(); + expect(res.body.data[0].txHash).toBeNull(); + }); }); describe('GET /api/v1/balances/me', () => { @@ -159,6 +214,28 @@ describe('GET /api/v1/balances/me', () => { withdrawableBalance: '4000000', }); }); + + it('returns 401 without an Authorization header', async () => { + const app = createApp(); + const res = await request(app).get('/api/v1/balances/me'); + expect(res.status).toBe(401); + }); + + it('returns zero balance when no tips received', async () => { + mockAuth(); + mockFindUnique.mockResolvedValue({ id: 'user-1', stellarAddress: address }); + mockAggregate + .mockResolvedValueOnce({ _sum: { amountStroops: null } }) + .mockResolvedValueOnce({ _sum: { amount: null } }); + + const app = createApp(); + const res = await request(app) + .get('/api/v1/balances/me') + .set('Authorization', 'Bearer valid-token'); + + expect(res.status).toBe(200); + expect(res.body.data.withdrawableBalance).toBe('0'); + }); }); describe('POST /api/v1/withdrawals/prepare', () => { @@ -179,6 +256,28 @@ describe('POST /api/v1/withdrawals/prepare', () => { expect(res.body.error.code).toBe('VALIDATION_ERROR'); }); + it('returns 400 for empty body', async () => { + mockAuth(); + + const app = createApp(); + const res = await request(app) + .post('/api/v1/withdrawals/prepare') + .set('Authorization', 'Bearer valid-token') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 401 without an Authorization header', async () => { + const app = createApp(); + const res = await request(app) + .post('/api/v1/withdrawals/prepare') + .send({ amount: '1000000' }); + + expect(res.status).toBe(401); + }); + it('returns prepared transaction on success', async () => { mockAuth(); mockFindUnique.mockResolvedValue({ id: 'user-1', stellarAddress: address }); @@ -203,6 +302,22 @@ describe('POST /api/v1/withdrawals/prepare', () => { netAmount: '980000', }); }); + + it('returns 400 when amount exceeds withdrawable balance', async () => { + mockAuth(); + mockFindUnique.mockResolvedValue({ id: 'user-1', stellarAddress: address }); + mockAggregate + .mockResolvedValueOnce({ _sum: { amountStroops: BigInt(500_000) } }) + .mockResolvedValueOnce({ _sum: { amount: BigInt(0) } }); + + const app = createApp(); + const res = await request(app) + .post('/api/v1/withdrawals/prepare') + .set('Authorization', 'Bearer valid-token') + .send({ amount: '1000000' }); + + expect(res.status).toBe(400); + }); }); describe('POST /api/v1/withdrawals/submit (#940)', () => {