diff --git a/src/modules/creator/creator-detail-no-trade-history.integration.test.ts b/src/modules/creator/creator-detail-no-trade-history.integration.test.ts new file mode 100644 index 0000000..2b02b85 --- /dev/null +++ b/src/modules/creator/creator-detail-no-trade-history.integration.test.ts @@ -0,0 +1,119 @@ +// Integration test: creator stats endpoint — no trade history +// +// Verifies that: +// 1. A creator with no trade history returns a valid response +// 2. Supply and holder count fields are zero rather than null or absent +// 3. All expected fields are present and not null +// +// Uses Jest mocks with a minimal fixture set — no database required. +// Follows the same conventions as creator-detail-empty-social-links.integration.test.ts + +import { httpGetCreatorStats } from '../creators/creators.controllers'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(params: Record = {}): any { + return { params }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators/:id/stats — no trade history', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns HTTP 200 for a creator with no trade history', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('response includes holderCount field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('holderCount'); + expect(body.data.holderCount).toBe(0); + }); + + it('response includes totalSupply field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('totalSupply'); + expect(body.data.totalSupply).toBe(0); + }); + + it('response includes totalVolume field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('totalVolume'); + expect(body.data.totalVolume).toBe(0); + }); + + it('holderCount is not null or absent', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.holderCount).not.toBeNull(); + expect(body.data.holderCount).toBeDefined(); + }); + + it('totalSupply is not null or absent', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.totalSupply).not.toBeNull(); + expect(body.data.totalSupply).toBeDefined(); + }); + + it('response envelope is well-formed with success field', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + }); + + it('all expected numeric fields are present in response', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const expectedFields = ['holderCount', 'totalSupply', 'totalVolume']; + expectedFields.forEach(field => { + expect(body.data).toHaveProperty(field); + expect(typeof body.data[field]).toBe('number'); + }); + }); +}); diff --git a/src/modules/creator/creator-list-negative-page-size.integration.test.ts b/src/modules/creator/creator-list-negative-page-size.integration.test.ts new file mode 100644 index 0000000..9b47d19 --- /dev/null +++ b/src/modules/creator/creator-list-negative-page-size.integration.test.ts @@ -0,0 +1,135 @@ +// Integration test: creator list endpoint — negative page size +// +// Verifies that: +// 1. A request with a negative page size returns HTTP 400 +// 2. The error body matches the standard validation error shape +// 3. No database query is issued for the invalid input +// +// Uses Jest mocks with a minimal fixture set — no database required. +// Follows the same conventions as creator-list-page-size-boundary.integration.test.ts + +import { httpListCreators } from '../creators/creators.controllers'; +import * as creatorsUtils from '../creators/creators.utils'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — negative page size', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects limit=-1 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('rejects limit=-10 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-10' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('rejects limit=-100 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-100' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('does not call fetchCreatorList when limit is negative', async () => { + const spy = jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-5' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('response body matches standard validation error shape', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', false); + expect(body).toHaveProperty('message'); + expect(body).toHaveProperty('data'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('error details include the limit field', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.length).toBeGreaterThan(0); + expect(body.data[0]).toHaveProperty('field'); + expect(body.data[0].field).toBe('limit'); + }); + + it('error message indicates invalid value', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data[0]).toHaveProperty('message'); + expect(body.data[0].message).toBeDefined(); + }); + + it('response does not include items array for negative limit', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data?.items).toBeUndefined(); + }); +}); diff --git a/src/modules/creator/creator-profile.handlers.ts b/src/modules/creator/creator-profile.handlers.ts index 8c25834..509c793 100644 --- a/src/modules/creator/creator-profile.handlers.ts +++ b/src/modules/creator/creator-profile.handlers.ts @@ -76,6 +76,27 @@ export async function upsertCreatorProfileHandler(req: Request, res: Response) { const bodyResult = UpsertCreatorProfileBodySchema.safeParse(req.body); if (!bodyResult.success) { + // Log missing required fields with structured context + const missingFields = bodyResult.error.issues + .filter( + (issue: any) => + issue.code === 'invalid_type' && + issue.received === 'undefined' + ) + .map((issue: any) => issue.path.join('.')); + + if (missingFields.length > 0) { + logger.warn( + { + type: 'creator_profile_validation_error', + handler: 'upsertCreatorProfileHandler', + missingFields, + ...(req.requestId ? { requestId: req.requestId } : {}), + }, + 'Missing required fields in creator profile payload' + ); + } + return sendValidationError( res, 'Invalid creator profile payload', diff --git a/src/modules/creators/creator-detail-no-trade-history.integration.test.ts b/src/modules/creators/creator-detail-no-trade-history.integration.test.ts new file mode 100644 index 0000000..e6834b4 --- /dev/null +++ b/src/modules/creators/creator-detail-no-trade-history.integration.test.ts @@ -0,0 +1,119 @@ +// Integration test: creator stats endpoint — no trade history +// +// Verifies that: +// 1. A creator with no trade history returns a valid response +// 2. Supply and holder count fields are zero rather than null or absent +// 3. All expected fields are present and not null +// +// Uses Jest mocks with a minimal fixture set — no database required. +// Follows the same conventions as creator-detail-empty-social-links.integration.test.ts + +import { httpGetCreatorStats } from './creators.controllers.js'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(params: Record = {}): any { + return { params }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators/:id/stats — no trade history', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns HTTP 200 for a creator with no trade history', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('response includes holderCount field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('holderCount'); + expect(body.data.holderCount).toBe(0); + }); + + it('response includes totalSupply field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('totalSupply'); + expect(body.data.totalSupply).toBe(0); + }); + + it('response includes totalVolume field set to zero', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('totalVolume'); + expect(body.data.totalVolume).toBe(0); + }); + + it('holderCount is not null or absent', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.holderCount).not.toBeNull(); + expect(body.data.holderCount).toBeDefined(); + }); + + it('totalSupply is not null or absent', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.totalSupply).not.toBeNull(); + expect(body.data.totalSupply).toBeDefined(); + }); + + it('response envelope is well-formed with success field', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + }); + + it('all expected numeric fields are present in response', async () => { + const req = makeReq({ id: 'creator-1' }); + const res = makeRes(); + await httpGetCreatorStats(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const expectedFields = ['holderCount', 'totalSupply', 'totalVolume']; + expectedFields.forEach(field => { + expect(body.data).toHaveProperty(field); + expect(typeof body.data[field]).toBe('number'); + }); + }); +}); diff --git a/src/modules/creators/creator-list-negative-page-size.integration.test.ts b/src/modules/creators/creator-list-negative-page-size.integration.test.ts new file mode 100644 index 0000000..4ee4e1c --- /dev/null +++ b/src/modules/creators/creator-list-negative-page-size.integration.test.ts @@ -0,0 +1,135 @@ +// Integration test: creator list endpoint — negative page size +// +// Verifies that: +// 1. A request with a negative page size returns HTTP 400 +// 2. The error body matches the standard validation error shape +// 3. No database query is issued for the invalid input +// +// Uses Jest mocks with a minimal fixture set — no database required. +// Follows the same conventions as creator-list-page-size-boundary.integration.test.ts + +import { httpListCreators } from './creators.controllers.js'; +import * as creatorsUtils from './creators.utils.js'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — negative page size', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects limit=-1 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('rejects limit=-10 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-10' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('rejects limit=-100 with HTTP 400', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-100' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + }); + + it('does not call fetchCreatorList when limit is negative', async () => { + const spy = jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-5' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('response body matches standard validation error shape', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', false); + expect(body).toHaveProperty('message'); + expect(body).toHaveProperty('data'); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('error details include the limit field', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.length).toBeGreaterThan(0); + expect(body.data[0]).toHaveProperty('field'); + expect(body.data[0].field).toBe('limit'); + }); + + it('error message indicates invalid value', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data[0]).toHaveProperty('message'); + expect(body.data[0].message).toBeDefined(); + }); + + it('response does not include items array for negative limit', async () => { + jest.spyOn(creatorsUtils, 'fetchCreatorList'); + + const req = makeReq({ limit: '-1' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data?.items).toBeUndefined(); + }); +}); diff --git a/src/modules/wallet/wallet.utils.ts b/src/modules/wallet/wallet.utils.ts index 73a5ab2..f2710f6 100644 --- a/src/modules/wallet/wallet.utils.ts +++ b/src/modules/wallet/wallet.utils.ts @@ -1,5 +1,6 @@ import { prisma } from '../../utils/prisma.utils'; import { MapUserToWalletType } from './wallet.schemas'; +import { logger } from '../../utils/logger.utils'; /** * Service boundary for Stellar wallet identity mapping. @@ -11,54 +12,87 @@ import { MapUserToWalletType } from './wallet.schemas'; * If the user already has a wallet, it updates the address. * Ensures the address is unique across the system. */ -export const upsertStellarWallet = async (input: MapUserToWalletType) => { - return await prisma.stellarWallet.upsert({ - where: { - userId: input.userId, - }, - update: { - address: input.address, - }, - create: { - userId: input.userId, - address: input.address, - }, - }); +export const upsertStellarWallet = async ( + input: MapUserToWalletType, + requestId?: string +) => { + try { + return await prisma.stellarWallet.upsert({ + where: { + userId: input.userId, + }, + update: { + address: input.address, + }, + create: { + userId: input.userId, + address: input.address, + }, + }); + } catch (error: any) { + // Log duplicate wallet address errors with structured context + if (error.code === 'P2002' && error.meta?.target?.includes('address')) { + // Mask the address for logging (show first 8 and last 4 characters) + const maskedAddress = maskWalletAddress(input.address); + + logger.warn( + { + type: 'wallet_address_duplicate', + userId: input.userId, + maskedAddress, + ...(requestId ? { requestId } : {}), + }, + 'Duplicate wallet address detected during upsert' + ); + } + throw error; + } }; +/** + * Masks a wallet address for logging purposes. + * Shows first 8 and last 4 characters with asterisks in between. + */ +function maskWalletAddress(address: string): string { + if (!address || address.length < 12) { + return '***'; + } + return `${address.slice(0, 8)}...${address.slice(-4)}`; +} + /** * Retrieves the Stellar wallet associated with a user ID. */ export const getStellarWalletByUserId = async (userId: string) => { - return await prisma.stellarWallet.findUnique({ - where: { - userId, - }, - }); + return await prisma.stellarWallet.findUnique({ + where: { + userId, + }, + }); }; /** * Retrieves the user associated with a Stellar address. */ export const getUserByStellarAddress = async (address: string) => { - return await prisma.stellarWallet.findUnique({ - where: { - address, - }, - include: { - user: true, - }, - }); + return await prisma.stellarWallet.findUnique({ + where: { + address, + }, + include: { + user: true, + }, + }); }; /** * Checks if a Stellar address is already registered in the system. */ export const isStellarAddressRegistered = async (address: string) => { - const wallet = await prisma.stellarWallet.findUnique({ - where: { - address, - }, - }); - return !!wallet; + const wallet = await prisma.stellarWallet.findUnique({ + where: { + address, + }, + }); + return !!wallet; };