Story 2.5: Test Backend Analytics and Categories Route Handlers
Sprint Key: 2-5-test-backend-analytics-and-categories-route-handlers
Epic: 2
PRD: testing-framework
Story 2.5: Test Backend Analytics and Categories Route Handlers
Status: ready-for-dev
Story
As a developer, I want tests for analytics and service-categories route groups, So that these backend API contracts are fully validated.
Acceptance Criteria
-
AC1: Analytics route test file created —
__tests__/routes/analytics.test.jsexists and covers all analytics endpoints via Supertest againstcreateApp(). -
AC2: Analytics GET /api/analytics/dashboard — Test returns 200 with dashboard data when valid token provided. Params
startDate,endDate,localeare all optional (route provides defaults: today/now/en). Returns 401 when no token. -
AC3: Analytics GET /api/analytics/metric/:metric — Test returns 200 with
{ metric, value }for each valid metric (totalQueries, uniqueUsers, averageResponseTime, satisfactionRate). Returns 400 for unsupported metric name. Returns 400 whenstartDate/endDatemissing. -
AC4: Analytics GET /api/analytics — Test returns 200 with general analytics data (queryCount, feedbackCount, avgRating, timeDistribution, categoryDistribution). Supports optional
filtersquery param as JSON string. -
AC5: Analytics GET /api/analytics/timeseries/:metricType — Test returns 200 with time series array for valid metricType (queries, users) and valid interval (hourly, daily, weekly, monthly). Returns 400 for invalid metricType or interval.
-
AC6: Analytics POST /api/analytics/events — Test returns 201 with created event when valid
eventTypeprovided. Returns 400 wheneventTypemissing. Returns 401 when no token. -
AC7: Analytics GET /api/analytics/records and GET /api/analytics/events — Test returns 200 with paginated results. Supports
limit(default 20) andoffset(default 0) query params. Note: These endpoints bypass service methods and query the DB directly viaanalyticsService.db.query(AQL). Mock must providedb: { query: jest.fn() }. -
AC8: Analytics satisfaction endpoints — Test
GET /api/analytics/satisfaction/gaugereturns 200 with gauge data (currentValue, previousValue, changePercentage, target, historicalData). TestGET /api/analytics/satisfaction/heatmapreturns 200 with heatmap array. Both return 400 whenstartDate/endDatemissing. -
AC9: Categories route test file created —
__tests__/routes/categories.test.jsexists and covers service-categories endpoints via Supertest againstcreateApp(). -
AC10: Categories GET /api/service-categories/categories — Test returns 200 with category list. Supports
localequery param (default 'en'). -
AC11: Categories GET /api/service-categories/categories/detailed — Test returns 200 with detailed category data (admin panel variant).
-
AC12: Categories GET /api/service-categories/categories/:categoryId — Test returns 200 with single category when valid ID. Returns 404 when category not found.
-
AC13: Categories translation endpoints — Test
GET /api/service-categories/:categoryId/translationsreturns 200 with translation list. TestGET /api/service-categories/services/:serviceId/translationsreturns 200 with service translations. -
AC14: Categories GET /api/service-categories/search — Test returns 200 with search results containing
categoriesandservicesarrays. Returns 400 whenqueryparam missing. -
AC15: Categories POST /api/service-categories — Test returns 201 with created category when valid
nameENprovided. Returns 400 whennameENmissing. -
AC16: Categories DELETE endpoints — Test
DELETE /api/service-categories/:categoryIdreturns 200 when category exists, 404 when not. TestDELETE /api/service-categories/services/:serviceIdreturns 200 when service exists, 404 when not. -
AC17: Categories PUT endpoints — Test
PUT /api/service-categories/:categoryIdreturns 200 with updated category. TestPUT /api/service-categories/services/:serviceIdreturns 200 with updated service. -
AC18: Service mocks —
analytics-serviceis mocked viajest.mock()with all required methods.service-category-serviceis mocked viajest.mock()with all required methods. -
AC19: Error format — Error responses are split: controller-validated params use
{ error: '...' }(e.g., metric/timeseries/satisfaction 400s), route catch blocks use{ message: error.message }(500s). POST /events 401 uses{ error: 'UNAUTHENTICATED', message: '...' }. -
AC20: Existing tests pass — All existing tests (200+) continue to pass unchanged.
-
AC21: Categories POST /:categoryId/services — Test returns 201 with created service when valid payload provided to
POST /api/service-categories/:categoryId/services. -
AC22: Categories POST /init — Test returns 200 with initialization result from
POST /api/service-categories/init.
Tasks / Subtasks
-
Task 1: Set up analytics test file with mocks (AC: #1 (closed), #18 (closed)) -
1.1 Create __tests__/routes/analytics.test.jswithdescribe('Analytics Routes') -
1.2 Import createAppfrom../../index,requestfromsupertest, fixtures from../fixtures/ -
1.3 Mock shared-libusing centralized mock:jest.mock('../../shared-lib', () => require('../mocks/shared-lib'), { virtual: true }) -
1.4 Mock swagger-jsdocandswagger-ui-expresswith{ virtual: true } -
1.5 Mock auth services: keycloak-auth-service,user-provisioning-service -
1.6 Mock analytics-servicewith all required methods (see Dev Notes for full list) -
1.7 Mock all other services loaded by index.js (see Dev Notes for complete list) -
1.8 Do NOT mock AnalyticsController— the route file instantiates it internally with the mocked service -
1.9 Require ../setup-envat top,createAppinbeforeAll
-
-
Task 2: Write analytics dashboard tests (AC: #2 (closed)) -
2.1 Test: valid token + startDate/endDate → 200 with dashboard data -
2.2 Test: valid token without startDate/endDate → 200 (defaults applied) -
2.3 Test: no token → 401
-
-
Task 3: Write analytics metric tests (AC: #3 (closed)) -
3.1 Test: each valid metric (totalQueries, uniqueUsers, averageResponseTime, satisfactionRate) → 200 -
3.2 Test: unsupported metric → 400 -
3.3 Test: missing date params → 400
-
-
Task 4: Write analytics general, timeseries, events tests (AC: #4 (closed), #5 (closed), #6 (closed), #7 (closed)) -
4.1 Test: GET /api/analytics → 200 with general data -
4.2 Test: GET /api/analytics/timeseries/queries → 200 with time series -
4.3 Test: invalid metricType/interval → 400 -
4.4 Test: POST /api/analytics/events with eventType → 201 -
4.5 Test: POST /api/analytics/events without eventType → 400 -
4.6 Test: GET /api/analytics/records → 200 with pagination -
4.7 Test: GET /api/analytics/events → 200 with pagination
-
-
Task 5: Write analytics satisfaction tests (AC: #8 (closed)) -
5.1 Test: GET satisfaction/gauge → 200 with gauge data -
5.2 Test: GET satisfaction/heatmap → 200 with heatmap data -
5.3 Test: missing date params → 400
-
-
Task 6: Set up categories test file with mocks (AC: #9 (closed), #18 (closed)) -
6.1 Create __tests__/routes/categories.test.jswithdescribe('Service Categories Routes') -
6.2 Same virtual mock pattern as analytics (shared-lib, swagger) -
6.3 Mock service-category-servicewith all required methods (see Dev Notes) -
6.4 Mock all other services loaded by index.js
-
-
Task 7: Write categories GET tests (AC: #10 (closed), #11 (closed), #12 (closed), #13 (closed), #14 (closed)) -
7.1 Test: GET /categories → 200 with category list -
7.2 Test: GET /categories/detailed → 200 with detailed data -
7.3 Test: GET /categories/:categoryId → 200 with single category -
7.4 Test: GET /categories/:categoryId → 404 when not found -
7.5 Test: GET /:categoryId/translations → 200 with translations -
7.6 Test: GET /services/:serviceId/translations → 200 with translations -
7.7 Test: GET /search with query → 200 with results -
7.8 Test: GET /search without query → 400
-
-
Task 8: Write categories POST/PUT/DELETE tests (AC: #15 (closed), #16 (closed), #17 (closed)) -
8.1 Test: POST / with nameEN → 201 with created category -
8.2 Test: POST / without nameEN → 400 -
8.3 Test: DELETE /:categoryId → 200 when exists -
8.4 Test: DELETE /:categoryId → 404 when not found -
8.5 Test: DELETE /services/:serviceId → 200 when exists -
8.6 Test: DELETE /services/:serviceId → 404 when not found -
8.7 Test: PUT /:categoryId → 200 with updated category -
8.8 Test: PUT /services/:serviceId → 200 with updated service -
8.9 Test: POST /:categoryId/services → 201 with created service (AC: #21 (closed)) -
8.10 Test: POST /init → 200 with initialization result (AC: #22 (closed))
-
-
Task 9: Verify existing tests pass (AC: #20 (closed)) -
9.1 Run cd components/gov-chat-backend && npm test— all tests pass -
9.2 Run cd components/gov-chat-backend && npm run lint— no lint errors
-
Dev Notes
Critical Discovery: Route Path Mismatch
The epics file says GET /api/categories/* but the actual route path is /api/service-categories. Route registration in index.js line 472:
{ file: 'service-category-routes', paths: ['/api/service-categories'], serviceName: 'serviceCategoryService', keycloakAuth: true }
All category endpoints are under /api/service-categories, NOT /api/categories.
Analytics Routes Architecture
Source: routes/analytics-routes.js (660 lines)
Factory signature: module.exports = (analyticsService) => { ... }
The route file internally instantiates AnalyticsController:
const AnalyticsController = require('../controllers/analyticsController');
const analyticsController = new AnalyticsController(analyticsService);
Route registration in index.js line 469:
{ file: 'analytics-routes', paths: ['/api/analytics'], serviceName: 'analyticsService', keycloakAuth: true }
Special initialization in index.js (lines ~880-883):
if (config.file === 'analytics-routes') {
const AnalyticsController = require('./controllers/analyticsController');
const analyticsController = new AnalyticsController(service);
routeInstance = routeModule(service, analyticsController);
}
Note: index.js passes 2 args but analytics-routes.js only accepts 1. The route file ignores the second arg and creates its own controller.
Authentication: keycloakAuth: true — middleware applied globally via router.use(keycloakAuthMiddleware.authenticate) (line 19). No admin-only checks.
Analytics Controller
Source: controllers/analyticsController.js (253 lines)
Constructor validates analyticsService has getDashboardAnalytics method:
if (!analyticsService || typeof analyticsService.getDashboardAnalytics !== 'function') {
throw new Error('Invalid analyticsService provided to AnalyticsController');
}
5 methods: getDashboardAnalytics, getMetric, getTimeSeriesData, getSatisfactionGauge, getSatisfactionHeatmap.
Analytics Endpoints (9 total)
| Method | Path | Handler | Params |
|---|---|---|---|
| GET | /api/analytics/dashboard | analyticsController.getDashboardAnalytics | startDate (opt), endDate (opt), locale (opt) |
| GET | /api/analytics/metric/:metric | analyticsController.getMetric | metric enum, startDate*, endDate* |
| GET | /api/analytics | route handler (direct service) | startDate, endDate, filters (JSON), locale |
| GET | /api/analytics/timeseries/:metricType | analyticsController.getTimeSeriesData | metricType enum, interval*, startDate*, endDate* |
| POST | /api/analytics/events | route handler (direct service) | body: eventType*, eventData |
| GET | /api/analytics/records | route handler (analyticsService.db.query AQL) | limit, offset |
| GET | /api/analytics/events | route handler (analyticsService.db.query AQL) | limit, offset |
| GET | /api/analytics/satisfaction/gauge | analyticsController.getSatisfactionGauge | startDate*, endDate*, locale |
| GET | /api/analytics/satisfaction/heatmap | analyticsController.getSatisfactionHeatmap | startDate*, endDate*, locale |
- = required
Analytics Service Mock
Source: services/analytics-service.js (935 lines) — singleton
The mock MUST include these methods (controller constructor validates getDashboardAnalytics):
jest.mock('../../services/analytics-service', () => ({
getDashboardAnalytics: jest.fn(),
getAnalytics: jest.fn(), // Used by GET / handler (direct service call)
getUniqueUsersCount: jest.fn(),
getTimeSeriesData: jest.fn(),
formatDateLabel: jest.fn((t) => t), // Used by timeseries controller to format labels
getSatisfactionGaugeData: jest.fn(),
getSatisfactionHeatmapData: jest.fn(),
recordQuery: jest.fn(),
recordFeedback: jest.fn(),
trackEvent: jest.fn(),
db: {
query: jest.fn().mockResolvedValue({
all: jest.fn().mockResolvedValue([])
})
}, // Used by GET /records and GET /events (raw AQL)
init: jest.fn()
}));
Metric-specific mock return values — The controller's getMetric calls different service methods per metric:
-
totalQueries→ callsgetDashboardAnalytics()→ reads.queries.total -
uniqueUsers→ callsgetUniqueUsersCount() -
averageResponseTime→ callsgetDashboardAnalytics()→ reads.queries.avgResponseTime -
satisfactionRate→ callsgetSatisfactionGaugeData()→ reads.currentValue
Set up mock accordingly:
analyticsService.getDashboardAnalytics.mockResolvedValue({
queries: { total: 1000, avgResponseTime: 2.8 }
});
analyticsService.getUniqueUsersCount.mockResolvedValue(120);
analyticsService.getSatisfactionGaugeData.mockResolvedValue({ currentValue: 85.0 });
Service Category Routes Architecture
Source: routes/service-category-routes.js (676 lines)
Factory signature: module.exports = (serviceCategoryService) => { ... }
Constructor validates: typeof serviceCategoryService.getAllCategoriesWithServices !== 'function'
Route registration in index.js line 472:
{ file: 'service-category-routes', paths: ['/api/service-categories'], serviceName: 'serviceCategoryService', keycloakAuth: true }
Authentication: keycloakAuth: true — global on router. No admin-only checks on any endpoint.
Service Category Endpoints (13 total)
| Method | Path | Service Method |
|---|---|---|
| GET | /categories | getAllCategoriesWithServices(locale) |
| GET | /categories/detailed | getAdminAllCategoriesWithServices(locale) |
| GET | /categories/:categoryId | getCategoryWithServices(categoryId, locale) |
| GET | /:categoryId/translations | getCategoryTranslations(categoryId) |
| GET | /services/:serviceId/translations | getServiceTranslations(serviceId) |
| GET | /search | searchCategoriesAndServices(query, locale) |
| POST | / | createCategory(payload) |
| POST | /:categoryId/services | createServiceWithTranslations(categoryId, payload) |
| POST | /init | initializeDefaultCategoriesAndServices() |
| PUT | /:categoryId | updateCategoryWithTranslations(categoryId, payload) |
| PUT | /services/:serviceId | updateServiceWithTranslations(serviceId, payload) |
| DELETE | /:categoryId | categoryExists(categoryId) then deleteCategory(categoryId) |
| DELETE | /services/:serviceId | deleteService(serviceId) |
Service Category Service Mock
Source: services/service-category-service.js (930 lines) — singleton
jest.mock('../../services/service-category-service', () => ({
getAllCategoriesWithServices: jest.fn(),
getAdminAllCategoriesWithServices: jest.fn(),
getCategoryWithServices: jest.fn(),
getCategoryTranslations: jest.fn(),
getServiceTranslations: jest.fn(),
searchCategoriesAndServices: jest.fn(),
createCategory: jest.fn(),
createServiceWithTranslations: jest.fn(),
updateCategoryWithTranslations: jest.fn(),
updateServiceWithTranslations: jest.fn(),
deleteCategory: jest.fn(),
deleteService: jest.fn(),
categoryExists: jest.fn(),
initializeDefaultCategoriesAndServices: jest.fn(),
upsertCategories: jest.fn(),
upsertServices: jest.fn(),
init: jest.fn()
}));
Mock Strategy: Do NOT Mock AnalyticsController in analytics.test.js
In auth.test.js, the controller is mocked to prevent initialization:
jest.mock('../../controllers/analyticsController', () => {
return function () { return {}; };
});
In analytics.test.js, do NOT use this mock. The route file imports and instantiates the real controller with the mocked service. The controller constructor validates getDashboardAnalytics exists on the mock — your service mock MUST include it.
In categories.test.js, you still need the analyticsController mock (to prevent errors when index.js loads analytics-routes):
jest.mock('../../controllers/analyticsController', () => {
return function () { return {}; };
});
Complete Service Mock List (Required by index.js)
Every service imported by index.js must be mocked to prevent "module not found" or initialization errors:
// Auth services (always required)
jest.mock('../../services/keycloak-auth-service', () => ({
verifyToken: jest.fn(),
checkUserStatusInKeycloak: jest.fn()
}));
jest.mock('../../services/user-provisioning-service', () => ({
provisionUser: jest.fn(),
initialize: jest.fn(),
markUserAsDeleted: jest.fn()
}));
jest.mock('../../services/session-service', () => ({
getUserSessions: jest.fn(),
endSession: jest.fn(),
createSession: jest.fn()
}));
// Mock all other services (prevent index.js from loading them)
jest.mock('../../services/user-profile-service', () => ({}));
jest.mock('../../services/admin-dashboard-service', () => ({}));
jest.mock('../../services/query-service', () => ({}));
jest.mock('../../services/chat-history-service', () => ({}));
jest.mock('../../services/logs-service', () => ({}));
jest.mock('../../services/database-operations-service', () => ({}));
jest.mock('../../services/weather-service', () => ({}));
jest.mock('../../services/security-scan-service', () => ({}));
jest.mock('../../services/translation-service', () => ({}));
For analytics.test.js, mock analytics-service with full method list (see above). For categories.test.js, mock service-category-service with full method list (see above).
Error Response Formats
Analytics error formats are split by handler type:
Controller-validated errors (metric, timeseries, satisfaction):
- 400:
{ error: 'Missing required parameters: startDate and endDate are required' } - 400:
{ error: 'Unsupported metric: <name>' } - 400:
{ error: 'Invalid interval: <val>. Must be one of: hourly, daily, weekly, monthly' } - 500:
{ error: 'Failed to retrieve ...' }
Route handler errors (dashboard, general, events, records):
- 400:
{ message: 'eventType is required' }(POST /events) - 401:
{ error: 'UNAUTHENTICATED', message: 'User not authenticated' }(POST /events — extra auth check) - 500:
{ message: error.message }(catch blocks)
Categories errors:
- 400:
{ message: 'Search query is required' } - 400:
{ message: 'Payload with nameEN is required' }(not "Category nameEN is required") - 404:
{ message: 'Category <id> not found' }(DELETE category — direct response, not next(error)) - 404:
{ message: error.message }(DELETE service — via error.code === 404 check) - 500:
{ message: error.message }
POST /events Extra Auth Check
POST /events (line 404-406) extracts req.user?.iss_sub and returns 401 if missing:
const userId = req.user?.iss_sub;
if (!userId) {
return res.status(401).json({ error: 'UNAUTHENTICATED', message: 'User not authenticated' });
}
Ensure test tokens include iss_sub claim (the standard token fixture uses sub — you need both). Without iss_sub, the endpoint returns 401 even with a valid token.
Timeseries Response Mapping
The controller maps raw time series data to { timestamp, dateLabel, value, userCount }. The formatDateLabel mock defaults to returning the timestamp as-is. Test assertions should check this mapped shape, not the raw service response.
Categories errors:
- 400:
{ message: 'Search query is required' } - 400:
{ message: 'Category nameEN is required' } - 404: (via
next(error)with NotFoundError) - 500:
{ message: error.message }
Token Validation Tests (Shared Pattern)
All endpoints require authentication. Reuse the token test pattern from auth.test.js:
describe('token validation', () => {
it('should return 401 when no Authorization header', async () => {
const response = await request(app).get('/api/analytics/dashboard');
expect(response.status).toBe(401);
});
it('should return 401 when expired token', async () => {
const token = createExpiredToken();
const response = await request(app)
.get('/api/analytics/dashboard')
.set('Authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
});
it('should return 401 when malformed token', async () => {
const response = await request(app)
.get('/api/analytics/dashboard')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(401);
});
});
Write token validation tests once per describe block (not per endpoint) if the middleware is applied globally on the router.
Categories DELETE Behavior
DELETE /api/service-categories/:categoryId calls TWO service methods:
-
categoryExists(categoryId)— returns boolean -
deleteCategory(categoryId)— performs deletion
Returns 404 if category doesn't exist. The route does NOT use next(error) for 404s — it returns res.status(404).json(...) directly.
DELETE /api/service-categories/services/:serviceId catches errors and checks error.code === 404 for specific 404 handling.
Files to Create
| File | Action |
|---|---|
__tests__/routes/analytics.test.js |
NEW |
__tests__/routes/categories.test.js |
NEW |
Files NOT Modified
All existing test files remain unchanged. New test files are purely additive.
Anti-Patterns to Avoid
- Do NOT test
/api/categories/*— the real path is/api/service-categories/* - Do NOT expect 400 for missing dashboard params — they are all optional with defaults
- Do NOT mock
getRecords/getEventson analytics service — those methods are never called; the route queriesanalyticsService.db.query()directly - Do NOT forget to include
iss_subin token claims for POST /events tests - Do NOT call
app.listen()—createApp()returns the app without listening - Do NOT modify existing test files
- Do NOT use ES
import/export— CommonJS only (require/module.exports) - Do NOT create real ArangoDB connections — all DB access must be mocked
- Do NOT use
ioredis-mock— not needed for route handler tests - Do NOT duplicate
process.envoverrides in test files — use centralized__tests__/setup-env.jsviarequire('../setup-env') - Do NOT mock
AnalyticsControllerinanalytics.test.js— the route creates it from the mocked service - Do NOT test service layer logic — this story tests route handlers only (service tests are Story 2.7)
- Do NOT test middleware itself — that's Story 2.8
- Do NOT mock
service-category-servicewith only partial methods — the route constructor validatesgetAllCategoriesWithServicesexists
Project Structure Notes
- Backend files live at
components/gov-chat-backend/root — nosrc/subdirectory - Route tests go in
__tests__/routes/(established by Story 2.3) - CommonJS (
require/module.exports) everywhere in backend - Jest config in
package.json:testMatch: ["**/__tests__/**/*.test.js"]
Downstream Impact
This story covers two route groups (analytics + categories). Stories 2.6 (admin/files), 2.7 (service layer), and 2.8 (middleware) follow the same pattern.
References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.5] — Original story definition
- [Source: _bmad-output/planning-artifacts/architecture.md#Backend Testing] — Jest + Supertest pattern,
__tests__/routes/location - [Source: _bmad-output/implementation-artifacts/2-2-create-backend-test-fixtures-and-shared-mocks.md] — Fixtures from Story 2.2
- [Source: _bmad-output/implementation-artifacts/2-3-test-backend-auth-route-handlers.md] — Established route test pattern
- [Source: components/gov-chat-backend/routes/analytics-routes.js] — Analytics route definitions (660 lines)
- [Source: components/gov-chat-backend/routes/service-category-routes.js] — Categories route definitions (676 lines)
- [Source: components/gov-chat-backend/controllers/analyticsController.js] — Controller with constructor validation (253 lines)
- [Source: components/gov-chat-backend/services/analytics-service.js] — Analytics service singleton (935 lines)
- [Source: components/gov-chat-backend/services/service-category-service.js] — Categories service singleton (930 lines)
- [Source: components/gov-chat-backend/index.js#L469] — Analytics route registration
- [Source: components/gov-chat-backend/index.js#L472] — Service-category route registration
- [Source: components/gov-chat-backend/index.js#L880-883] — Analytics special initialization
Dev Agent Record
Agent Model Used
{{agent_model_name_version}}