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>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for src/services/TierService.ts
|
|
*/
|
|
|
|
import { Pool, QueryResult } from 'pg';
|
|
import Stripe from 'stripe';
|
|
import type { RedisClientType } from 'redis';
|
|
import { TierService } from '../../../src/services/TierService';
|
|
import { ValidationError, TierLimitError } from '../../../src/utils/errors';
|
|
|
|
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function makePool(queryFn: jest.Mock): Pool {
|
|
return { query: queryFn } as unknown as Pool;
|
|
}
|
|
|
|
function makeRedis(getFn: jest.Mock): RedisClientType {
|
|
return { get: getFn } as unknown as RedisClientType;
|
|
}
|
|
|
|
function makeStripe(overrides: Partial<{
|
|
checkoutUrl: string;
|
|
}> = {}): Stripe {
|
|
const url = overrides.checkoutUrl ?? 'https://checkout.stripe.com/tier_upgrade_test';
|
|
return {
|
|
checkout: {
|
|
sessions: {
|
|
create: jest.fn().mockResolvedValue({ url, id: 'cs_tier_1' }),
|
|
},
|
|
},
|
|
} as unknown as Stripe;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// TierService
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('TierService', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// getStatus()
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('getStatus()', () => {
|
|
it('should return correct ITierStatus shape with tier, limits, usage, and resetAt', async () => {
|
|
// Pool: tier query, then agent count query
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ tier: 'pro' }] } as unknown as QueryResult)
|
|
.mockResolvedValueOnce({ rows: [{ count: '7' }] } as unknown as QueryResult);
|
|
|
|
const mockGet = jest.fn()
|
|
.mockResolvedValueOnce('123') // callsToday
|
|
.mockResolvedValueOnce('456'); // tokensToday
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
|
const status = await service.getStatus('org-uuid-1');
|
|
|
|
expect(status.tier).toBe('pro');
|
|
expect(status.limits).toEqual({
|
|
maxAgents: 100,
|
|
maxCallsPerDay: 50_000,
|
|
maxTokensPerDay: 50_000,
|
|
});
|
|
expect(status.usage).toEqual({
|
|
callsToday: 123,
|
|
tokensToday: 456,
|
|
agentCount: 7,
|
|
});
|
|
expect(typeof status.resetAt).toBe('string');
|
|
// resetAt must be a valid ISO 8601 timestamp
|
|
expect(new Date(status.resetAt).toString()).not.toBe('Invalid Date');
|
|
});
|
|
|
|
it('should read usage from Redis keys rate:tier:calls:<orgId> and rate:tier:tokens:<orgId>', async () => {
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
|
|
.mockResolvedValueOnce({ rows: [{ count: '3' }] } as unknown as QueryResult);
|
|
|
|
const mockGet = jest.fn().mockResolvedValue('0');
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
|
await service.getStatus('org-redis-test');
|
|
|
|
expect(mockGet).toHaveBeenCalledWith('rate:tier:calls:org-redis-test');
|
|
expect(mockGet).toHaveBeenCalledWith('rate:tier:tokens:org-redis-test');
|
|
});
|
|
|
|
it('should default to free tier when no organization row is found', async () => {
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult) // no org row
|
|
.mockResolvedValueOnce({ rows: [{ count: '0' }] } as unknown as QueryResult);
|
|
|
|
const mockGet = jest.fn().mockResolvedValue(null);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
|
const status = await service.getStatus('org-unknown');
|
|
|
|
expect(status.tier).toBe('free');
|
|
expect(status.limits.maxAgents).toBe(10);
|
|
});
|
|
|
|
it('should return 0 for Redis counters when Redis keys are absent (null)', async () => {
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
|
|
.mockResolvedValueOnce({ rows: [{ count: '2' }] } as unknown as QueryResult);
|
|
|
|
const mockGet = jest.fn().mockResolvedValue(null);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
|
const status = await service.getStatus('org-no-redis');
|
|
|
|
expect(status.usage.callsToday).toBe(0);
|
|
expect(status.usage.tokensToday).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// initiateUpgrade()
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('initiateUpgrade()', () => {
|
|
it('should throw ValidationError if already on target tier', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ tier: 'pro' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
|
|
await expect(service.initiateUpgrade('org-1', 'pro')).rejects.toThrow(ValidationError);
|
|
});
|
|
|
|
it('should throw ValidationError when downgrade is attempted (pro → free)', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ tier: 'pro' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
|
|
await expect(service.initiateUpgrade('org-1', 'free')).rejects.toThrow(ValidationError);
|
|
});
|
|
|
|
it('should create a Stripe checkout session and return checkoutUrl', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ tier: 'free' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const stripe = makeStripe({ checkoutUrl: 'https://checkout.stripe.com/upgrade' });
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
|
|
|
|
const result = await service.initiateUpgrade('org-free', 'pro');
|
|
|
|
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/upgrade');
|
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mode: 'subscription',
|
|
client_reference_id: 'org-free',
|
|
metadata: expect.objectContaining({ orgId: 'org-free', targetTier: 'pro' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should throw when Stripe returns no session URL', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ tier: 'free' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const stripe = {
|
|
checkout: {
|
|
sessions: {
|
|
create: jest.fn().mockResolvedValue({ url: null, id: 'cs_no_url' }),
|
|
},
|
|
},
|
|
} as unknown as Stripe;
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
|
|
|
|
await expect(service.initiateUpgrade('org-free', 'pro')).rejects.toThrow(
|
|
'Stripe did not return a checkout session URL.',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// applyUpgrade()
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('applyUpgrade()', () => {
|
|
it('should update the organizations table tier column', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
await service.applyUpgrade('org-upgrade', 'pro');
|
|
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
expect.stringContaining('UPDATE organizations'),
|
|
['pro', 'org-upgrade'],
|
|
);
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
expect.stringContaining('tier_updated_at'),
|
|
expect.any(Array),
|
|
);
|
|
});
|
|
|
|
it('should resolve without throwing on success', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
await expect(service.applyUpgrade('org-upgrade', 'enterprise')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// enforceAgentLimit()
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('enforceAgentLimit()', () => {
|
|
it('should throw TierLimitError when agent count is at or above the limit', async () => {
|
|
// Agent count query returns 10 (= free tier max of 10)
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ count: '10' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
|
|
await expect(service.enforceAgentLimit('org-limited', 'free')).rejects.toThrow(TierLimitError);
|
|
});
|
|
|
|
it('should not throw when agent count is below the limit', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ count: '5' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
|
|
await expect(service.enforceAgentLimit('org-ok', 'free')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('should never throw for enterprise tier (Infinity limit)', async () => {
|
|
// pool.query should NOT be called because Infinity bypasses the check
|
|
const mockQuery = jest.fn();
|
|
|
|
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
|
|
|
await expect(service.enforceAgentLimit('org-enterprise', 'enterprise')).resolves.toBeUndefined();
|
|
// No DB query needed for Infinity limit
|
|
expect(mockQuery).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|