From 4e1fc8f9d541a806bc4d46487cfbba0f96a5f283 Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:08:19 +0000 Subject: [PATCH 1/3] feat: add creator analytics endpoint with buy volume and unique buyers (#708) - Add GET /api/v1/creators/:id/analytics endpoint protected by requireCreatorProfileOwnership middleware - Controller aggregates buy volume (total XLM in stroops) and unique buyer count from the Trade read model - Route returns 401 without wallet header; 403 when caller does not own the creator profile; 405 for non-GET methods - Integration test covers: three seeded buys producing 1000 XLM volume and 2 unique buyers, zero-state creator, buyer deduplication, and authentication enforcement (401/403) Closes #708 --- .../creator-analytics.integration.test.ts | 304 ++++++++++++++++++ src/modules/creators/creators.controllers.ts | 55 ++++ src/modules/creators/creators.routes.ts | 21 ++ 3 files changed, 380 insertions(+) create mode 100644 src/modules/creators/creator-analytics.integration.test.ts diff --git a/src/modules/creators/creator-analytics.integration.test.ts b/src/modules/creators/creator-analytics.integration.test.ts new file mode 100644 index 0000000..58d853f --- /dev/null +++ b/src/modules/creators/creator-analytics.integration.test.ts @@ -0,0 +1,304 @@ +// Integration test: creator analytics endpoint — buy volume and unique buyers (#708) +// +// Verifies that: +// 1. Total buy volume is correctly aggregated from trade history +// 2. Unique buyer count deduplicates repeat buyers +// 3. A creator with no trades returns zero volume and zero buyers +// 4. Unauthenticated requests receive 403 +// +// Two sections: +// - Controller-level tests (mock prisma directly, test the handler function) +// - Supertest-based auth tests (verify middleware protects the route) +// +// Follows existing conventions from creator-stats-buy-transaction.integration.test.ts +// and creator-profile-protected.integration.test.ts. + +// ── Module mocks (hoisted by Jest; applied to both test sections) ───────────── +jest.mock('chalk', () => ({ + red: (text: string) => text, + green: (text: string) => text, + magenta: (text: string) => text, + cyan: (text: string) => text, +})); + +jest.mock('tspec', () => ({ + TspecDocsMiddleware: jest.fn().mockResolvedValue([]), +})); + +// We mock the real prisma export object so that any code that imports prisma +// (controllers, middleware, services) gets the same shared mock we can control. +const mockPrisma = { + creatorProfile: { + findFirst: jest.fn(), + }, + trade: { + findMany: jest.fn(), + }, + stellarWallet: { + findUnique: jest.fn(), + }, +}; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: mockPrisma, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + isLevelEnabled: jest.fn().mockReturnValue(false), + }, +})); + +jest.mock('../../config', () => ({ + envConfig: { + MODE: 'test', + PORT: 3000, + ENABLE_REQUEST_LOGGING: false, + }, + appConfig: { allowedOrigins: [] }, +})); + +// ── Imports (after jest.mock so hoisting ensures they resolve to the mocks) ─── + +import { httpGetCreatorAnalytics } from './creators.controllers'; +import { prisma } from '../../utils/prisma.utils'; + +// ── Lightweight request/response mocks ──────────────────────────────────────── + +function makeReq(creatorId: string): any { + return { + params: { id: creatorId }, + }; +} + +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(); +} + +// ── Stellar addresses for the test ──────────────────────────────────────────── + +const walletA = 'GWALLETA11111111111111111111111111111111111111111111111'; +const walletB = 'GWALLETB22222222222222222222222222222222222222222222222'; + +// 500 XLM in stroops (1 XLM = 10^7 stroops) +const XLM_500 = (500n * 10_000_000n).toString(); +// 200 XLM in stroops +const XLM_200 = (200n * 10_000_000n).toString(); +// 300 XLM in stroops +const XLM_300 = (300n * 10_000_000n).toString(); +// 400 XLM in stroops +const XLM_400 = (400n * 10_000_000n).toString(); +// 1000 XLM in stroops +const XLM_1000 = (1000n * 10_000_000n).toString(); +// 100 XLM in stroops +const XLM_100 = (100n * 10_000_000n).toString(); + +// ── Controller-level tests (mock-based, direct handler invocation) ──────────── + +describe('#708 Integration test: creator analytics endpoint', () => { + const creatorId = 'creator-analytics-1'; + + // In-memory trade store + const tradeStore: Array<{ buyer: string; creatorId: string; price: string }> = + []; + + beforeEach(() => { + jest.clearAllMocks(); + tradeStore.length = 0; + + mockPrisma.creatorProfile.findFirst.mockResolvedValue({ id: creatorId }); + + mockPrisma.trade.findMany.mockImplementation(async (args: any) => { + return tradeStore.filter(t => t.creatorId === args.where.creatorId); + }); + }); + + describe('with seeded buy transactions', () => { + beforeEach(() => { + // Seed three buy transactions: + // Wallet A: 500 XLM + // Wallet B: 200 XLM + // Wallet A: 300 XLM + tradeStore.push( + { buyer: walletA, creatorId, price: XLM_500 }, + { buyer: walletB, creatorId, price: XLM_200 }, + { buyer: walletA, creatorId, price: XLM_300 } + ); + }); + + it('returns total buy volume of 1000 XLM in stroops', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const data = res.json.mock.calls[0][0].data; + expect(data.buyVolume).toBe(XLM_1000); + }); + + it('returns unique buyer count of 2 (wallet A counted once despite two purchases)', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(data.uniqueBuyers).toBe(2); + }); + + it('response envelope includes success: true', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('success', true); + expect(body).toHaveProperty('data'); + }); + + it('buyVolume is a string representing stroops', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(typeof data.buyVolume).toBe('string'); + expect(BigInt(data.buyVolume)).toBe(10_000_000_000n); + }); + + it('uniqueBuyers is a number', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(typeof data.uniqueBuyers).toBe('number'); + }); + }); + + describe('creator with no transactions', () => { + it('returns buyVolume "0" for a creator with no trades', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const data = res.json.mock.calls[0][0].data; + expect(data.buyVolume).toBe('0'); + }); + + it('returns uniqueBuyers 0 for a creator with no trades', async () => { + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(data.uniqueBuyers).toBe(0); + }); + }); + + describe('edge cases', () => { + it('handles a creator with a single buyer making a single purchase', async () => { + tradeStore.push({ buyer: walletA, creatorId, price: XLM_500 }); + + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + expect(data.buyVolume).toBe(XLM_500); + expect(data.uniqueBuyers).toBe(1); + }); + + it('deduplicates buyers when the same wallet buys many times', async () => { + tradeStore.push( + { buyer: walletA, creatorId, price: XLM_100 }, + { buyer: walletA, creatorId, price: XLM_200 }, + { buyer: walletA, creatorId, price: XLM_300 }, + { buyer: walletA, creatorId, price: XLM_400 }, + { buyer: walletA, creatorId, price: XLM_500 } + ); + + const req = makeReq(creatorId); + const res = makeRes(); + await httpGetCreatorAnalytics(req, res, makeNext()); + + const data = res.json.mock.calls[0][0].data; + // 100 + 200 + 300 + 400 + 500 = 1500 + const expectedVolume = ( + 100n + 200n + 300n + 400n + 500n + ) * 10_000_000n; + expect(data.buyVolume).toBe(expectedVolume.toString()); + expect(data.uniqueBuyers).toBe(1); + }); + }); +}); + +// ── Supertest-based auth tests ──────────────────────────────────────────────── + +import supertest from 'supertest'; +import app from '../../app'; + +describe('GET /api/v1/creators/:id/analytics — auth protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Set up the mock so that the wallet-ownership middleware sees a + // wallet that does NOT own the creator profile → 403 is expected. + mockPrisma.creatorProfile.findFirst.mockResolvedValue({ + id: 'creator-analytics-1', + userId: 'owner-user-id', + }); + mockPrisma.stellarWallet.findUnique.mockResolvedValue({ + userId: 'user-other', + }); + mockPrisma.trade.findMany.mockResolvedValue([]); + }); + + it('returns 401 when x-wallet-address header is missing', async () => { + const res = await supertest(app).get( + '/api/v1/creators/creator-analytics-1/analytics' + ); + + expect(res.status).toBe(401); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'UNAUTHORIZED', + }), + }) + ); + }); + + it('returns 403 when the wallet does not own the creator profile', async () => { + const res = await supertest(app) + .get('/api/v1/creators/creator-analytics-1/analytics') + .set( + 'x-wallet-address', + 'GOTHERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ); + + expect(res.status).toBe(403); + expect(res.body).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + code: 'FORBIDDEN', + }), + }) + ); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index 72dcc0a..23508a1 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -267,6 +267,61 @@ export const httpGetCreatorLeaderboard: AsyncController = async ( } }; +/** + * Controller for GET /api/v1/creators/:id/analytics + * + * Returns buy volume (total XLM spent in stroops) and unique buyer count + * aggregated from the creator's trade history. + * Requires wallet ownership — only the authenticated creator can access + * their own analytics. + */ +export const httpGetCreatorAnalytics: AsyncController = async ( + req, + res, + next +) => { + try { + const rawId = req.params.id; + const creatorId = Array.isArray(rawId) ? rawId[0] : rawId; + + // Resolve the creator profile to get the canonical ID + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: creatorId }, { handle: creatorId }] }, + select: { id: true }, + }); + const resolvedId = creator ? creator.id : creatorId; + + // Fetch all trades for this creator + const trades = await prisma.trade.findMany({ + where: { creatorId: resolvedId }, + select: { + buyer: true, + price: true, + }, + }); + + // Compute total buy volume (sum of prices, in stroops) + let buyVolume = 0n; + const uniqueBuyers = new Set(); + + for (const trade of trades) { + const price = BigInt(trade.price); + buyVolume += price; + uniqueBuyers.add(trade.buyer); + } + + const analytics = { + buyVolume: buyVolume.toString(), + uniqueBuyers: uniqueBuyers.size, + }; + + attachTimestampHeader(res); + sendSuccess(res, analytics); + } catch (error) { + next(error); + } +}; + /** * Controller for GET /api/v1/creators/trending * diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 8baf367..d9c277e 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -5,6 +5,7 @@ import { httpGetCreatorStats, httpGetTrendingCreators, httpGetCreatorLeaderboard, + httpGetCreatorAnalytics, } from './creators.controllers'; import { httpGetCreatorHolders } from './creator-holders.controller'; import { cacheControl } from '../../middlewares/cache-control.middleware'; @@ -13,6 +14,9 @@ import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-route import { createCreatorReadMetricsMiddleware } from '../../utils/creator-read-metrics.utils'; import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; import { validateCreatorParam } from '../../middlewares/creator-param.middleware'; +import { + requireCreatorProfileOwnership, +} from '../../middlewares/wallet-ownership.middleware'; const creatorsRouter = Router(); @@ -92,6 +96,23 @@ creatorsRouter.get( httpGetTrendingCreators ); +/** + * GET /api/v1/creators/:id/analytics + * + * Returns buy volume and unique buyer count for the authenticated creator. + * Protected route — requires wallet ownership via x-wallet-address header. + */ +creatorsRouter.get( + '/:id/analytics', + validateCreatorParam('id'), + requireCreatorProfileOwnership('id'), + httpGetCreatorAnalytics +); +// 405 handler for /:id/analytics +creatorsRouter.all('/:id/analytics', (_req, res) => { + res.set('Allow', 'GET').sendStatus(405); +}); + /** * GET /api/v1/creators/leaderboard * From aa77b04024be27d794d7e9716f45008651ba22a6 Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:13:24 +0000 Subject: [PATCH 2/3] fix: remove unused prisma import in creator-analytics integration test --- src/modules/creators/creator-analytics.integration.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/creators/creator-analytics.integration.test.ts b/src/modules/creators/creator-analytics.integration.test.ts index 58d853f..797bf3a 100644 --- a/src/modules/creators/creator-analytics.integration.test.ts +++ b/src/modules/creators/creator-analytics.integration.test.ts @@ -65,7 +65,6 @@ jest.mock('../../config', () => ({ // ── Imports (after jest.mock so hoisting ensures they resolve to the mocks) ─── import { httpGetCreatorAnalytics } from './creators.controllers'; -import { prisma } from '../../utils/prisma.utils'; // ── Lightweight request/response mocks ──────────────────────────────────────── From aa8d64632702884d944a1b92f4e230fb179ffb6c Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:22:37 +0000 Subject: [PATCH 3/3] fix: add missing imports lost during merge conflict resolution Add imports for requireStellarSignature, httpBuyCreatorKey, httpCreatePost, and httpListPosts in creators.routes.ts that were dropped during a merge. --- src/modules/creators/creators.routes.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 9d42989..d16475c 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -17,6 +17,9 @@ import { validateCreatorParam } from '../../middlewares/creator-param.middleware import { requireCreatorProfileOwnership, } from '../../middlewares/wallet-ownership.middleware'; +import { requireStellarSignature } from '../../middlewares/stellar-signature.middleware'; +import { httpBuyCreatorKey } from '../creator/buy.controller'; +import { httpCreatePost, httpListPosts } from '../creator/post.controller'; const creatorsRouter = Router();