diff --git a/src/app.test.ts b/src/app.test.ts index 77d2cb8..873260c 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -4,6 +4,10 @@ import request from 'supertest'; import { createApp } from './app.js'; import { InMemoryUsageEventsRepository } from './repositories/usageEventsRepository.js'; +import type { Api } from './db/schema.js'; +import type { ApiRepository, ApiListFilters } from './repositories/apiRepository.js'; +import type { Developer } from './db/schema.js'; +import type { DeveloperRepository } from './repositories/developerRepository.js'; import { InMemoryApiRepository } from './repositories/apiRepository.js'; const seedRepository = () => @@ -55,6 +59,130 @@ const seedRepository = () => }, ]); +const developerProfile: Developer = { + id: 11, + user_id: 'dev-1', + name: 'Test Developer', + website: null, + description: null, + category: null, + created_at: 1, + updated_at: 1, +}; + +const sampleApis: Api[] = [ + { + id: 101, + developer_id: 11, + name: 'Search API', + description: null, + base_url: 'https://search.example.com', + logo_url: null, + category: 'search', + status: 'active', + created_at: 1, + updated_at: 1, + }, + { + id: 102, + developer_id: 11, + name: 'Chat API', + description: null, + base_url: 'https://chat.example.com', + logo_url: null, + category: 'chat', + status: 'active', + created_at: 1, + updated_at: 1, + }, + { + id: 103, + developer_id: 11, + name: 'Archived API', + description: null, + base_url: 'https://archive.example.com', + logo_url: null, + category: 'archive', + status: 'archived', + created_at: 1, + updated_at: 1, + }, +]; + +class FakeApiRepository implements ApiRepository { + constructor(private readonly apis: Api[]) {} + + async listByDeveloper(developerId: number, filters: ApiListFilters = {}): Promise { + let results = this.apis.filter((api) => api.developer_id === developerId); + if (filters.status) { + results = results.filter((api) => api.status === filters.status); + } + if (typeof filters.offset === 'number') { + results = results.slice(filters.offset); + } + if (typeof filters.limit === 'number') { + results = results.slice(0, filters.limit); + } + return results; + } +} + +const createDeveloperRepository = (profile?: Developer): DeveloperRepository => ({ + async findByUserId(userId: string) { + if (profile && profile.user_id === userId) { + return profile; + } + return undefined; + }, +}); + +const usageEventsForApis = () => + new InMemoryUsageEventsRepository([ + { + id: 'evt-search-1', + developerId: 'dev-1', + apiId: '101', + endpoint: '/v1/search', + userId: 'user-a', + occurredAt: new Date('2026-02-01T01:00:00.000Z'), + revenue: 100n, + }, + { + id: 'evt-search-2', + developerId: 'dev-1', + apiId: '101', + endpoint: '/v1/search', + userId: 'user-b', + occurredAt: new Date('2026-02-01T02:00:00.000Z'), + revenue: 200n, + }, + { + id: 'evt-chat-1', + developerId: 'dev-1', + apiId: '102', + endpoint: '/v1/send', + userId: 'user-c', + occurredAt: new Date('2026-02-02T01:00:00.000Z'), + revenue: 150n, + }, + { + id: 'evt-other', + developerId: 'dev-2', + apiId: '101', + endpoint: '/v1/search', + userId: 'user-z', + occurredAt: new Date('2026-02-03T01:00:00.000Z'), + revenue: 999n, + }, + ]); + +const createDeveloperApisApp = () => + createApp({ + usageEventsRepository: usageEventsForApis(), + developerRepository: createDeveloperRepository(developerProfile), + apiRepository: new FakeApiRepository(sampleApis), + }); + test('GET /api/developers/analytics returns 401 when unauthenticated', async () => { const app = createApp({ usageEventsRepository: seedRepository() }); const response = await request(app).get('/api/developers/analytics'); @@ -136,6 +264,51 @@ test('GET /api/developers/analytics filters by apiId and blocks non-owned API', assert.equal(blocked.status, 403); }); +test('GET /api/developers/apis returns 401 when unauthenticated', async () => { + const response = await request(createDeveloperApisApp()).get('/api/developers/apis'); + assert.equal(response.status, 401); +}); + +test('GET /api/developers/apis returns 404 when developer profile is missing', async () => { + const app = createApp({ + usageEventsRepository: usageEventsForApis(), + developerRepository: createDeveloperRepository(undefined), + apiRepository: new FakeApiRepository(sampleApis), + }); + const response = await request(app).get('/api/developers/apis').set('x-user-id', 'dev-1'); + assert.equal(response.status, 404); +}); + +test('GET /api/developers/apis validates status query parameter', async () => { + const response = await request(createDeveloperApisApp()) + .get('/api/developers/apis?status=unknown') + .set('x-user-id', 'dev-1'); + assert.equal(response.status, 400); +}); + +test('GET /api/developers/apis lists APIs with stats, filters, and pagination', async () => { + const app = createDeveloperApisApp(); + const fullResponse = await request(app).get('/api/developers/apis').set('x-user-id', 'dev-1'); + assert.equal(fullResponse.status, 200); + assert.deepEqual(fullResponse.body.data, [ + { id: 101, name: 'Search API', status: 'active', callCount: 2, revenue: '300' }, + { id: 102, name: 'Chat API', status: 'active', callCount: 1, revenue: '150' }, + { id: 103, name: 'Archived API', status: 'archived', callCount: 0 }, + ]); + + const limited = await request(app) + .get('/api/developers/apis?limit=1&offset=1') + .set('x-user-id', 'dev-1'); + assert.deepEqual(limited.body.data, [ + { id: 102, name: 'Chat API', status: 'active', callCount: 1, revenue: '150' }, + ]); + + const filtered = await request(app) + .get('/api/developers/apis?status=archived') + .set('x-user-id', 'dev-1'); + assert.deepEqual(filtered.body.data, [ + { id: 103, name: 'Archived API', status: 'archived', callCount: 0 }, + ]); // ── GET /api/apis/:id ──────────────────────────────────────────────────────── const buildApiRepo = () => { diff --git a/src/app.ts b/src/app.ts index 11b2c5d..ed07bb4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,9 @@ import { type GroupBy, type UsageEventsRepository, } from './repositories/usageEventsRepository.js'; +import { defaultApiRepository, type ApiRepository } from './repositories/apiRepository.js'; +import { defaultDeveloperRepository, type DeveloperRepository } from './repositories/developerRepository.js'; +import { apiStatusEnum, type ApiStatus } from './db/schema.js'; import type { ApiRepository } from './repositories/apiRepository.js'; import { requireAuth, type AuthenticatedLocals } from './middleware/requireAuth.js'; import { buildDeveloperAnalytics } from './services/developerAnalytics.js'; @@ -16,6 +19,7 @@ import { requestLogger } from './middleware/logging.js'; interface AppDependencies { usageEventsRepository: UsageEventsRepository; apiRepository: ApiRepository; + developerRepository: DeveloperRepository; } const isValidGroupBy = (value: string): value is GroupBy => @@ -33,10 +37,26 @@ const parseDate = (value: unknown): Date | null => { return date; }; +const parseNonNegativeIntegerParam = ( + value: unknown +): { value?: number; invalid: boolean } => { + if (typeof value !== 'string' || value.trim() === '') { + return { value: undefined, invalid: false }; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { + return { value: undefined, invalid: true }; + } + return { value: parsed, invalid: false }; +}; + export const createApp = (dependencies?: Partial) => { const app = express(); const usageEventsRepository = dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); + const apiRepository = dependencies?.apiRepository ?? defaultApiRepository; + const developerRepository = dependencies?.developerRepository ?? defaultDeveloperRepository; app.use(requestIdMiddleware); // Lazy singleton for production Drizzle repo; injected repo is used in tests. @@ -120,6 +140,69 @@ export const createApp = (dependencies?: Partial) => { res.json({ calls: 0, period: 'current' }); }); + app.get('/api/developers/apis', requireAuth, async (req, res: express.Response) => { + const user = res.locals.authenticatedUser; + if (!user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const developer = await developerRepository.findByUserId(user.id); + if (!developer) { + res.status(404).json({ error: 'Developer profile not found' }); + return; + } + + const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined; + let statusFilter: ApiStatus | undefined; + if (statusParam) { + if (!apiStatusEnum.includes(statusParam as ApiStatus)) { + res + .status(400) + .json({ error: `status must be one of: ${apiStatusEnum.join(', ')}` }); + return; + } + statusFilter = statusParam as ApiStatus; + } + + const limitParam = parseNonNegativeIntegerParam(req.query.limit); + if (limitParam.invalid) { + res.status(400).json({ error: 'limit must be a non-negative integer' }); + return; + } + + const offsetParam = parseNonNegativeIntegerParam(req.query.offset); + if (offsetParam.invalid) { + res.status(400).json({ error: 'offset must be a non-negative integer' }); + return; + } + + const apis = await apiRepository.listByDeveloper(developer.id, { + status: statusFilter, + ...(typeof limitParam.value === 'number' ? { limit: limitParam.value } : {}), + ...(typeof offsetParam.value === 'number' ? { offset: offsetParam.value } : {}), + }); + + const usageStats = await usageEventsRepository.aggregateByDeveloper(user.id); + const statsByApi = new Map(usageStats.map((stat) => [stat.apiId, stat])); + + const payload = apis.map((api) => { + const stats = statsByApi.get(String(api.id)); + const entry: { id: number; name: string; status: ApiStatus; callCount: number; revenue?: string } = { + id: api.id, + name: api.name, + status: api.status, + callCount: stats?.calls ?? 0, + }; + if (stats) { + entry.revenue = stats.revenue.toString(); + } + return entry; + }); + + res.json({ data: payload }); + }); + app.get('/api/developers/analytics', requireAuth, async (req, res: express.Response) => { const user = res.locals.authenticatedUser; if (!user) { diff --git a/src/repositories/apiRepository.ts b/src/repositories/apiRepository.ts index c027b8f..7ddbabb 100644 --- a/src/repositories/apiRepository.ts +++ b/src/repositories/apiRepository.ts @@ -1,3 +1,36 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import type { Api, ApiStatus } from '../db/schema.js'; + +export interface ApiListFilters { + status?: ApiStatus; + limit?: number; + offset?: number; +} + +export interface ApiRepository { + listByDeveloper(developerId: number, filters?: ApiListFilters): Promise; +} + +export const defaultApiRepository: ApiRepository = { + async listByDeveloper(developerId, filters = {}) { + let query = db.select().from(schema.apis).where(eq(schema.apis.developer_id, developerId)); + + if (filters.status) { + query = query.where(eq(schema.apis.status, filters.status)); + } + + if (typeof filters.limit === 'number') { + query = query.limit(filters.limit); + } + + if (typeof filters.offset === 'number') { + query = query.offset(filters.offset); + } + + return query; + }, +}; export interface ApiDeveloperInfo { name: string | null; website: string | null; diff --git a/src/repositories/developerRepository.ts b/src/repositories/developerRepository.ts index 5393569..0968393 100644 --- a/src/repositories/developerRepository.ts +++ b/src/repositories/developerRepository.ts @@ -2,6 +2,14 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import type { Developer, NewDeveloper } from '../db/schema.js'; +export interface DeveloperRepository { + findByUserId(userId: string): Promise; +} + +export const defaultDeveloperRepository: DeveloperRepository = { + findByUserId, +}; + export async function findByUserId(userId: string): Promise { const rows = await db .select() diff --git a/src/repositories/usageEventsRepository.ts b/src/repositories/usageEventsRepository.ts index ecc6811..d44f122 100644 --- a/src/repositories/usageEventsRepository.ts +++ b/src/repositories/usageEventsRepository.ts @@ -17,9 +17,16 @@ export interface UsageEventQuery { apiId?: string; } +export interface UsageStats { + apiId: string; + calls: number; + revenue: bigint; +} + export interface UsageEventsRepository { findByDeveloper(query: UsageEventQuery): Promise; developerOwnsApi(developerId: string, apiId: string): Promise; + aggregateByDeveloper(developerId: string): Promise; } export class InMemoryUsageEventsRepository implements UsageEventsRepository { @@ -44,4 +51,26 @@ export class InMemoryUsageEventsRepository implements UsageEventsRepository { (event) => event.developerId === developerId && event.apiId === apiId ); } + + async aggregateByDeveloper(developerId: string): Promise { + const statsByApi = new Map(); + for (const event of this.events) { + if (event.developerId !== developerId) { + continue; + } + const existing = statsByApi.get(event.apiId); + if (existing) { + existing.calls += 1; + existing.revenue += event.revenue; + } else { + statsByApi.set(event.apiId, { calls: 1, revenue: event.revenue }); + } + } + + return [...statsByApi.entries()].map(([apiId, values]) => ({ + apiId, + calls: values.calls, + revenue: values.revenue, + })); + } }