test(platform-service): add repository tests for notifications, plans, subscriptions, usage, tokens, memory + fix extraction-service flaky test
This commit is contained in:
parent
7524c4d29e
commit
fbb2197f7c
155
packages/config/src/__tests__/keyvault.test.ts
Normal file
155
packages/config/src/__tests__/keyvault.test.ts
Normal 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_]*$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
262
packages/extraction/src/__tests__/extraction.test.ts
Normal file
262
packages/extraction/src/__tests__/extraction.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
191
packages/logger/src/__tests__/logger.test.ts
Normal file
191
packages/logger/src/__tests__/logger.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
221
services/extraction-service/src/lib/circuit-breaker.test.ts
Normal file
221
services/extraction-service/src/lib/circuit-breaker.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
194
services/extraction-service/src/modules/extract/jobs.test.ts
Normal file
194
services/extraction-service/src/modules/extract/jobs.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
194
services/extraction-service/src/modules/extract/usage.test.ts
Normal file
194
services/extraction-service/src/modules/extract/usage.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
services/platform-service/src/modules/memory/repository.test.ts
Normal file
174
services/platform-service/src/modules/memory/repository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
123
services/platform-service/src/modules/plans/repository.test.ts
Normal file
123
services/platform-service/src/modules/plans/repository.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
187
services/platform-service/src/modules/themes/themes.test.ts
Normal file
187
services/platform-service/src/modules/themes/themes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
services/platform-service/src/modules/tokens/repository.test.ts
Normal file
152
services/platform-service/src/modules/tokens/repository.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
167
services/platform-service/src/modules/tokens/tokens.test.ts
Normal file
167
services/platform-service/src/modules/tokens/tokens.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
services/platform-service/src/modules/usage/repository.test.ts
Normal file
111
services/platform-service/src/modules/usage/repository.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user