From fbb2197f7cd4f2da1728285118136e89a42eed3e Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 16 Feb 2026 11:59:06 -0800 Subject: [PATCH] test(platform-service): add repository tests for notifications, plans, subscriptions, usage, tokens, memory + fix extraction-service flaky test --- .../config/src/__tests__/keyvault.test.ts | 155 ++++++++++ .../src/__tests__/extraction.test.ts | 262 +++++++++++++++++ packages/logger/src/__tests__/logger.test.ts | 191 ++++++++++++ .../src/lib/circuit-breaker.test.ts | 221 ++++++++++++++ .../src/modules/extract/jobs.test.ts | 194 +++++++++++++ .../src/modules/extract/usage.test.ts | 194 +++++++++++++ .../src/modules/memory/repository.test.ts | 174 +++++++++++ .../modules/notifications/repository.test.ts | 118 ++++++++ .../src/modules/plans/repository.test.ts | 123 ++++++++ .../src/modules/ratelimit/ratelimit.test.ts | 271 ++++++++++++++++++ .../modules/subscriptions/repository.test.ts | 160 +++++++++++ .../src/modules/themes/themes.test.ts | 187 ++++++++++++ .../src/modules/tokens/repository.test.ts | 152 ++++++++++ .../src/modules/tokens/tokens.test.ts | 167 +++++++++++ .../src/modules/usage/repository.test.ts | 111 +++++++ 15 files changed, 2680 insertions(+) create mode 100644 packages/config/src/__tests__/keyvault.test.ts create mode 100644 packages/extraction/src/__tests__/extraction.test.ts create mode 100644 packages/logger/src/__tests__/logger.test.ts create mode 100644 services/extraction-service/src/lib/circuit-breaker.test.ts create mode 100644 services/extraction-service/src/modules/extract/jobs.test.ts create mode 100644 services/extraction-service/src/modules/extract/usage.test.ts create mode 100644 services/platform-service/src/modules/memory/repository.test.ts create mode 100644 services/platform-service/src/modules/notifications/repository.test.ts create mode 100644 services/platform-service/src/modules/plans/repository.test.ts create mode 100644 services/platform-service/src/modules/ratelimit/ratelimit.test.ts create mode 100644 services/platform-service/src/modules/subscriptions/repository.test.ts create mode 100644 services/platform-service/src/modules/themes/themes.test.ts create mode 100644 services/platform-service/src/modules/tokens/repository.test.ts create mode 100644 services/platform-service/src/modules/tokens/tokens.test.ts create mode 100644 services/platform-service/src/modules/usage/repository.test.ts diff --git a/packages/config/src/__tests__/keyvault.test.ts b/packages/config/src/__tests__/keyvault.test.ts new file mode 100644 index 00000000..441ab6b7 --- /dev/null +++ b/packages/config/src/__tests__/keyvault.test.ts @@ -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_]*$/); + } + }); +}); diff --git a/packages/extraction/src/__tests__/extraction.test.ts b/packages/extraction/src/__tests__/extraction.test.ts new file mode 100644 index 00000000..25e31cf5 --- /dev/null +++ b/packages/extraction/src/__tests__/extraction.test.ts @@ -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'); + }); +}); diff --git a/packages/logger/src/__tests__/logger.test.ts b/packages/logger/src/__tests__/logger.test.ts new file mode 100644 index 00000000..b6ec896d --- /dev/null +++ b/packages/logger/src/__tests__/logger.test.ts @@ -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; + }); + }); +}); diff --git a/services/extraction-service/src/lib/circuit-breaker.test.ts b/services/extraction-service/src/lib/circuit-breaker.test.ts new file mode 100644 index 00000000..052b2504 --- /dev/null +++ b/services/extraction-service/src/lib/circuit-breaker.test.ts @@ -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); + }); + }); +}); diff --git a/services/extraction-service/src/modules/extract/jobs.test.ts b/services/extraction-service/src/modules/extract/jobs.test.ts new file mode 100644 index 00000000..c429415c --- /dev/null +++ b/services/extraction-service/src/modules/extract/jobs.test.ts @@ -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); + }); + }); +}); diff --git a/services/extraction-service/src/modules/extract/usage.test.ts b/services/extraction-service/src/modules/extract/usage.test.ts new file mode 100644 index 00000000..bc2081d0 --- /dev/null +++ b/services/extraction-service/src/modules/extract/usage.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/memory/repository.test.ts b/services/platform-service/src/modules/memory/repository.test.ts new file mode 100644 index 00000000..d1ec10d8 --- /dev/null +++ b/services/platform-service/src/modules/memory/repository.test.ts @@ -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); + }); + }); +}); diff --git a/services/platform-service/src/modules/notifications/repository.test.ts b/services/platform-service/src/modules/notifications/repository.test.ts new file mode 100644 index 00000000..4d98e303 --- /dev/null +++ b/services/platform-service/src/modules/notifications/repository.test.ts @@ -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); + }); + }); +}); diff --git a/services/platform-service/src/modules/plans/repository.test.ts b/services/platform-service/src/modules/plans/repository.test.ts new file mode 100644 index 00000000..13a3c82f --- /dev/null +++ b/services/platform-service/src/modules/plans/repository.test.ts @@ -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'); + }); + }); +}); diff --git a/services/platform-service/src/modules/ratelimit/ratelimit.test.ts b/services/platform-service/src/modules/ratelimit/ratelimit.test.ts new file mode 100644 index 00000000..743c2681 --- /dev/null +++ b/services/platform-service/src/modules/ratelimit/ratelimit.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/subscriptions/repository.test.ts b/services/platform-service/src/modules/subscriptions/repository.test.ts new file mode 100644 index 00000000..e34cf11e --- /dev/null +++ b/services/platform-service/src/modules/subscriptions/repository.test.ts @@ -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); + }); + }); +}); diff --git a/services/platform-service/src/modules/themes/themes.test.ts b/services/platform-service/src/modules/themes/themes.test.ts new file mode 100644 index 00000000..fb235797 --- /dev/null +++ b/services/platform-service/src/modules/themes/themes.test.ts @@ -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(); + }); +}); diff --git a/services/platform-service/src/modules/tokens/repository.test.ts b/services/platform-service/src/modules/tokens/repository.test.ts new file mode 100644 index 00000000..cfa1184a --- /dev/null +++ b/services/platform-service/src/modules/tokens/repository.test.ts @@ -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'); + }); + }); +}); diff --git a/services/platform-service/src/modules/tokens/tokens.test.ts b/services/platform-service/src/modules/tokens/tokens.test.ts new file mode 100644 index 00000000..55192606 --- /dev/null +++ b/services/platform-service/src/modules/tokens/tokens.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/usage/repository.test.ts b/services/platform-service/src/modules/usage/repository.test.ts new file mode 100644 index 00000000..2e5a1b61 --- /dev/null +++ b/services/platform-service/src/modules/usage/repository.test.ts @@ -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 }); + }); + }); +});