diff --git a/docs/billing-forecast-endpoint.md b/docs/billing-forecast-endpoint.md index a44ede57..418e6e43 100644 --- a/docs/billing-forecast-endpoint.md +++ b/docs/billing-forecast-endpoint.md @@ -6,11 +6,11 @@ ## Overview -The `/api/billing/forecast` endpoint allows authenticated developers to forecast their upcoming billing amount based on their actual current run rate over a configurable historical lookback window. +The `/api/billing/forecast` endpoint (mounted via `createBillingForecastRouter` in `src/routes/index.ts` alongside `/api/forecast` from `createForecastRouter`) allows authenticated developers to forecast their upcoming billing amount based on their actual current run rate over a configurable historical lookback window. ## Authentication -Requires Bearer JWT token or `x-user-id` header (via `requireAuth` middleware). Unauthenticated requests return `401 Unauthorized`. +Requires Bearer JWT token (via `requireAuth` middleware). Unauthenticated requests return `401 Unauthorized`. ## Query Parameters diff --git a/src/routes/billing/forecast.ts b/src/routes/billing/forecast.ts index 2c722f9e..8fb44bbf 100644 --- a/src/routes/billing/forecast.ts +++ b/src/routes/billing/forecast.ts @@ -82,7 +82,7 @@ export interface BillingForecastRouterDeps { } /** - * Creates the router for /api/billing/forecast. + * Creates the router for /api/billing/forecast (mounted by createApiRouter in src/routes/index.ts). */ export function createBillingForecastRouter(deps: BillingForecastRouterDeps = {}): Router { const router = Router(); diff --git a/src/routes/forecast.test.ts b/src/routes/forecast.test.ts index a3b12621..bebca693 100644 --- a/src/routes/forecast.test.ts +++ b/src/routes/forecast.test.ts @@ -13,7 +13,12 @@ import { forecastLogger } from '../middleware/forecastAccessLog.js'; import jwt from 'jsonwebtoken'; // Mock auditService to capture audit calls -jest.mock('../services/auditService.js'); +jest.mock('../services/auditService.js', () => ({ + defaultAuditService: { + record: jest.fn(), + }, + appendAuditRow: jest.fn(), +})); const mockAuditService = defaultAuditService as jest.Mocked; // Mock JWT to simulate authenticated users @@ -823,3 +828,42 @@ describe('Forecast access log integration', () => { expect(payload.status).toBe(500); }); }); + +// ============================================================================= +// API Index Router Mount Integration (Issue #1215) +// ============================================================================= + +describe('createApiRouter forecast mounting (#1215)', () => { + it('mounts both /api/forecast and /api/billing/forecast on createApiRouter', async () => { + const { createApiRouter } = await import('./index.js'); + const apiApp = express(); + apiApp.use(express.json()); + apiApp.use(requestIdMiddleware); + apiApp.use(auditEnrichMiddleware); + apiApp.use('/api', createApiRouter()); + apiApp.use(errorHandler); + + const listRes = await request(apiApp).get('/api/forecast?limit=5'); + expect(listRes.status).toBe(200); + expect(listRes.body.success).toBe(true); + expect(listRes.body.data.items).toHaveLength(5); + expect(listRes.body.data.total).toBe(24); + + const createRes = await request(apiApp) + .post('/api/forecast') + .set('Authorization', 'Bearer mock-token-dev-index-1') + .send({ name: 'Mounted Forecast', description: 'Created via createApiRouter' }); + expect(createRes.status).toBe(201); + expect(createRes.body.data.name).toBe('Mounted Forecast'); + + const billingRes = await request(apiApp) + .get('/api/billing/forecast?lookbackDays=15&period=week') + .set('Authorization', 'Bearer mock-token-dev-index-1'); + expect(billingRes.status).toBe(200); + expect(billingRes.body.userId).toBe('dev-index-1'); + expect(billingRes.body.lookbackDays).toBe(15); + expect(billingRes.body.forecastPeriod).toBe('week'); + expect(billingRes.body.forecastDays).toBe(7); + }); +}); + diff --git a/src/routes/forecast.ts b/src/routes/forecast.ts index 430b33fc..b8b60fc1 100644 --- a/src/routes/forecast.ts +++ b/src/routes/forecast.ts @@ -101,6 +101,10 @@ export interface Forecast { // In-memory store for forecast data (simulated persistence) const forecastStore = new Map(); +export function resetForecastStoreForTests(): void { + forecastStore.clear(); +} + // ----------------------------------------------------------------------- // Helper functions // ----------------------------------------------------------------------- diff --git a/src/routes/index.ts b/src/routes/index.ts index a81e1670..694379c1 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -32,6 +32,7 @@ import type { SubscriptionRepository } from "../repositories/subscriptionReposit import type { DeveloperRepository } from "../repositories/developerRepository.js"; import type { ApiRepository } from "../repositories/apiRepository.js"; import { createForecastRouter } from "./forecast.js"; +import { createBillingForecastRouter } from "./billing/forecast.js"; import { createPlansRouter } from "./plans.js"; import { createCreditsRouter } from "./credits.js"; import type { CreditsRepository } from "../repositories/creditsRepository.js"; @@ -76,6 +77,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { const router = Router(); router.use("/health", healthRouter); + router.use("/forecast", createForecastRouter()); router.use("/plans", createPlansRouter()); // Hot-path credits lookup (idx_credits_lookup_hot) — see migrations/credits_index.sql router.use( @@ -258,6 +260,11 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { } if (billingMiddlewares.length > 0) { + router.use( + "/billing/forecast", + ...billingMiddlewares, + createBillingForecastRouter(), + ); router.use("/billing", ...billingMiddlewares, billingRouter); router.use( "/billing/portal", @@ -265,6 +272,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { createBillingPortalRouter(), ); } else { + router.use("/billing/forecast", createBillingForecastRouter()); router.use("/billing", billingRouter); router.use("/billing/portal", createBillingPortalRouter()); }