feat(phase-6): WS3+WS4+WS6 — Analytics, API Tiers, AGNTCY Compliance
WS3 — Advanced Analytics Dashboard: - DB migration: analytics_events table (tenant_id, date, metric_type, count) - AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary - Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated) - AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag) - Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page WS4 — API Gateway Tiers: - DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits) - TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts - tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag) - Agent count enforcement in AgentService.create() - Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed - TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow WS6 — AGNTCY Compliance Certification: - ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards() - Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain) - ComplianceController updated with getComplianceReport, exportAgentCards handlers - routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected QA: - 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass - 673 total unit tests passing; 0 TypeScript errors across API and portal - AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests) - Portal builds cleanly: 9 routes including /analytics and /settings/tier - Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
164
tests/unit/services/AnalyticsService.test.ts
Normal file
164
tests/unit/services/AnalyticsService.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Unit tests for src/services/AnalyticsService.ts
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { AnalyticsService } from '../../../src/services/AnalyticsService';
|
||||
|
||||
// ── Mock pg Pool ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makePool(queryFn: jest.Mock): Pool {
|
||||
return { query: queryFn } as unknown as Pool;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// AnalyticsService
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// recordEvent()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('recordEvent()', () => {
|
||||
it('should call pool.query with the upsert SQL on success', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
|
||||
await service.recordEvent('tenant-1', 'token_issued');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO analytics_events'),
|
||||
['tenant-1', 'token_issued'],
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ON CONFLICT'),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should silently swallow errors — never rejects or throws', async () => {
|
||||
const mockQuery = jest.fn().mockRejectedValue(new Error('DB connection failed'));
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
|
||||
// Must not throw — fire-and-forget contract
|
||||
await expect(service.recordEvent('tenant-err', 'token_issued')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getTokenTrend()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTokenTrend()', () => {
|
||||
it('should cap days at 90 (MAX_TREND_DAYS)', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ date: '2026-04-04', count: '5' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
await service.getTokenTrend('tenant-1', 200);
|
||||
|
||||
// The first positional parameter passed to pool.query should be 90, not 200
|
||||
const callArgs = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(callArgs[1][0]).toBe(90);
|
||||
});
|
||||
|
||||
it('should return mapped rows with date and count as numbers', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ date: '2026-04-01', count: '3' },
|
||||
{ date: '2026-04-02', count: '7' },
|
||||
{ date: '2026-04-03', count: '0' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getTokenTrend('tenant-1', 3);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ date: '2026-04-01', count: 3 });
|
||||
expect(result[1]).toEqual({ date: '2026-04-02', count: 7 });
|
||||
expect(result[2]).toEqual({ date: '2026-04-03', count: 0 });
|
||||
// count must be a number, not a string
|
||||
expect(typeof result[0].count).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getAgentActivity()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAgentActivity()', () => {
|
||||
it('should return rows mapped to correct IAgentActivityEntry shape', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ agent_id: 'agent-uuid-1', dow: '1', hour: '0', count: '12' },
|
||||
{ agent_id: 'agent-uuid-1', dow: '3', hour: '0', count: '5' },
|
||||
{ agent_id: 'agent-uuid-2', dow: '5', hour: '0', count: '20' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentActivity('tenant-1');
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', dow: 1, hour: 0, count: 12 });
|
||||
expect(result[1]).toEqual({ agent_id: 'agent-uuid-1', dow: 3, hour: 0, count: 5 });
|
||||
expect(result[2]).toEqual({ agent_id: 'agent-uuid-2', dow: 5, hour: 0, count: 20 });
|
||||
// Numeric types
|
||||
expect(typeof result[0].dow).toBe('number');
|
||||
expect(typeof result[0].hour).toBe('number');
|
||||
expect(typeof result[0].count).toBe('number');
|
||||
});
|
||||
|
||||
it('should return an empty array when no activity rows exist', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentActivity('tenant-empty');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getAgentUsageSummary()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAgentUsageSummary()', () => {
|
||||
it('should return rows mapped to correct IAgentUsageSummaryEntry shape', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ agent_id: 'agent-uuid-1', name: 'team-a', token_count: '200' },
|
||||
{ agent_id: 'agent-uuid-2', name: 'team-b', token_count: '50' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentUsageSummary('tenant-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', name: 'team-a', token_count: 200 });
|
||||
expect(result[1]).toEqual({ agent_id: 'agent-uuid-2', name: 'team-b', token_count: 50 });
|
||||
// token_count must be a number
|
||||
expect(typeof result[0].token_count).toBe('number');
|
||||
});
|
||||
|
||||
it('should return an empty array when no agents exist', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentUsageSummary('tenant-empty');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user