test(platform-service): add repository tests for notifications, plans, subscriptions, usage, tokens, memory + fix extraction-service flaky test

This commit is contained in:
saravanakumardb1 2026-02-16 11:59:06 -08:00
parent 7524c4d29e
commit fbb2197f7c
15 changed files with 2680 additions and 0 deletions

View File

@ -0,0 +1,155 @@
/**
* Tests for Azure Key Vault secret resolution.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '../keyvault.js';
import type { SecretMapping } from '../keyvault.js';
describe('resolveKeyVaultSecrets', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clean env vars used in tests
delete process.env.AZURE_KEYVAULT_URL;
delete process.env.TEST_SECRET_A;
delete process.env.TEST_SECRET_B;
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
it('skips entirely when AZURE_KEYVAULT_URL is not set', async () => {
const secrets: SecretMapping[] = [
{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' },
];
await resolveKeyVaultSecrets(secrets);
// Should not have set the env var (no KV to resolve from)
expect(process.env.TEST_SECRET_A).toBeUndefined();
});
it('skips secrets that already exist in env', async () => {
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
process.env.TEST_SECRET_A = 'already-set';
const secrets: SecretMapping[] = [
{ kvName: 'test-secret-a', envVar: 'TEST_SECRET_A' },
];
// Should not attempt KV call since all secrets are present
await resolveKeyVaultSecrets(secrets);
expect(process.env.TEST_SECRET_A).toBe('already-set');
});
it('accepts custom vaultUrl via opts', async () => {
// With no AZURE_KEYVAULT_URL but custom vaultUrl, it should attempt resolution
// This will fail with import error in test env (no @azure/identity), which is expected
const secrets: SecretMapping[] = [
{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' },
];
// Should not throw — gracefully handles missing @azure/identity
await expect(
resolveKeyVaultSecrets(secrets, { vaultUrl: 'https://kv-test.vault.azure.net' })
).resolves.not.toThrow();
});
it('handles empty secrets array', async () => {
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
await expect(resolveKeyVaultSecrets([])).resolves.not.toThrow();
});
it('gracefully handles import failures (no @azure/identity installed)', async () => {
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
const secrets: SecretMapping[] = [
{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' },
];
// In test env, @azure/identity likely isn't available
// resolveKeyVaultSecrets should catch and warn, not throw
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await resolveKeyVaultSecrets(secrets);
// Either warn was called (no Azure SDK) or the env var remains unset
// Both are acceptable — the function should not throw
expect(process.env.TEST_SECRET_A).toBeUndefined();
warnSpy.mockRestore();
});
it('filters to only missing secrets', async () => {
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
process.env.TEST_SECRET_A = 'present';
// TEST_SECRET_B is missing
const secrets: SecretMapping[] = [
{ kvName: 'secret-a', envVar: 'TEST_SECRET_A' },
{ kvName: 'secret-b', envVar: 'TEST_SECRET_B' },
];
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await resolveKeyVaultSecrets(secrets);
// TEST_SECRET_A should remain unchanged
expect(process.env.TEST_SECRET_A).toBe('present');
warnSpy.mockRestore();
});
});
describe('LYSNR_SECRETS', () => {
it('exports all expected secret mappings', () => {
const expectedKeys = [
'COSMOS_KEY',
'COSMOS_ENDPOINT',
'JWT_SECRET',
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET',
'BILLING_INTERNAL_KEY',
'AZURE_BLOB_CONNECTION_STRING',
'AZURE_BLOB_ACCOUNT_KEY',
'GEMINI_API_KEY',
'SEED_SECRET',
'AZURE_SPEECH_KEY',
'AZURE_OPENAI_KEY',
'AZURE_OPENAI_ENDPOINT',
];
for (const key of expectedKeys) {
expect(LYSNR_SECRETS).toHaveProperty(key);
}
});
it('each mapping has kvName and envVar', () => {
for (const [key, mapping] of Object.entries(LYSNR_SECRETS)) {
expect(mapping.kvName).toBeDefined();
expect(typeof mapping.kvName).toBe('string');
expect(mapping.kvName.length).toBeGreaterThan(0);
expect(mapping.envVar).toBeDefined();
expect(typeof mapping.envVar).toBe('string');
expect(mapping.envVar).toBe(key);
}
});
it('kvNames follow lysnr-* naming convention', () => {
for (const mapping of Object.values(LYSNR_SECRETS)) {
expect(mapping.kvName).toMatch(/^lysnr-/);
}
});
it('envVars are UPPER_SNAKE_CASE', () => {
for (const mapping of Object.values(LYSNR_SECRETS)) {
expect(mapping.envVar).toMatch(/^[A-Z][A-Z0-9_]*$/);
}
});
});

View File

@ -0,0 +1,262 @@
/**
* Tests for @bytelyst/extraction package client factory + types.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createExtractionClient } from '../client.js';
import type {
ExtractRequest,
ExtractResponse,
BatchExtractRequest,
BatchExtractResponse,
ExtractionTask,
ExtractionClientConfig,
ExtractionEntity,
ExtractionExample,
} from '../types.js';
// ── Mock @bytelyst/api-client ──────────────────────────────────
const mockApiFetch = vi.fn();
vi.mock('@bytelyst/api-client', () => ({
createApiClient: vi.fn(() => ({
fetch: mockApiFetch,
})),
}));
describe('createExtractionClient', () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
it('returns an object with extract, extractBatch, listTasks, getTask', () => {
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
expect(typeof client.extract).toBe('function');
expect(typeof client.extractBatch).toBe('function');
expect(typeof client.listTasks).toBe('function');
expect(typeof client.getTask).toBe('function');
});
describe('extract', () => {
it('calls POST /api/extract with correct body', async () => {
const mockResponse: ExtractResponse = {
extractions: [
{ extraction_class: 'person', extraction_text: 'John' },
],
metadata: { modelId: 'gemini-1.5', durationMs: 150, charCount: 35 },
};
mockApiFetch.mockResolvedValue(mockResponse);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
const req: ExtractRequest = {
text: 'John said we should ship by Friday.',
taskId: 'transcript-extraction',
};
const result = await client.extract(req);
expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', {
method: 'POST',
body: JSON.stringify(req),
});
expect(result).toEqual(mockResponse);
});
it('passes optional fields in request', async () => {
mockApiFetch.mockResolvedValue({ extractions: [], metadata: {} });
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
const req: ExtractRequest = {
text: 'Hello world',
taskPrompt: 'Extract entities',
modelId: 'gpt-4',
productId: 'lysnrai',
options: { extractionPasses: 2, maxWorkers: 4, maxCharBuffer: 1000 },
examples: [{ text: 'Hi Bob', extractions: [{ extraction_class: 'person', extraction_text: 'Bob' }] }],
};
await client.extract(req);
expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', {
method: 'POST',
body: JSON.stringify(req),
});
});
it('propagates errors from api client', async () => {
mockApiFetch.mockRejectedValue(new Error('Forbidden'));
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
await expect(client.extract({ text: 'test' })).rejects.toThrow('Forbidden');
});
});
describe('extractBatch', () => {
it('calls POST /api/extract/batch with correct body', async () => {
const mockResponse: BatchExtractResponse = {
results: [
{ extractions: [], metadata: { modelId: 'test', durationMs: 10, charCount: 5 } },
],
requestId: 'req-123',
};
mockApiFetch.mockResolvedValue(mockResponse);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
const req: BatchExtractRequest = {
inputs: [
{ text: 'First document', taskId: 'triage' },
{ text: 'Second document', taskPrompt: 'Extract names' },
],
modelId: 'gemini-1.5',
productId: 'mindlyst',
};
const result = await client.extractBatch(req);
expect(mockApiFetch).toHaveBeenCalledWith('/api/extract/batch', {
method: 'POST',
body: JSON.stringify(req),
});
expect(result).toEqual(mockResponse);
});
});
describe('listTasks', () => {
it('calls GET /api/tasks without productId', async () => {
const tasks: ExtractionTask[] = [
{
id: 'triage',
name: 'Triage Extraction',
prompt: 'Extract entities',
classes: ['person', 'date'],
builtIn: true,
productId: 'lysnrai',
},
];
mockApiFetch.mockResolvedValue(tasks);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
const result = await client.listTasks();
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks');
expect(result).toEqual(tasks);
});
it('appends productId query param when provided', async () => {
mockApiFetch.mockResolvedValue([]);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
await client.listTasks('mindlyst');
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=mindlyst');
});
it('encodes special characters in productId', async () => {
mockApiFetch.mockResolvedValue([]);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
await client.listTasks('my product');
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=my%20product');
});
});
describe('getTask', () => {
it('calls GET /api/tasks/:id without productId', async () => {
const task: ExtractionTask = {
id: 'triage',
name: 'Triage',
prompt: 'Extract',
classes: ['person'],
builtIn: true,
productId: 'lysnrai',
};
mockApiFetch.mockResolvedValue(task);
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
const result = await client.getTask('triage');
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/triage');
expect(result).toEqual(task);
});
it('appends productId query param when provided', async () => {
mockApiFetch.mockResolvedValue({});
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
await client.getTask('my-task', 'mindlyst');
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/my-task?productId=mindlyst');
});
it('encodes special characters in task id', async () => {
mockApiFetch.mockResolvedValue({});
const client = createExtractionClient({ baseUrl: 'http://localhost:4005' });
await client.getTask('task with spaces');
expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/task%20with%20spaces');
});
});
describe('config options', () => {
it('passes getToken to createApiClient', async () => {
const { createApiClient } = await import('@bytelyst/api-client');
const getToken = () => 'my-token';
createExtractionClient({ baseUrl: 'http://localhost:4005', getToken });
expect(createApiClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:4005',
getToken,
});
});
it('works without getToken', async () => {
const { createApiClient } = await import('@bytelyst/api-client');
createExtractionClient({ baseUrl: 'http://localhost:4005' });
expect(createApiClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:4005',
getToken: undefined,
});
});
});
});
describe('Extraction types', () => {
it('ExtractionEntity shape is correct', () => {
const entity: ExtractionEntity = {
extraction_class: 'person',
extraction_text: 'John Doe',
attributes: { role: 'engineer' },
start_offset: 0,
end_offset: 8,
};
expect(entity.extraction_class).toBe('person');
expect(entity.attributes?.role).toBe('engineer');
});
it('ExtractionExample shape is correct', () => {
const example: ExtractionExample = {
text: 'Meet Bob at 3pm',
extractions: [
{ extraction_class: 'person', extraction_text: 'Bob' },
{ extraction_class: 'time', extraction_text: '3pm' },
],
};
expect(example.extractions).toHaveLength(2);
});
it('ExtractionClientConfig with optional getToken', () => {
const config1: ExtractionClientConfig = { baseUrl: 'http://localhost:4005' };
expect(config1.getToken).toBeUndefined();
const config2: ExtractionClientConfig = {
baseUrl: 'http://localhost:4005',
getToken: () => 'tok',
};
expect(config2.getToken?.()).toBe('tok');
});
});

View File

@ -0,0 +1,191 @@
/**
* Tests for @bytelyst/logger package createLogger + structured logging.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createLogger } from '../logger.js';
import type { Logger, LoggerConfig } from '../types.js';
describe('createLogger', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stdoutSpy: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stderrSpy: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let consoleSpy: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let consoleErrorSpy: any;
beforeEach(() => {
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns a Logger with all four log methods', () => {
const log = createLogger({ service: 'test-service' });
expect(typeof log.error).toBe('function');
expect(typeof log.warn).toBe('function');
expect(typeof log.info).toBe('function');
expect(typeof log.debug).toBe('function');
});
describe('production mode (isDev: false)', () => {
let log: Logger;
beforeEach(() => {
log = createLogger({ service: 'platform-service', isDev: false });
});
it('info writes structured JSON to stdout', () => {
log.info('Server started', { port: 4003 });
expect(stdoutSpy).toHaveBeenCalledTimes(1);
const output = stdoutSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.level).toBe('info');
expect(parsed.message).toBe('Server started');
expect(parsed.service).toBe('platform-service');
expect(parsed.port).toBe(4003);
expect(parsed.timestamp).toBeDefined();
});
it('error writes structured JSON to stderr', () => {
log.error('Connection failed', new Error('timeout'));
expect(stderrSpy).toHaveBeenCalledTimes(1);
const output = stderrSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.level).toBe('error');
expect(parsed.message).toBe('Connection failed');
expect(parsed.error).toBe('timeout');
expect(parsed.stack).toBeDefined();
expect(parsed.service).toBe('platform-service');
});
it('warn writes structured JSON to stderr', () => {
log.warn('High memory usage', { usageMb: 512 });
expect(stderrSpy).toHaveBeenCalledTimes(1);
const output = stderrSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.level).toBe('warn');
expect(parsed.message).toBe('High memory usage');
expect(parsed.usageMb).toBe(512);
});
it('debug does NOT emit in production mode', () => {
log.debug('Debug info');
expect(stdoutSpy).not.toHaveBeenCalled();
expect(stderrSpy).not.toHaveBeenCalled();
});
it('error handles non-Error objects', () => {
log.error('Strange error', 'just a string');
const output = stderrSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.error).toBe('just a string');
expect(parsed.stack).toBeUndefined();
});
it('error handles undefined error', () => {
log.error('No error object');
const output = stderrSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.error).toBeUndefined();
});
it('includes timestamp in ISO format', () => {
log.info('Timestamp test');
const output = stdoutSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output.trim());
expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('appends newline to output', () => {
log.info('Newline check');
const output = stdoutSpy.mock.calls[0][0] as string;
expect(output.endsWith('\n')).toBe(true);
});
});
describe('dev mode (isDev: true)', () => {
let log: Logger;
beforeEach(() => {
log = createLogger({ service: 'dev-service', isDev: true });
});
it('info uses console.log with prefix', () => {
log.info('Dev message');
expect(consoleSpy).toHaveBeenCalledTimes(1);
const output = consoleSpy.mock.calls[0][0] as string;
expect(output).toContain('[INFO]');
expect(output).toContain('Dev message');
});
it('error uses console.error with prefix', () => {
log.error('Dev error', new Error('boom'));
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
const output = consoleErrorSpy.mock.calls[0][0] as string;
expect(output).toContain('[ERROR]');
expect(output).toContain('Dev error');
expect(output).toContain('boom');
});
it('warn uses console.error with prefix', () => {
log.warn('Dev warning');
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
const output = consoleErrorSpy.mock.calls[0][0] as string;
expect(output).toContain('[WARN]');
expect(output).toContain('Dev warning');
});
it('debug emits in dev mode', () => {
log.debug('Debug details', { key: 'value' });
expect(consoleSpy).toHaveBeenCalledTimes(1);
const output = consoleSpy.mock.calls[0][0] as string;
expect(output).toContain('[DEBUG]');
expect(output).toContain('Debug details');
});
it('includes extra data in dev mode', () => {
log.info('With extras', { requestId: 'abc', userId: 'u1' });
const output = consoleSpy.mock.calls[0][0] as string;
expect(output).toContain('requestId');
});
});
describe('config defaults', () => {
it('defaults to dev mode when NODE_ENV is not production', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const log = createLogger({ service: 'test' });
log.debug('Should emit in dev');
// Debug should emit in non-production
expect(consoleSpy.mock.calls.length + stdoutSpy.mock.calls.length).toBeGreaterThan(0);
process.env.NODE_ENV = originalEnv;
});
});
});

View File

@ -0,0 +1,221 @@
/**
* Tests for CircuitBreaker state machine (CLOSED OPEN HALF_OPEN).
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { CircuitBreaker } from './circuit-breaker.js';
describe('CircuitBreaker', () => {
let cb: CircuitBreaker;
beforeEach(() => {
cb = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 1000 });
});
describe('initial state', () => {
it('starts in CLOSED state', () => {
expect(cb.currentState).toBe('CLOSED');
});
it('allows requests when CLOSED', () => {
expect(cb.allowRequest()).toBe(true);
});
it('stats reflect initial state', () => {
const stats = cb.stats;
expect(stats.state).toBe('CLOSED');
expect(stats.failureCount).toBe(0);
expect(stats.threshold).toBe(3);
expect(stats.resetMs).toBe(1000);
});
});
describe('default options', () => {
it('uses failureThreshold=5 and resetTimeoutMs=30000 by default', () => {
const defaultCb = new CircuitBreaker();
const stats = defaultCb.stats;
expect(stats.threshold).toBe(5);
expect(stats.resetMs).toBe(30_000);
});
});
describe('failure tracking', () => {
it('stays CLOSED below threshold', () => {
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(1);
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(2);
});
it('transitions to OPEN at threshold', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
expect(cb.stats.failureCount).toBe(3);
});
it('blocks requests when OPEN', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.allowRequest()).toBe(false);
});
});
describe('success resets', () => {
it('resets failure count on success', () => {
cb.recordFailure();
cb.recordFailure();
expect(cb.stats.failureCount).toBe(2);
cb.recordSuccess();
expect(cb.stats.failureCount).toBe(0);
expect(cb.currentState).toBe('CLOSED');
});
it('returns to CLOSED from any state on success', () => {
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.allowRequest()).toBe(true);
});
});
describe('HALF_OPEN transition', () => {
it('transitions to HALF_OPEN after reset timeout', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
expect(cb.allowRequest()).toBe(false);
// Advance past reset timeout
vi.advanceTimersByTime(1001);
// allowRequest should transition to HALF_OPEN
expect(cb.allowRequest()).toBe(true);
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
it('returns to CLOSED on success during HALF_OPEN', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
cb.allowRequest(); // transitions to HALF_OPEN
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(0);
vi.useRealTimers();
});
it('returns to OPEN on failure during HALF_OPEN', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
cb.allowRequest(); // transitions to HALF_OPEN
// Failure during HALF_OPEN should re-open
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
expect(cb.currentState).toBe('OPEN');
vi.useRealTimers();
});
it('allows probe request in HALF_OPEN state', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1001);
expect(cb.allowRequest()).toBe(true); // probe allowed
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
});
describe('OPEN state timing', () => {
it('stays OPEN before reset timeout', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(500); // Half of reset timeout
expect(cb.allowRequest()).toBe(false);
expect(cb.currentState).toBe('OPEN');
vi.useRealTimers();
});
it('transitions exactly at reset timeout boundary', () => {
vi.useFakeTimers();
cb.recordFailure();
cb.recordFailure();
cb.recordFailure();
vi.advanceTimersByTime(1000); // Exactly at boundary
expect(cb.allowRequest()).toBe(true);
expect(cb.currentState).toBe('HALF_OPEN');
vi.useRealTimers();
});
});
describe('edge cases', () => {
it('handles rapid success/failure alternation', () => {
cb.recordFailure();
cb.recordSuccess();
cb.recordFailure();
cb.recordSuccess();
cb.recordFailure();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(1);
});
it('threshold of 1 opens immediately on first failure', () => {
const strictCb = new CircuitBreaker({ failureThreshold: 1 });
strictCb.recordFailure();
expect(strictCb.currentState).toBe('OPEN');
});
it('multiple successes keep state CLOSED', () => {
cb.recordSuccess();
cb.recordSuccess();
cb.recordSuccess();
expect(cb.currentState).toBe('CLOSED');
expect(cb.stats.failureCount).toBe(0);
});
});
});

View File

@ -0,0 +1,194 @@
/**
* Tests for async extraction job queue createJob, getJob, listJobs.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createJob, getJob, listJobs } from './jobs.js';
// Mock the python-bridge to avoid real sidecar calls
vi.mock('../../lib/python-bridge.js', () => ({
sidecarExtract: vi.fn(),
}));
import { sidecarExtract } from '../../lib/python-bridge.js';
const mockSidecarExtract = vi.mocked(sidecarExtract);
describe('extraction jobs', () => {
beforeEach(() => {
mockSidecarExtract.mockReset();
});
describe('createJob', () => {
it('creates a job and starts processing', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
const job = createJob([{ text: 'Hello world' }]);
expect(job.id).toBeDefined();
expect(['pending', 'processing', 'completed']).toContain(job.status);
expect(job.inputs).toHaveLength(1);
expect(job.results).toEqual([]);
expect(job.errors).toEqual([]);
expect(job.progress).toEqual({ completed: 0, total: 1 });
expect(job.createdAt).toBeDefined();
});
it('sets correct total from inputs length', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
const job = createJob([
{ text: 'First' },
{ text: 'Second' },
{ text: 'Third' },
]);
expect(job.progress.total).toBe(3);
});
it('generates unique IDs for each job', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
const job1 = createJob([{ text: 'A' }]);
const job2 = createJob([{ text: 'B' }]);
expect(job1.id).not.toBe(job2.id);
});
it('stores the job for later retrieval', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
const job = createJob([{ text: 'Stored' }]);
const retrieved = getJob(job.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(job.id);
});
it('processes job and marks as completed on success', async () => {
mockSidecarExtract.mockResolvedValue({
extractions: [{ extraction_class: 'person', extraction_text: 'John' }],
metadata: { model_id: 'gemini', duration_ms: 100, char_count: 20 },
});
const job = createJob([{ text: 'Meet John at 3pm' }]);
// Wait for background processing
await vi.waitFor(() => {
const j = getJob(job.id);
expect(j!.status).toBe('completed');
}, { timeout: 2000 });
const completed = getJob(job.id)!;
expect(completed.results).toHaveLength(1);
expect(completed.results[0].extractions[0].extraction_text).toBe('John');
expect(completed.progress.completed).toBe(1);
expect(completed.completedAt).toBeDefined();
});
it('records errors for failed extractions', async () => {
mockSidecarExtract.mockRejectedValue(new Error('Sidecar unavailable'));
const job = createJob([{ text: 'Will fail' }]);
await vi.waitFor(() => {
const j = getJob(job.id);
expect(j!.status).not.toBe('pending');
expect(j!.status).not.toBe('processing');
}, { timeout: 2000 });
const finished = getJob(job.id)!;
expect(finished.errors).toHaveLength(1);
expect(finished.errors[0].index).toBe(0);
expect(finished.errors[0].error).toBe('Sidecar unavailable');
expect(finished.status).toBe('failed');
});
it('handles mixed success/failure in batch', async () => {
mockSidecarExtract
.mockResolvedValueOnce({
extractions: [{ extraction_class: 'person', extraction_text: 'Alice' }],
metadata: { model_id: 'test', duration_ms: 50, char_count: 10 },
})
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce({
extractions: [],
metadata: { model_id: 'test', duration_ms: 30, char_count: 5 },
});
const job = createJob([
{ text: 'Alice is here' },
{ text: 'Will timeout' },
{ text: 'Empty result' },
]);
await vi.waitFor(() => {
const j = getJob(job.id);
expect(j!.progress.completed).toBe(3);
}, { timeout: 2000 });
const finished = getJob(job.id)!;
expect(finished.status).toBe('completed'); // Not all failed
expect(finished.errors).toHaveLength(1);
expect(finished.errors[0].index).toBe(1);
expect(finished.results).toHaveLength(3); // Placeholder for failed one
});
});
describe('getJob', () => {
it('returns undefined for unknown job ID', () => {
expect(getJob('nonexistent-id')).toBeUndefined();
});
});
describe('listJobs', () => {
it('returns jobs sorted by creation date (newest first)', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
createJob([{ text: 'First' }]);
createJob([{ text: 'Second' }]);
createJob([{ text: 'Third' }]);
const jobs = listJobs();
expect(jobs.length).toBeGreaterThanOrEqual(3);
// Verify sorted newest first
for (let i = 0; i < jobs.length - 1; i++) {
expect(jobs[i].createdAt >= jobs[i + 1].createdAt).toBe(true);
}
});
it('respects limit parameter', () => {
mockSidecarExtract.mockResolvedValue({
extractions: [],
metadata: { model_id: 'test', duration_ms: 10, char_count: 5 },
});
for (let i = 0; i < 5; i++) {
createJob([{ text: `Job ${i}` }]);
}
const limited = listJobs(2);
expect(limited.length).toBe(2);
});
it('defaults to limit of 50', () => {
const jobs = listJobs();
expect(jobs.length).toBeLessThanOrEqual(50);
});
});
});

View File

@ -0,0 +1,194 @@
/**
* Tests for extraction usage quota enforcement plan tiers + in-memory tracker.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { getQuota, checkQuota, incrementUsage, getUsageSummary, ExtractionUsageSchema } from './usage.js';
describe('getQuota', () => {
it('returns 10 for free plan', () => {
expect(getQuota('free')).toBe(10);
});
it('returns 100 for pro plan', () => {
expect(getQuota('pro')).toBe(100);
});
it('returns Infinity for enterprise plan', () => {
expect(getQuota('enterprise')).toBe(Infinity);
});
it('falls back to free tier for unknown plans', () => {
expect(getQuota('unknown')).toBe(10);
expect(getQuota('')).toBe(10);
});
});
describe('checkQuota', () => {
it('allows first request for free user', () => {
const result = checkQuota('fresh-user-1', 'free');
expect(result.allowed).toBe(true);
expect(result.limit).toBe(10);
expect(result.used).toBe(0);
expect(result.remaining).toBe(10);
});
it('enterprise users always allowed with Infinity remaining', () => {
const result = checkQuota('enterprise-user', 'enterprise');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(Infinity);
expect(result.limit).toBe(Infinity);
});
it('defaults to free plan when no plan specified', () => {
const result = checkQuota('no-plan-user');
expect(result.limit).toBe(10);
});
it('tracks usage after increments', () => {
const userId = 'tracked-user-' + Date.now();
incrementUsage(userId, 'free');
incrementUsage(userId, 'free');
incrementUsage(userId, 'free');
const result = checkQuota(userId, 'free');
expect(result.used).toBe(3);
expect(result.remaining).toBe(7);
expect(result.allowed).toBe(true);
});
it('blocks when quota exceeded', () => {
const userId = 'blocked-user-' + Date.now();
for (let i = 0; i < 10; i++) {
incrementUsage(userId, 'free');
}
const result = checkQuota(userId, 'free');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.used).toBe(10);
});
it('pro users have higher quota', () => {
const userId = 'pro-user-' + Date.now();
for (let i = 0; i < 10; i++) {
incrementUsage(userId, 'pro');
}
const result = checkQuota(userId, 'pro');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(90);
});
});
describe('incrementUsage', () => {
it('increments usage counter', () => {
const userId = 'inc-user-' + Date.now();
incrementUsage(userId);
const q1 = checkQuota(userId, 'free');
expect(q1.used).toBe(1);
incrementUsage(userId);
const q2 = checkQuota(userId, 'free');
expect(q2.used).toBe(2);
});
it('tracks different users separately', () => {
const userA = 'user-a-' + Date.now();
const userB = 'user-b-' + Date.now();
incrementUsage(userA);
incrementUsage(userA);
incrementUsage(userB);
expect(checkQuota(userA, 'free').used).toBe(2);
expect(checkQuota(userB, 'free').used).toBe(1);
});
});
describe('getUsageSummary', () => {
it('returns complete summary for new user', () => {
const userId = 'summary-user-' + Date.now();
const summary = getUsageSummary(userId, 'pro');
expect(summary.userId).toBe(userId);
expect(summary.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(summary.used).toBe(0);
expect(summary.limit).toBe(100);
expect(summary.remaining).toBe(100);
expect(summary.plan).toBe('pro');
});
it('returns -1 for limit/remaining when enterprise (Infinity)', () => {
const userId = 'ent-summary-' + Date.now();
const summary = getUsageSummary(userId, 'enterprise');
expect(summary.limit).toBe(-1);
expect(summary.remaining).toBe(-1);
expect(summary.plan).toBe('enterprise');
});
it('reflects current usage', () => {
const userId = 'used-summary-' + Date.now();
incrementUsage(userId, 'free');
incrementUsage(userId, 'free');
const summary = getUsageSummary(userId, 'free');
expect(summary.used).toBe(2);
expect(summary.remaining).toBe(8);
});
it('defaults to free plan', () => {
const userId = 'default-plan-' + Date.now();
const summary = getUsageSummary(userId);
expect(summary.plan).toBe('free');
expect(summary.limit).toBe(10);
});
});
describe('ExtractionUsageSchema', () => {
it('accepts valid usage document', () => {
const result = ExtractionUsageSchema.safeParse({
id: 'usage-1',
userId: 'user-1',
productId: 'lysnrai',
date: '2026-02-16',
count: 5,
plan: 'free',
updatedAt: '2026-02-16T10:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects negative count', () => {
const result = ExtractionUsageSchema.safeParse({
id: 'usage-1',
userId: 'user-1',
productId: 'lysnrai',
date: '2026-02-16',
count: -1,
plan: 'free',
updatedAt: '2026-02-16T10:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects non-integer count', () => {
const result = ExtractionUsageSchema.safeParse({
id: 'usage-1',
userId: 'user-1',
productId: 'lysnrai',
date: '2026-02-16',
count: 5.5,
plan: 'free',
updatedAt: '2026-02-16T10:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing fields', () => {
const result = ExtractionUsageSchema.safeParse({ id: 'usage-1' });
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,174 @@
/**
* Repository tests for memory module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
const mockRead = vi.fn();
const mockReplace = vi.fn();
const mockDelete = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace, delete: mockDelete }),
})),
}));
import { list, getById, create, replace, remove } from './repository.js';
import type { MemoryItemDoc } from './types.js';
const baseItem: MemoryItemDoc = {
id: 'mem_1',
productId: 'lysnrai',
userId: 'user_1',
sourceType: 'voice',
captureSurface: 'app',
rawContent: 'Hello world',
triageResult: {
contentType: 'memory',
summary: 'Hello world',
urgencyScore: 0.2,
emotionScore: 0.5,
confidenceScore: 0.9,
suggestedBrainId: 'brain_1',
entities: [],
suggestedActions: [],
},
brainIds: ['brain_1'],
actedOn: false,
actedOnAt: null,
nudgeCount: 0,
userCorrection: null,
isSensitive: false,
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
describe('memory repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('returns memory items', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseItem] });
const result = await list({
productId: 'lysnrai',
userId: 'user_1',
limit: 20,
offset: 0,
});
expect(result.items).toEqual([baseItem]);
});
it('returns empty when no items', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({
productId: 'lysnrai',
userId: 'user_1',
limit: 20,
offset: 0,
});
expect(result.items).toEqual([]);
});
it('handles brainId filter', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseItem] });
const result = await list({
productId: 'lysnrai',
userId: 'user_1',
brainId: 'brain_1',
limit: 20,
offset: 0,
});
expect(result.items).toEqual([baseItem]);
});
it('handles forgotten filter', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({
productId: 'lysnrai',
userId: 'user_1',
filter: 'forgotten',
limit: 20,
offset: 0,
});
expect(result.items).toEqual([]);
});
it('handles completed_today filter', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({
productId: 'lysnrai',
userId: 'user_1',
filter: 'completed_today',
limit: 20,
offset: 0,
});
expect(result.items).toEqual([]);
});
});
describe('getById', () => {
it('returns item when found and productId matches', async () => {
mockRead.mockResolvedValue({ resource: baseItem });
const result = await getById('mem_1', 'user_1', 'lysnrai');
expect(result).toEqual(baseItem);
});
it('returns null when productId does not match', async () => {
mockRead.mockResolvedValue({ resource: baseItem });
const result = await getById('mem_1', 'user_1', 'other_product');
expect(result).toBeNull();
});
it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getById('mem_1', 'user_1', 'lysnrai');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getById('mem_1', 'user_1', 'lysnrai');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates and returns item', async () => {
mockCreate.mockResolvedValue({ resource: baseItem });
const result = await create(baseItem);
expect(result).toEqual(baseItem);
});
});
describe('replace', () => {
it('replaces and returns item', async () => {
const updated = { ...baseItem, rawText: 'Updated' };
mockReplace.mockResolvedValue({ resource: updated });
const result = await replace(updated);
expect(result).toEqual(updated);
});
});
describe('remove', () => {
it('deletes and returns true', async () => {
mockDelete.mockResolvedValue(undefined);
const result = await remove('mem_1', 'user_1');
expect(result).toBe(true);
});
it('returns false on error', async () => {
mockDelete.mockRejectedValue(new Error('Not found'));
const result = await remove('mem_1', 'user_1');
expect(result).toBe(false);
});
});
});

View File

@ -0,0 +1,118 @@
/**
* Repository tests for notifications module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockUpsert = vi.fn();
const mockDelete = vi.fn();
const mockRead = vi.fn();
const mockCreate = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
upsert: mockUpsert,
create: mockCreate,
},
item: () => ({ delete: mockDelete, read: mockRead }),
})),
}));
import { getDevicesByUser, upsertDevice, removeDevice, getPrefs, upsertPrefs } from './repository.js';
import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
const baseDevice: DeviceDoc = {
id: 'dev_1',
productId: 'lysnrai',
userId: 'user_1',
deviceId: 'device_abc',
platform: 'ios',
pushToken: 'token_xyz',
lastSeenAt: '2026-02-16T00:00:00Z',
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
const basePrefs: NotificationPrefsDoc = {
id: 'prefs_lysnrai_user_1',
productId: 'lysnrai',
userId: 'user_1',
pushEnabled: true,
emailEnabled: false,
categories: { marketing: false, updates: true },
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
describe('notifications repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getDevicesByUser', () => {
it('returns devices for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseDevice] });
const result = await getDevicesByUser('user_1', 'lysnrai');
expect(result).toEqual([baseDevice]);
});
it('returns empty array when no devices', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getDevicesByUser('user_1', 'lysnrai');
expect(result).toEqual([]);
});
});
describe('upsertDevice', () => {
it('upserts and returns device', async () => {
mockUpsert.mockResolvedValue({ resource: baseDevice });
const result = await upsertDevice(baseDevice);
expect(result).toEqual(baseDevice);
});
});
describe('removeDevice', () => {
it('returns true on successful delete', async () => {
mockDelete.mockResolvedValue(undefined);
const result = await removeDevice('dev_1', 'user_1');
expect(result).toBe(true);
});
it('returns false on error', async () => {
mockDelete.mockRejectedValue(new Error('Not found'));
const result = await removeDevice('dev_1', 'user_1');
expect(result).toBe(false);
});
});
describe('getPrefs', () => {
it('returns prefs when found', async () => {
mockRead.mockResolvedValue({ resource: basePrefs });
const result = await getPrefs('user_1', 'lysnrai');
expect(result).toEqual(basePrefs);
});
it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getPrefs('user_1', 'lysnrai');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getPrefs('user_1', 'lysnrai');
expect(result).toBeNull();
});
});
describe('upsertPrefs', () => {
it('upserts and returns prefs', async () => {
mockUpsert.mockResolvedValue({ resource: basePrefs });
const result = await upsertPrefs(basePrefs);
expect(result).toEqual(basePrefs);
});
});
});

View File

@ -0,0 +1,123 @@
/**
* Repository tests for plans module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
const mockRead = vi.fn();
const mockReplace = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace }),
})),
}));
import { list, getByName, create, update, getDefaults } from './repository.js';
import type { PlanConfig } from './types.js';
const basePlan: PlanConfig = {
id: 'plan_lysnrai_pro',
productId: 'lysnrai',
name: 'pro',
displayName: 'Pro',
price: 9.99,
tokens: 100000,
words: 50000,
dictations: 5000,
features: ['basic_dictation', 'all_languages'],
active: true,
createdAt: '2026-02-16T00:00:00Z',
updatedAt: '2026-02-16T00:00:00Z',
};
describe('plans repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('returns plans from Cosmos', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePlan] });
const result = await list('lysnrai');
expect(result).toEqual([basePlan]);
});
it('returns defaults when no plans in DB', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list('lysnrai');
expect(result).toHaveLength(3);
expect(result[0].name).toBe('free');
expect(result[1].name).toBe('pro');
expect(result[2].name).toBe('enterprise');
});
});
describe('getByName', () => {
it('returns plan when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePlan] });
const result = await getByName('pro', 'lysnrai');
expect(result).toEqual(basePlan);
});
it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByName('nonexistent', 'lysnrai');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates and returns plan', async () => {
mockCreate.mockResolvedValue({ resource: basePlan });
const result = await create(basePlan);
expect(result).toEqual(basePlan);
});
});
describe('update', () => {
it('merges updates and returns plan', async () => {
mockRead.mockResolvedValue({ resource: basePlan });
const updated = { ...basePlan, price: 14.99 };
mockReplace.mockResolvedValue({ resource: updated });
const result = await update('plan_lysnrai_pro', { price: 14.99 });
expect(result).toEqual(updated);
});
it('returns null when plan not found', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await update('nonexistent', { price: 14.99 });
expect(result).toBeNull();
});
it('returns null on error', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await update('nonexistent', { price: 14.99 });
expect(result).toBeNull();
});
});
describe('getDefaults', () => {
it('returns 3 default plans with correct productId', () => {
const defaults = getDefaults('lysnrai');
expect(defaults).toHaveLength(3);
expect(defaults.every(p => p.productId === 'lysnrai')).toBe(true);
expect(defaults[0].name).toBe('free');
expect(defaults[0].price).toBe(0);
expect(defaults[1].name).toBe('pro');
expect(defaults[2].name).toBe('enterprise');
});
it('generates correct IDs', () => {
const defaults = getDefaults('mindlyst');
expect(defaults[0].id).toBe('plan_mindlyst_free');
expect(defaults[1].id).toBe('plan_mindlyst_pro');
});
});
});

View File

@ -0,0 +1,271 @@
/**
* Tests for rate limiting module store (sliding window) + types (Zod schemas).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { checkAndRecord, reset, peek, clearAll } from './store.js';
import { CheckRateLimitSchema, RateLimitConfigSchema } from './types.js';
import type { RateLimitRule } from './types.js';
// ── Store: sliding window counter ──────────────────────────────
describe('ratelimit store', () => {
beforeEach(() => {
clearAll();
});
describe('checkAndRecord', () => {
const rule: RateLimitRule = { maxRequests: 3, windowSeconds: 60 };
it('allows first request and returns full remaining', () => {
const result = checkAndRecord('user:123', rule);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(2);
expect(result.limit).toBe(3);
expect(result.resetAt).toBeDefined();
});
it('decrements remaining on each call', () => {
const r1 = checkAndRecord('user:a', rule);
expect(r1.remaining).toBe(2);
const r2 = checkAndRecord('user:a', rule);
expect(r2.remaining).toBe(1);
const r3 = checkAndRecord('user:a', rule);
expect(r3.remaining).toBe(0);
});
it('blocks after exceeding maxRequests', () => {
checkAndRecord('user:b', rule);
checkAndRecord('user:b', rule);
checkAndRecord('user:b', rule);
const result = checkAndRecord('user:b', rule);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfterMs).toBeGreaterThan(0);
});
it('uses separate counters for different keys', () => {
checkAndRecord('user:x', rule);
checkAndRecord('user:x', rule);
checkAndRecord('user:x', rule);
const resultX = checkAndRecord('user:x', rule);
expect(resultX.allowed).toBe(false);
const resultY = checkAndRecord('user:y', rule);
expect(resultY.allowed).toBe(true);
expect(resultY.remaining).toBe(2);
});
it('returns resetAt as ISO string', () => {
const result = checkAndRecord('user:time', rule);
expect(() => new Date(result.resetAt)).not.toThrow();
expect(new Date(result.resetAt).getTime()).toBeGreaterThan(Date.now() - 1000);
});
it('handles single-request limit', () => {
const strictRule: RateLimitRule = { maxRequests: 1, windowSeconds: 10 };
const r1 = checkAndRecord('user:strict', strictRule);
expect(r1.allowed).toBe(true);
expect(r1.remaining).toBe(0);
const r2 = checkAndRecord('user:strict', strictRule);
expect(r2.allowed).toBe(false);
});
it('handles large maxRequests without issues', () => {
const bigRule: RateLimitRule = { maxRequests: 10000, windowSeconds: 60 };
const result = checkAndRecord('user:big', bigRule);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9999);
});
});
describe('reset', () => {
it('clears rate limit for a specific key', () => {
const rule: RateLimitRule = { maxRequests: 2, windowSeconds: 60 };
checkAndRecord('user:reset', rule);
checkAndRecord('user:reset', rule);
const blocked = checkAndRecord('user:reset', rule);
expect(blocked.allowed).toBe(false);
reset('user:reset');
const afterReset = checkAndRecord('user:reset', rule);
expect(afterReset.allowed).toBe(true);
expect(afterReset.remaining).toBe(1);
});
it('does not affect other keys', () => {
const rule: RateLimitRule = { maxRequests: 1, windowSeconds: 60 };
checkAndRecord('user:keep', rule);
checkAndRecord('user:clear', rule);
reset('user:clear');
const keep = checkAndRecord('user:keep', rule);
expect(keep.allowed).toBe(false);
const cleared = checkAndRecord('user:clear', rule);
expect(cleared.allowed).toBe(true);
});
it('is safe to reset non-existent key', () => {
expect(() => reset('nonexistent')).not.toThrow();
});
});
describe('peek', () => {
const rule: RateLimitRule = { maxRequests: 3, windowSeconds: 60 };
it('returns full remaining for unknown key', () => {
const result = peek('new-key', rule);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(3);
expect(result.limit).toBe(3);
});
it('does not consume a request', () => {
peek('user:peek', rule);
peek('user:peek', rule);
peek('user:peek', rule);
const result = checkAndRecord('user:peek', rule);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(2);
});
it('reflects current usage', () => {
checkAndRecord('user:usage', rule);
checkAndRecord('user:usage', rule);
const result = peek('user:usage', rule);
expect(result.remaining).toBe(1);
expect(result.allowed).toBe(true);
});
it('shows blocked status when limit exceeded', () => {
checkAndRecord('user:full', rule);
checkAndRecord('user:full', rule);
checkAndRecord('user:full', rule);
const result = peek('user:full', rule);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
});
describe('clearAll', () => {
it('resets all keys', () => {
const rule: RateLimitRule = { maxRequests: 1, windowSeconds: 60 };
checkAndRecord('a', rule);
checkAndRecord('b', rule);
clearAll();
expect(checkAndRecord('a', rule).allowed).toBe(true);
expect(checkAndRecord('b', rule).allowed).toBe(true);
});
});
});
// ── Zod schemas ────────────────────────────────────────────────
describe('CheckRateLimitSchema', () => {
it('accepts valid input with defaults', () => {
const result = CheckRateLimitSchema.safeParse({ key: 'user:123' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.key).toBe('user:123');
expect(result.data.productId).toBe('lysnrai');
expect(result.data.routePrefix).toBeUndefined();
}
});
it('accepts full input', () => {
const result = CheckRateLimitSchema.safeParse({
key: 'user:456',
productId: 'mindlyst',
routePrefix: '/api/auth',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.productId).toBe('mindlyst');
expect(result.data.routePrefix).toBe('/api/auth');
}
});
it('rejects empty key', () => {
const result = CheckRateLimitSchema.safeParse({ key: '' });
expect(result.success).toBe(false);
});
it('rejects missing key', () => {
const result = CheckRateLimitSchema.safeParse({});
expect(result.success).toBe(false);
});
});
describe('RateLimitConfigSchema', () => {
it('accepts valid config', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'lysnrai',
rules: [
{ maxRequests: 60, windowSeconds: 60 },
{ maxRequests: 5, windowSeconds: 60, routePrefix: '/api/auth' },
],
});
expect(result.success).toBe(true);
});
it('rejects zero maxRequests', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'test',
rules: [{ maxRequests: 0, windowSeconds: 60 }],
});
expect(result.success).toBe(false);
});
it('rejects zero windowSeconds', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'test',
rules: [{ maxRequests: 10, windowSeconds: 0 }],
});
expect(result.success).toBe(false);
});
it('rejects negative values', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'test',
rules: [{ maxRequests: -1, windowSeconds: 60 }],
});
expect(result.success).toBe(false);
});
it('rejects missing productId', () => {
const result = RateLimitConfigSchema.safeParse({
rules: [{ maxRequests: 10, windowSeconds: 60 }],
});
expect(result.success).toBe(false);
});
it('accepts empty rules array', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'test',
rules: [],
});
expect(result.success).toBe(true);
});
it('rejects non-integer maxRequests', () => {
const result = RateLimitConfigSchema.safeParse({
productId: 'test',
rules: [{ maxRequests: 10.5, windowSeconds: 60 }],
});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,160 @@
/**
* Repository tests for subscriptions module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
const mockRead = vi.fn();
const mockReplace = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace }),
})),
}));
import {
getByUserId,
getByStripeCustomerIdAnyProduct,
getByStripeCustomerId,
createSubscription,
updateSubscription,
getPaymentsByUser,
createPayment,
} from './repository.js';
import type { SubscriptionDoc, PaymentDoc } from './types.js';
const baseSub: SubscriptionDoc = {
id: 'sub_1',
productId: 'lysnrai',
userId: 'user_1',
plan: 'pro',
status: 'active',
currentPeriodStart: '2026-02-01T00:00:00Z',
currentPeriodEnd: '2026-03-01T00:00:00Z',
cancelAtPeriodEnd: false,
monthlyPrice: 9.99,
tokensIncluded: 100000,
tokensUsed: 5000,
stripeCustomerId: 'cus_abc',
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-01T00:00:00Z',
};
const basePayment: PaymentDoc = {
id: 'pay_1',
productId: 'lysnrai',
userId: 'user_1',
amount: 999,
currency: 'usd',
status: 'succeeded',
description: 'Pro plan',
method: 'card',
createdAt: '2026-02-01T00:00:00Z',
};
describe('subscriptions repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getByUserId', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
const result = await getByUserId('user_1', 'lysnrai');
expect(result).toEqual(baseSub);
});
it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByUserId('user_1', 'lysnrai');
expect(result).toBeNull();
});
});
describe('getByStripeCustomerIdAnyProduct', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
const result = await getByStripeCustomerIdAnyProduct('cus_abc');
expect(result).toEqual(baseSub);
});
it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerIdAnyProduct('cus_none');
expect(result).toBeNull();
});
});
describe('getByStripeCustomerId', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
const result = await getByStripeCustomerId('cus_abc', 'lysnrai');
expect(result).toEqual(baseSub);
});
it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerId('cus_none', 'lysnrai');
expect(result).toBeNull();
});
});
describe('createSubscription', () => {
it('creates and returns subscription', async () => {
mockCreate.mockResolvedValue({ resource: baseSub });
const result = await createSubscription(baseSub);
expect(result).toEqual(baseSub);
});
});
describe('updateSubscription', () => {
it('merges updates and returns subscription', async () => {
mockRead.mockResolvedValue({ resource: baseSub });
const updated = { ...baseSub, plan: 'enterprise' as const };
mockReplace.mockResolvedValue({ resource: updated });
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toEqual(updated);
});
it('returns null when not found', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toBeNull();
});
it('returns null on error', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toBeNull();
});
});
describe('getPaymentsByUser', () => {
it('returns payments for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePayment] });
const result = await getPaymentsByUser('user_1', 'lysnrai');
expect(result).toEqual([basePayment]);
});
it('returns empty array when no payments', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getPaymentsByUser('user_1', 'lysnrai');
expect(result).toEqual([]);
});
});
describe('createPayment', () => {
it('creates and returns payment', async () => {
mockCreate.mockResolvedValue({ resource: basePayment });
const result = await createPayment(basePayment);
expect(result).toEqual(basePayment);
});
});
});

View File

@ -0,0 +1,187 @@
/**
* Tests for themes module Zod schemas + type validation.
*/
import { describe, it, expect } from 'vitest';
import { CreateThemeSchema, UpdateThemeSchema } from './types.js';
import type { ThemeDoc, PlatformTheme } from './types.js';
describe('CreateThemeSchema', () => {
it('accepts valid theme with just name', () => {
const result = CreateThemeSchema.safeParse({ name: 'Dark Mode' });
expect(result.success).toBe(true);
});
it('accepts full input with platform themes', () => {
const result = CreateThemeSchema.safeParse({
name: 'Ocean Blue',
description: 'A cool ocean theme',
ios: { primary: '#0000ff', background: '#000022' },
android: { primary: '#0000ff', accent: '#00ccff' },
desktop: { primary: '#0000ff', idle: '#00ff00', listening: '#ff0000' },
is_active: true,
is_default: false,
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
const result = CreateThemeSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('rejects missing name', () => {
const result = CreateThemeSchema.safeParse({});
expect(result.success).toBe(false);
});
it('accepts null description', () => {
const result = CreateThemeSchema.safeParse({ name: 'Test', description: null });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.description).toBeNull();
}
});
it('defaults optional booleans to undefined', () => {
const result = CreateThemeSchema.safeParse({ name: 'Test' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.is_active).toBeUndefined();
expect(result.data.is_default).toBeUndefined();
}
});
it('accepts partial platform theme (all fields optional)', () => {
const result = CreateThemeSchema.safeParse({
name: 'Partial',
ios: { primary: '#fff' },
});
expect(result.success).toBe(true);
});
it('accepts desktop-specific fields (idle, listening, processing, offline)', () => {
const result = CreateThemeSchema.safeParse({
name: 'Desktop Theme',
desktop: {
idle: '#00ff00',
listening: '#ff0000',
processing: '#ffaa00',
offline: '#999999',
},
});
expect(result.success).toBe(true);
});
});
describe('UpdateThemeSchema', () => {
it('accepts partial updates', () => {
const result = UpdateThemeSchema.safeParse({ name: 'Updated Name' });
expect(result.success).toBe(true);
});
it('accepts empty object (no-op update)', () => {
const result = UpdateThemeSchema.safeParse({});
expect(result.success).toBe(true);
});
it('accepts is_active toggle', () => {
const result = UpdateThemeSchema.safeParse({ is_active: true });
expect(result.success).toBe(true);
});
it('accepts platform theme update', () => {
const result = UpdateThemeSchema.safeParse({
ios: { primary: '#ff0000', accent: '#cc0000' },
});
expect(result.success).toBe(true);
});
it('accepts description update to null', () => {
const result = UpdateThemeSchema.safeParse({ description: null });
expect(result.success).toBe(true);
});
it('rejects empty name string', () => {
const result = UpdateThemeSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
});
describe('ThemeDoc type shape', () => {
it('has expected fields', () => {
const doc: ThemeDoc = {
id: 'theme-1',
productId: 'lysnrai',
name: 'Default Green',
description: 'The default theme',
ios: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
},
android: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
},
desktop: {
primary: '#4caf50',
secondary: '#2e7d32',
accent: '#66bb6a',
background: '#ffffff',
surface: '#f5f5f5',
error: '#f44336',
warning: '#ff9800',
success: '#4caf50',
idle: '#4caf50',
listening: '#e94560',
processing: '#f5a623',
offline: '#9e9e9e',
},
is_active: true,
is_default: true,
version: '1.0',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
created_by: 'admin-user',
type: 'theme',
};
expect(doc.type).toBe('theme');
expect(doc.desktop.idle).toBe('#4caf50');
expect(doc.ios.primary).toBe('#4caf50');
});
it('allows null description and created_by', () => {
const doc: ThemeDoc = {
id: 'theme-2',
productId: 'mindlyst',
name: 'Minimal',
description: null,
ios: {} as PlatformTheme,
android: {} as PlatformTheme,
desktop: {} as PlatformTheme,
is_active: false,
is_default: false,
version: '1.0',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
created_by: null,
type: 'theme',
};
expect(doc.description).toBeNull();
expect(doc.created_by).toBeNull();
});
});

View File

@ -0,0 +1,152 @@
/**
* Repository tests for tokens module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
const mockRead = vi.fn();
const mockReplace = vi.fn();
const mockDelete = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace, delete: mockDelete }),
})),
}));
vi.mock('bcryptjs', () => ({
default: { hash: vi.fn().mockResolvedValue('hashed_token') },
}));
import { list, listByUser, getById, create, revoke, remove, countActive, hashToken } from './repository.js';
import type { ApiTokenDoc } from './types.js';
const baseToken: ApiTokenDoc = {
id: 'tok_1',
productId: 'lysnrai',
userId: 'user_1',
userName: 'Test User',
name: 'Test Token',
tokenHash: 'hash_abc',
prefix: 'lysnr_',
status: 'active',
scopes: ['read'],
createdAt: '2026-02-16T00:00:00Z',
expiresAt: '2027-02-16T00:00:00Z',
lastUsed: '2026-02-16T00:00:00Z',
};
describe('tokens repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('returns tokens without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
const result = await list('lysnrai');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash');
});
it('returns empty array when no tokens', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list('lysnrai');
expect(result).toEqual([]);
});
});
describe('listByUser', () => {
it('returns tokens for user without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
const result = await listByUser('user_1', 'lysnrai');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash');
});
});
describe('getById', () => {
it('returns token when found', async () => {
mockRead.mockResolvedValue({ resource: baseToken });
const result = await getById('tok_1', 'user_1');
expect(result).toEqual(baseToken);
});
it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getById('tok_1', 'user_1');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getById('tok_1', 'user_1');
expect(result).toBeNull();
});
});
describe('create', () => {
it('creates and returns token without hash', async () => {
mockCreate.mockResolvedValue({ resource: baseToken });
const result = await create(baseToken);
expect(result).not.toHaveProperty('tokenHash');
expect(result.id).toBe('tok_1');
});
});
describe('revoke', () => {
it('revokes an existing token', async () => {
mockRead.mockResolvedValue({ resource: baseToken });
mockReplace.mockResolvedValue({ resource: { ...baseToken, status: 'revoked' } });
const result = await revoke('tok_1', 'user_1');
expect(result).toBe(true);
});
it('returns false when token not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await revoke('tok_1', 'user_1');
expect(result).toBe(false);
});
});
describe('remove', () => {
it('deletes and returns true', async () => {
mockDelete.mockResolvedValue(undefined);
const result = await remove('tok_1', 'user_1');
expect(result).toBe(true);
});
it('returns false on error', async () => {
mockDelete.mockRejectedValue(new Error('Not found'));
const result = await remove('tok_1', 'user_1');
expect(result).toBe(false);
});
});
describe('countActive', () => {
it('returns count', async () => {
mockFetchAll.mockResolvedValue({ resources: [5] });
const result = await countActive('lysnrai');
expect(result).toBe(5);
});
it('returns 0 when no results', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await countActive('lysnrai');
expect(result).toBe(0);
});
});
describe('hashToken', () => {
it('hashes a token string', async () => {
const result = await hashToken('raw_token');
expect(result).toBe('hashed_token');
});
});
});

View File

@ -0,0 +1,167 @@
/**
* Tests for API tokens module Zod schemas + type validation.
*/
import { describe, it, expect } from 'vitest';
import { CreateTokenSchema, PatchTokenSchema } from './types.js';
import type { ApiTokenDoc, ApiTokenResponse } from './types.js';
describe('CreateTokenSchema', () => {
it('accepts valid input with defaults', () => {
const result = CreateTokenSchema.safeParse({ name: 'CI Pipeline' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('CI Pipeline');
expect(result.data.scopes).toEqual(['read']);
expect(result.data.expiresInDays).toBe(90);
}
});
it('accepts full input', () => {
const result = CreateTokenSchema.safeParse({
name: 'Deploy Key',
scopes: ['read', 'write', 'admin'],
expiresInDays: 30,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.scopes).toEqual(['read', 'write', 'admin']);
expect(result.data.expiresInDays).toBe(30);
}
});
it('rejects empty name', () => {
const result = CreateTokenSchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
it('rejects missing name', () => {
const result = CreateTokenSchema.safeParse({});
expect(result.success).toBe(false);
});
it('rejects expiresInDays < 1', () => {
const result = CreateTokenSchema.safeParse({ name: 'test', expiresInDays: 0 });
expect(result.success).toBe(false);
});
it('rejects expiresInDays > 365', () => {
const result = CreateTokenSchema.safeParse({ name: 'test', expiresInDays: 400 });
expect(result.success).toBe(false);
});
it('rejects non-integer expiresInDays', () => {
const result = CreateTokenSchema.safeParse({ name: 'test', expiresInDays: 30.5 });
expect(result.success).toBe(false);
});
it('accepts boundary values for expiresInDays', () => {
const min = CreateTokenSchema.safeParse({ name: 'test', expiresInDays: 1 });
expect(min.success).toBe(true);
const max = CreateTokenSchema.safeParse({ name: 'test', expiresInDays: 365 });
expect(max.success).toBe(true);
});
it('accepts empty scopes array', () => {
const result = CreateTokenSchema.safeParse({ name: 'test', scopes: [] });
expect(result.success).toBe(true);
});
});
describe('PatchTokenSchema', () => {
it('accepts revoke action', () => {
const result = PatchTokenSchema.safeParse({ action: 'revoke' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.action).toBe('revoke');
}
});
it('rejects unknown action', () => {
const result = PatchTokenSchema.safeParse({ action: 'delete' });
expect(result.success).toBe(false);
});
it('rejects empty action', () => {
const result = PatchTokenSchema.safeParse({ action: '' });
expect(result.success).toBe(false);
});
it('rejects missing action', () => {
const result = PatchTokenSchema.safeParse({});
expect(result.success).toBe(false);
});
});
describe('ApiTokenDoc type shape', () => {
it('has all required fields', () => {
const doc: ApiTokenDoc = {
id: 'tok_abc123',
productId: 'lysnrai',
userId: 'user_1',
userName: 'admin@example.com',
name: 'CI Token',
prefix: 'wai_abc12345',
tokenHash: '$2a$10$hashedvalue',
status: 'active',
scopes: ['read', 'write'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-04-01T00:00:00.000Z',
lastUsed: null,
ttl: 7776000,
};
expect(doc.status).toBe('active');
expect(doc.scopes).toContain('read');
expect(doc.lastUsed).toBeNull();
});
it('supports all status values', () => {
const statuses: ApiTokenDoc['status'][] = ['active', 'revoked', 'expired'];
for (const status of statuses) {
const doc: ApiTokenDoc = {
id: 'tok_1',
productId: 'lysnrai',
userId: 'u1',
userName: 'test',
name: 'test',
prefix: 'wai_',
tokenHash: 'hash',
status,
scopes: [],
createdAt: '',
expiresAt: '',
lastUsed: null,
};
expect(doc.status).toBe(status);
}
});
});
describe('ApiTokenResponse type shape', () => {
it('excludes tokenHash from the response', () => {
const doc: ApiTokenDoc = {
id: 'tok_1',
productId: 'lysnrai',
userId: 'u1',
userName: 'test@test.com',
name: 'My Token',
prefix: 'wai_abc',
tokenHash: 'secret_hash',
status: 'active',
scopes: ['read'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-04-01T00:00:00.000Z',
lastUsed: '2026-02-15T10:00:00.000Z',
};
// Simulate what stripHash does
const { tokenHash: _hash, ...response } = doc;
const apiResponse: ApiTokenResponse = response;
expect(apiResponse.id).toBe('tok_1');
expect(apiResponse.name).toBe('My Token');
expect('tokenHash' in apiResponse).toBe(false);
});
});

View File

@ -0,0 +1,111 @@
/**
* Repository tests for usage module mocked Cosmos DB.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockFetchAll = vi.fn();
const mockUpsert = vi.fn();
const mockRead = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
upsert: mockUpsert,
},
item: () => ({ read: mockRead }),
})),
}));
import { getByDate, list, upsert, getMonthlyUsage } from './repository.js';
import type { UsageDoc } from './types.js';
const baseUsage: UsageDoc = {
id: 'usg_2026-02-16_user_1',
productId: 'lysnrai',
userId: 'user_1',
date: '2026-02-16',
dictations: 5,
words: 250,
durationMs: 30000,
tokensUsed: 1200,
costUsd: 0.01,
createdAt: '2026-02-16T00:00:00Z',
};
describe('usage repository', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getByDate', () => {
it('returns usage when found', async () => {
mockRead.mockResolvedValue({ resource: baseUsage });
const result = await getByDate('user_1', '2026-02-16');
expect(result).toEqual(baseUsage);
});
it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getByDate('user_1', '2026-02-16');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getByDate('user_1', '2026-02-16');
expect(result).toBeNull();
});
});
describe('list', () => {
it('returns usage records', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseUsage] });
const result = await list({ userId: 'user_1', productId: 'lysnrai' });
expect(result).toEqual([baseUsage]);
});
it('returns empty array when no records', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({});
expect(result).toEqual([]);
});
it('uses default values for days and limit', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list();
expect(result).toEqual([]);
});
});
describe('upsert', () => {
it('upserts and returns usage', async () => {
mockUpsert.mockResolvedValue({ resource: baseUsage });
const result = await upsert(baseUsage);
expect(result).toEqual(baseUsage);
});
});
describe('getMonthlyUsage', () => {
it('returns aggregated monthly usage', async () => {
mockFetchAll.mockResolvedValue({
resources: [{ totalTokens: 5000, totalWords: 2500, totalDictations: 20 }],
});
const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 5000, words: 2500, dictations: 20 });
});
it('returns zeros when no data', async () => {
mockFetchAll.mockResolvedValue({ resources: [undefined] });
const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
});
it('returns zeros when empty resources', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
});
});
});