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:
SentryAgent.ai Developer
2026-04-04 02:20:09 +00:00
parent 0fad328329
commit eea885db04
34 changed files with 4262 additions and 25 deletions

View File

@@ -35,9 +35,9 @@ describe('metricsRegistry', () => {
expect(metricsRegistry).not.toBe(register);
});
it('contains exactly 14 metric entries', async () => {
it('contains exactly 19 metric entries', async () => {
const entries = await metricsRegistry.getMetricsAsJSON();
expect(entries).toHaveLength(14);
expect(entries).toHaveLength(19);
});
// ──────────────────────────────────────────────────────────────────

View 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([]);
});
});
});

View File

@@ -0,0 +1,271 @@
/**
* Unit tests for src/services/ComplianceService.ts
*/
import { Pool, QueryResult } from 'pg';
import type { RedisClientType } from 'redis';
import { ComplianceService } from '../../../src/services/ComplianceService';
// ── Mock AuditVerificationService (instantiated internally by ComplianceService) ──
jest.mock('../../../src/services/AuditVerificationService', () => {
return {
AuditVerificationService: jest.fn().mockImplementation(() => ({
verifyChain: jest.fn().mockResolvedValue({
verified: true,
checkedCount: 42,
brokenAtEventId: null,
}),
})),
};
});
// ── Re-import after mock is established ──────────────────────────────────────
import { AuditVerificationService } from '../../../src/services/AuditVerificationService';
const MockAuditVerificationService = AuditVerificationService as jest.MockedClass<
typeof AuditVerificationService
>;
// ── Mock helpers ──────────────────────────────────────────────────────────────
function makePool(queryFn: jest.Mock): Pool {
return { query: queryFn } as unknown as Pool;
}
function makeRedis(overrides: Partial<{
getFn: jest.Mock;
setFn: jest.Mock;
}>): RedisClientType {
return {
get: overrides.getFn ?? jest.fn().mockResolvedValue(null),
set: overrides.setFn ?? jest.fn().mockResolvedValue('OK'),
} as unknown as RedisClientType;
}
// ════════════════════════════════════════════════════════════════════════════
// ComplianceService
// ════════════════════════════════════════════════════════════════════════════
describe('ComplianceService', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset AuditVerificationService mock to default passing behaviour
MockAuditVerificationService.mockImplementation(
() =>
({
verifyChain: jest.fn().mockResolvedValue({
verified: true,
checkedCount: 42,
brokenAtEventId: null,
}),
}) as unknown as InstanceType<typeof AuditVerificationService>,
);
});
// ────────────────────────────────────────────────────────────────
// generateReport() — cache miss
// ────────────────────────────────────────────────────────────────
describe('generateReport() — cache miss', () => {
it('should build a report, store it in Redis, and return IComplianceReport structure', async () => {
// Cache miss → null
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// Pool returns empty agents list → agent-identity section passes trivially
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-1');
// Redis cache miss check
expect(getFn).toHaveBeenCalledWith('compliance:report:tenant-1');
// Report stored in Redis after build
expect(setFn).toHaveBeenCalledWith(
'compliance:report:tenant-1',
expect.any(String),
expect.objectContaining({ EX: 300 }),
);
// IComplianceReport structure
expect(report).toMatchObject({
tenant_id: 'tenant-1',
agntcy_schema_version: '1.0',
sections: expect.any(Array),
overall_status: expect.stringMatching(/^(pass|fail|warn)$/),
generated_at: expect.any(String),
});
// from_cache should be absent on a freshly built report
expect(report.from_cache).toBeUndefined();
});
});
// ────────────────────────────────────────────────────────────────
// generateReport() — cache hit
// ────────────────────────────────────────────────────────────────
describe('generateReport() — cache hit', () => {
it('should return cached data with from_cache: true', async () => {
const cachedReport = {
generated_at: '2026-04-04T00:00:00.000Z',
tenant_id: 'tenant-cache',
agntcy_schema_version: '1.0',
sections: [{ name: 'agent-identity', status: 'pass', details: 'All good.' }],
overall_status: 'pass',
};
const getFn = jest.fn().mockResolvedValue(JSON.stringify(cachedReport));
const setFn = jest.fn();
const mockQuery = jest.fn();
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-cache');
expect(report.from_cache).toBe(true);
expect(report.tenant_id).toBe('tenant-cache');
expect(report.overall_status).toBe('pass');
// No DB queries should be made on a cache hit
expect(mockQuery).not.toHaveBeenCalled();
// Redis set should not be called either
expect(setFn).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────
// generateReport() — overall_status rollup
// ────────────────────────────────────────────────────────────────
describe('generateReport() — overall_status', () => {
it('should return overall_status: pass when all sections pass', async () => {
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// No agents → agent-identity passes trivially
// AuditVerificationService mock returns verified: true (default in beforeEach)
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-all-pass');
expect(report.overall_status).toBe('pass');
});
it('should return overall_status: fail when any section fails', async () => {
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// Override AuditVerificationService to simulate broken chain → audit-trail fails
MockAuditVerificationService.mockImplementation(
() =>
({
verifyChain: jest.fn().mockResolvedValue({
verified: false,
checkedCount: 10,
brokenAtEventId: 'event-uuid-broken',
}),
}) as unknown as InstanceType<typeof AuditVerificationService>,
);
// No agents so agent-identity is 'pass'; audit-trail will be 'fail'
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-fail');
expect(report.overall_status).toBe('fail');
const auditSection = report.sections.find((s) => s.name === 'audit-trail');
expect(auditSection?.status).toBe('fail');
});
});
// ────────────────────────────────────────────────────────────────
// exportAgentCards()
// ────────────────────────────────────────────────────────────────
describe('exportAgentCards()', () => {
it('should return an array of IAgentCard objects with correct fields', async () => {
const createdAt = new Date('2026-01-15T12:00:00Z');
const agentRows = [
{
agent_id: 'agent-uuid-1',
owner: 'team-alpha',
capabilities: ['agents:read', 'tokens:issue'],
created_at: createdAt,
did: 'did:web:sentryagent.ai:agent-uuid-1',
},
{
agent_id: 'agent-uuid-2',
owner: 'team-beta',
capabilities: ['agents:read'],
created_at: createdAt,
did: null, // no DID — id should fall back to agent_id
},
];
const mockQuery = jest.fn().mockResolvedValue({
rows: agentRows,
} as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
const cards = await service.exportAgentCards('tenant-1');
expect(cards).toHaveLength(2);
// Card with DID uses DID as id
expect(cards[0]).toEqual({
id: 'did:web:sentryagent.ai:agent-uuid-1',
name: 'team-alpha',
capabilities: ['agents:read', 'tokens:issue'],
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-1',
created_at: createdAt.toISOString(),
agntcy_schema_version: '1.0',
});
// Card without DID falls back to agent_id as id
expect(cards[1]).toEqual({
id: 'agent-uuid-2',
name: 'team-beta',
capabilities: ['agents:read'],
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-2',
created_at: createdAt.toISOString(),
agntcy_schema_version: '1.0',
});
});
it('should return an empty array when no active agents exist', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [],
} as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
const cards = await service.exportAgentCards('tenant-empty');
expect(cards).toEqual([]);
});
it('should query only non-decommissioned agents scoped to the tenantId', async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
await service.exportAgentCards('tenant-scope-test');
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status != 'decommissioned'"),
['tenant-scope-test'],
);
});
});
});

View File

@@ -0,0 +1,250 @@
/**
* 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();
});
});
});