diff --git a/services/platform-service/src/modules/diagnostics/diagnostics.test.ts b/services/platform-service/src/modules/diagnostics/diagnostics.test.ts new file mode 100644 index 00000000..cb1d82ea --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/diagnostics.test.ts @@ -0,0 +1,351 @@ +/** + * Diagnostics module tests — Phase 1: Server Foundation + * + * @module diagnostics + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import * as repo from './repository.js'; +import { + CreateDebugSessionSchema, + UpdateDebugSessionSchema, + IngestTracesSchema, + IngestLogsSchema, + type DebugSessionDoc, + type DebugTraceDoc, + type DebugLogEntryDoc, + type CreateDebugSessionInput, + type UpdateDebugSessionInput, + type IngestTracesInput, + type IngestLogsInput, +} from './types.js'; + +// Test helpers +function generateId(prefix: string): string { + return `${prefix}_${randomUUID().replace(/-/g, '')}`; +} + +function createTestSession(productId: string, overrides?: Partial): DebugSessionDoc { + const now = new Date().toISOString(); + return { + id: generateId('ds'), + productId, + targetUserId: `user_${randomUUID()}`, + status: 'pending', + collectionLevel: 'debug', + captureLogs: true, + captureNetwork: true, + captureScreenshots: false, + screenshotOnError: true, + maxDurationMinutes: 60, + createdAt: now, + updatedAt: now, + expiresAt: new Date(Date.now() + 60 * 60000).toISOString(), + logCount: 0, + traceCount: 0, + screenshotCount: 0, + createdBy: 'test_admin', + ...overrides, + }; +} + +// ─── Session CRUD Tests ──────────────────────────────────────────────────── + +describe('Session CRUD', () => { + const productId = 'test_product'; + + it('should create a session', async () => { + const session = createTestSession(productId); + const created = await repo.createSession(session); + expect(created.id).toBe(session.id); + expect(created.status).toBe('pending'); + }); + + it('should get a session by id', async () => { + const session = createTestSession(productId); + await repo.createSession(session); + + const found = await repo.getSession(session.id); + expect(found).not.toBeNull(); + expect(found?.id).toBe(session.id); + }); + + it('should return null for non-existent session', async () => { + const found = await repo.getSession('non_existent_id'); + expect(found).toBeNull(); + }); + + it('should update a session', async () => { + const session = createTestSession(productId); + await repo.createSession(session); + + const updated = await repo.updateSession(session.id, { status: 'active' }); + expect(updated).not.toBeNull(); + expect(updated?.status).toBe('active'); + expect(updated?.updatedAt).not.toBe(session.updatedAt); + }); + + it('should return null when updating non-existent session', async () => { + const updated = await repo.updateSession('non_existent', { status: 'active' }); + expect(updated).toBeNull(); + }); + + it('should soft delete (cancel) a session', async () => { + const session = createTestSession(productId); + await repo.createSession(session); + + const result = await repo.deleteSession(session.id); + expect(result).toBe(true); + + const found = await repo.getSession(session.id); + expect(found?.status).toBe('cancelled'); + }); + + it('should list sessions with filters', async () => { + const session1 = createTestSession(productId, { status: 'active' }); + const session2 = createTestSession(productId, { status: 'pending' }); + await repo.createSession(session1); + await repo.createSession(session2); + + const { sessions } = await repo.listSessions({ + productId, + status: 'active', + limit: 10, + offset: 0, + }); + + expect(sessions.length).toBeGreaterThanOrEqual(1); + expect(sessions.some((s) => s.id === session1.id)).toBe(true); + }); +}); + +// ─── Trace Ingest Tests ──────────────────────────────────────────────────── + +describe('Trace Ingest', () => { + const productId = 'test_product'; + const sessionId = generateId('ds'); + + it('should ingest traces', async () => { + const traces: DebugTraceDoc[] = [ + { + id: generateId('tr'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + traceId: randomUUID(), + spanId: randomUUID(), + name: 'test_operation', + startTime: new Date().toISOString(), + durationMs: 100, + attributes: {}, + status: 'ok', + }, + ]; + + await repo.ingestTraces(productId, sessionId, traces); + + const { traces: found } = await repo.getTraces(productId, sessionId, { limit: 10 }); + expect(found.length).toBeGreaterThanOrEqual(1); + }); + + it('should query traces for a session', async () => { + const traceId = randomUUID(); + const spanId = randomUUID(); + + const traces: DebugTraceDoc[] = [ + { + id: generateId('tr'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + traceId, + spanId, + name: 'test_query', + startTime: new Date().toISOString(), + durationMs: 50, + attributes: { key: 'value' }, + status: 'ok', + }, + ]; + + await repo.ingestTraces(productId, sessionId, traces); + + const { traces: found } = await repo.getTraces(productId, sessionId, { limit: 10 }); + expect(found.some((t) => t.name === 'test_query')).toBe(true); + }); +}); + +// ─── Log Ingest Tests ──────────────────────────────────────────────────── + +describe('Log Ingest', () => { + const productId = 'test_product'; + const sessionId = generateId('ds'); + + it('should ingest logs', async () => { + const logs: DebugLogEntryDoc[] = [ + { + id: generateId('log'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + level: 'info', + message: 'Test log message', + timestamp: new Date().toISOString(), + module: 'TestModule', + context: {}, + }, + ]; + + await repo.ingestLogs(productId, sessionId, logs); + + const { logs: found } = await repo.getLogs(productId, sessionId, { limit: 10 }); + expect(found.length).toBeGreaterThanOrEqual(1); + }); + + it('should filter logs by level', async () => { + const logs: DebugLogEntryDoc[] = [ + { + id: generateId('log'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + level: 'error', + message: 'Error message', + timestamp: new Date().toISOString(), + module: 'TestModule', + context: {}, + }, + { + id: generateId('log'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + level: 'info', + message: 'Info message', + timestamp: new Date().toISOString(), + module: 'TestModule', + context: {}, + }, + ]; + + await repo.ingestLogs(productId, sessionId, logs); + + const { logs: found } = await repo.getLogs(productId, sessionId, { + level: 'error', + limit: 10, + }); + + expect(found.every((l) => l.level === 'error')).toBe(true); + }); + + it('should search logs by message content', async () => { + const logs: DebugLogEntryDoc[] = [ + { + id: generateId('log'), + pk: `${productId}:${sessionId}`, + sessionId, + productId, + level: 'info', + message: 'Searchable unique message content', + timestamp: new Date().toISOString(), + module: 'TestModule', + context: {}, + }, + ]; + + await repo.ingestLogs(productId, sessionId, logs); + + const { logs: found } = await repo.getLogs(productId, sessionId, { + search: 'unique message', + limit: 10, + }); + + expect(found.some((l) => l.message.includes('unique message'))).toBe(true); + }); +}); + +// ─── Schema Validation Tests ─────────────────────────────────────────────── + +describe('Schema Validation', () => { + it('should validate CreateDebugSessionSchema', () => { + const input: CreateDebugSessionInput = { + productId: 'test_product', + targetUserId: 'user_123', + collectionLevel: 'debug', + captureLogs: true, + captureNetwork: true, + captureScreenshots: false, + screenshotOnError: true, + maxDurationMinutes: 60, + }; + + const result = CreateDebugSessionSchema.parse(input); + expect(result.productId).toBe('test_product'); + expect(result.maxDurationMinutes).toBe(60); + }); + + it('should validate UpdateDebugSessionSchema', () => { + const input: UpdateDebugSessionInput = { + status: 'active', + collectionLevel: 'trace', + }; + + const result = UpdateDebugSessionSchema.parse(input); + expect(result.status).toBe('active'); + }); + + it('should validate IngestTracesSchema', () => { + const input: IngestTracesInput = { + sessionId: 'ds_123', + traces: [ + { + traceId: randomUUID(), + spanId: randomUUID(), + name: 'test', + startTime: new Date().toISOString(), + status: 'ok', + attributes: {}, + }, + ], + }; + + const result = IngestTracesSchema.parse(input); + expect(result.traces).toHaveLength(1); + }); + + it('should validate IngestLogsSchema', () => { + const input: IngestLogsInput = { + sessionId: 'ds_123', + logs: [ + { + level: 'info', + message: 'Test message', + timestamp: new Date().toISOString(), + module: 'Test', + context: {}, + }, + ], + }; + + const result = IngestLogsSchema.parse(input); + expect(result.logs).toHaveLength(1); + }); + + it('should reject invalid log level', () => { + expect(() => + IngestLogsSchema.parse({ + sessionId: 'ds_123', + logs: [ + { + level: 'invalid_level', + message: 'Test', + timestamp: new Date().toISOString(), + module: 'Test', + context: {}, + }, + ], + }) + ).toThrow(); + }); +});