Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () =>
Expand Down Expand Up @@ -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<Api[]> {
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');
Expand Down Expand Up @@ -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 = () => {
Expand Down
83 changes: 83 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 =>
Expand All @@ -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<AppDependencies>) => {
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.
Expand Down Expand Up @@ -120,6 +140,69 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
res.json({ calls: 0, period: 'current' });
});

app.get('/api/developers/apis', requireAuth, async (req, res: express.Response<unknown, AuthenticatedLocals>) => {
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<unknown, AuthenticatedLocals>) => {
const user = res.locals.authenticatedUser;
if (!user) {
Expand Down
33 changes: 33 additions & 0 deletions src/repositories/apiRepository.ts
Original file line number Diff line number Diff line change
@@ -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<Api[]>;
}

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;
Expand Down
8 changes: 8 additions & 0 deletions src/repositories/developerRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Developer | undefined>;
}

export const defaultDeveloperRepository: DeveloperRepository = {
findByUserId,
};

export async function findByUserId(userId: string): Promise<Developer | undefined> {
const rows = await db
.select()
Expand Down
Loading
Loading