test(platform-service): cover ai diagnostics routes
This commit is contained in:
parent
7c99f5a5fa
commit
26283b402a
@ -0,0 +1,352 @@
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const repositoryMock = {
|
||||
saveNaturalLanguageQuery: vi.fn(),
|
||||
findClustersByProduct: vi.fn(),
|
||||
getLatestInsightForCluster: vi.fn(),
|
||||
getErrorClusterById: vi.fn(),
|
||||
findRelatedClusters: vi.fn(),
|
||||
createDiagnosticInsight: vi.fn(),
|
||||
updateErrorCluster: vi.fn(),
|
||||
updateInsightFeedback: vi.fn(),
|
||||
getActiveAlerts: vi.fn(),
|
||||
getTopErrorTypes: vi.fn(),
|
||||
getQueryHistory: vi.fn(),
|
||||
acknowledgeAlert: vi.fn(),
|
||||
resolveAlert: vi.fn(),
|
||||
};
|
||||
|
||||
const queryParserMock = {
|
||||
parseQuery: vi.fn(),
|
||||
validateQuery: vi.fn(),
|
||||
generateQuerySuggestions: vi.fn(),
|
||||
};
|
||||
|
||||
const queryExecutorMock = {
|
||||
executeQuery: vi.fn(),
|
||||
};
|
||||
|
||||
const llmAnalyzerMock = {
|
||||
analyzeRootCause: vi.fn(),
|
||||
generatePatternSummary: vi.fn(),
|
||||
};
|
||||
|
||||
const telemetryLinkingMock = {
|
||||
aggregateClusterContext: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('./repository.js', () => repositoryMock);
|
||||
vi.mock('./query-parser.js', () => queryParserMock);
|
||||
vi.mock('./query-executor.js', () => queryExecutorMock);
|
||||
vi.mock('./llm-analyzer.js', () => llmAnalyzerMock);
|
||||
vi.mock('./telemetry-linking.js', () => telemetryLinkingMock);
|
||||
|
||||
async function buildApp(payload?: { sub: string; productId?: string; role?: string }) {
|
||||
const aiDiagnosticsRoutes = (await import('./routes.js')).default;
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
if (payload) {
|
||||
app.addHook('onRequest', async request => {
|
||||
request.jwtPayload = payload;
|
||||
});
|
||||
}
|
||||
|
||||
await app.register(aiDiagnosticsRoutes, { prefix: '/api/ai-diagnostics' });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('aiDiagnosticsRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
queryParserMock.parseQuery.mockReturnValue({
|
||||
intent: 'root_cause',
|
||||
entities: {},
|
||||
});
|
||||
queryParserMock.validateQuery.mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
});
|
||||
queryParserMock.generateQuerySuggestions.mockReturnValue([
|
||||
'Investigate iOS crash spikes',
|
||||
'Compare Android regressions',
|
||||
]);
|
||||
|
||||
queryExecutorMock.executeQuery.mockResolvedValue({
|
||||
aiResponse: 'Likely tied to the latest mobile release.',
|
||||
confidence: 0.87,
|
||||
supportingData: [{ type: 'cluster', id: 'ec_1', relevanceScore: 0.92 }],
|
||||
executionTimeMs: 42,
|
||||
});
|
||||
|
||||
repositoryMock.saveNaturalLanguageQuery.mockResolvedValue(undefined);
|
||||
repositoryMock.findClustersByProduct.mockResolvedValue([]);
|
||||
repositoryMock.getLatestInsightForCluster.mockResolvedValue(null);
|
||||
repositoryMock.getErrorClusterById.mockResolvedValue(null);
|
||||
repositoryMock.findRelatedClusters.mockResolvedValue([]);
|
||||
repositoryMock.createDiagnosticInsight.mockResolvedValue(undefined);
|
||||
repositoryMock.updateErrorCluster.mockResolvedValue(undefined);
|
||||
repositoryMock.updateInsightFeedback.mockResolvedValue(undefined);
|
||||
repositoryMock.getActiveAlerts.mockResolvedValue([]);
|
||||
repositoryMock.getTopErrorTypes.mockResolvedValue([]);
|
||||
repositoryMock.getQueryHistory.mockResolvedValue([]);
|
||||
repositoryMock.acknowledgeAlert.mockResolvedValue(undefined);
|
||||
repositoryMock.resolveAlert.mockResolvedValue(undefined);
|
||||
|
||||
telemetryLinkingMock.aggregateClusterContext.mockResolvedValue({
|
||||
contextSummary: 'Most events originated from iOS 18 devices.',
|
||||
});
|
||||
|
||||
llmAnalyzerMock.analyzeRootCause.mockResolvedValue({
|
||||
analysisType: 'root_cause',
|
||||
generatedAt: '2026-03-21T12:00:00.000Z',
|
||||
rootCauseCategory: 'logic',
|
||||
hypothesis: 'A nil access happens after release gating is bypassed.',
|
||||
reasoning: 'The stack signature clusters around the same view transition.',
|
||||
confidence: 'high',
|
||||
confidenceScore: 0.93,
|
||||
evidence: [
|
||||
{
|
||||
type: 'stack_trace',
|
||||
description: 'Frames consistently point to the same code path.',
|
||||
strength: 'strong',
|
||||
},
|
||||
],
|
||||
suggestedInvestigation: ['Inspect the view transition guard.'],
|
||||
potentialFixDirection: 'Reinstate the guard before state mutation.',
|
||||
similarResolvedIssues: [],
|
||||
feedbackStats: { helpful: 0, notHelpful: 0, engineerNotes: [] },
|
||||
modelUsed: 'gpt-4o-mini',
|
||||
promptTokens: 120,
|
||||
completionTokens: 60,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects non-admin access', async () => {
|
||||
const app = await buildApp({ sub: 'user_1', productId: 'lysnrai', role: 'member' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/ai-diagnostics/query-history',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /ai-diagnostics/query executes and persists a parsed query', async () => {
|
||||
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ai-diagnostics/query',
|
||||
payload: {
|
||||
query: 'Why did iOS crash yesterday?',
|
||||
productId: 'chronomind',
|
||||
timeRange: {
|
||||
start: '2026-03-20T00:00:00.000Z',
|
||||
end: '2026-03-21T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(queryParserMock.parseQuery).toHaveBeenCalledWith('Why did iOS crash yesterday?');
|
||||
expect(queryExecutorMock.executeQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entities: expect.objectContaining({
|
||||
products: ['chronomind'],
|
||||
timeRange: {
|
||||
start: '2026-03-20T00:00:00.000Z',
|
||||
end: '2026-03-21T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ productId: 'chronomind', userId: 'admin_1' }
|
||||
);
|
||||
expect(repositoryMock.saveNaturalLanguageQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'admin_1',
|
||||
productId: 'chronomind',
|
||||
rawQuery: 'Why did iOS crash yesterday?',
|
||||
parsedIntent: 'root_cause',
|
||||
})
|
||||
);
|
||||
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.result.aiResponse).toContain('Likely tied');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /ai-diagnostics/query returns 400 when parsed query validation fails', async () => {
|
||||
queryParserMock.validateQuery.mockReturnValue({
|
||||
valid: false,
|
||||
errors: ['missing product scope'],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ai-diagnostics/query',
|
||||
payload: {
|
||||
query: 'show me crashes',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(queryExecutorMock.executeQuery).not.toHaveBeenCalled();
|
||||
expect(repositoryMock.saveNaturalLanguageQuery).not.toHaveBeenCalled();
|
||||
expect(res.json()).toEqual({
|
||||
error: 'Invalid query',
|
||||
details: ['missing product scope'],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /ai-diagnostics/clusters enriches clusters with latest insights when requested', async () => {
|
||||
repositoryMock.findClustersByProduct.mockResolvedValue([
|
||||
{
|
||||
id: 'ec_1',
|
||||
productId: 'lysnrai',
|
||||
fingerprintHash: 'fp_1',
|
||||
firstSeenAt: '2026-03-20T00:00:00.000Z',
|
||||
lastSeenAt: '2026-03-21T00:00:00.000Z',
|
||||
occurrenceCount: 10,
|
||||
uniqueUsers: 4,
|
||||
errorType: 'TypeError',
|
||||
messageTemplate: 'Cannot read property',
|
||||
stackSignature: 'stack-signature',
|
||||
relatedClusterIds: [],
|
||||
status: 'active',
|
||||
createdAt: '2026-03-20T00:00:00.000Z',
|
||||
updatedAt: '2026-03-21T00:00:00.000Z',
|
||||
ttl: 7776000,
|
||||
},
|
||||
]);
|
||||
repositoryMock.getLatestInsightForCluster.mockResolvedValue({
|
||||
id: 'di_1',
|
||||
clusterId: 'ec_1',
|
||||
productId: 'lysnrai',
|
||||
analysisType: 'root_cause',
|
||||
generatedAt: '2026-03-21T12:00:00.000Z',
|
||||
rootCauseCategory: 'logic',
|
||||
hypothesis: 'State is stale during retry.',
|
||||
reasoning: 'All samples align to the same path.',
|
||||
confidence: 'high',
|
||||
confidenceScore: 0.9,
|
||||
evidence: [],
|
||||
suggestedInvestigation: [],
|
||||
feedbackStats: { helpful: 0, notHelpful: 0, engineerNotes: [] },
|
||||
modelUsed: 'gpt-4o-mini',
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
createdAt: '2026-03-21T12:00:00.000Z',
|
||||
updatedAt: '2026-03-21T12:00:00.000Z',
|
||||
ttl: 7776000,
|
||||
});
|
||||
|
||||
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/ai-diagnostics/clusters?productId=lysnrai&includeInsights=true',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(repositoryMock.findClustersByProduct).toHaveBeenCalledWith('lysnrai', {
|
||||
status: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
expect(repositoryMock.getLatestInsightForCluster).toHaveBeenCalledWith('ec_1', 'lysnrai');
|
||||
expect(res.json().clusters[0].latestInsight.id).toBe('di_1');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /ai-diagnostics/clusters/:id/analyze stores a new insight and updates the cluster', async () => {
|
||||
repositoryMock.getErrorClusterById.mockResolvedValue({
|
||||
id: 'ec_1',
|
||||
productId: 'lysnrai',
|
||||
fingerprintHash: 'fp_1',
|
||||
firstSeenAt: '2026-03-20T00:00:00.000Z',
|
||||
lastSeenAt: '2026-03-21T00:00:00.000Z',
|
||||
occurrenceCount: 10,
|
||||
uniqueUsers: 4,
|
||||
errorType: 'TypeError',
|
||||
messageTemplate: 'Cannot read property',
|
||||
stackSignature: 'stack-signature',
|
||||
relatedClusterIds: [],
|
||||
status: 'active',
|
||||
createdAt: '2026-03-20T00:00:00.000Z',
|
||||
updatedAt: '2026-03-21T00:00:00.000Z',
|
||||
ttl: 7776000,
|
||||
});
|
||||
|
||||
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ai-diagnostics/clusters/ec_1/analyze?productId=lysnrai',
|
||||
payload: {
|
||||
analysisType: 'root_cause',
|
||||
modelPreference: 'gpt-4o-mini',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(telemetryLinkingMock.aggregateClusterContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'ec_1' }),
|
||||
[]
|
||||
);
|
||||
expect(llmAnalyzerMock.analyzeRootCause).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cluster: expect.objectContaining({ id: 'ec_1' }),
|
||||
analysisType: 'root_cause',
|
||||
})
|
||||
);
|
||||
expect(repositoryMock.createDiagnosticInsight).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clusterId: 'ec_1',
|
||||
productId: 'lysnrai',
|
||||
hypothesis: 'A nil access happens after release gating is bypassed.',
|
||||
})
|
||||
);
|
||||
expect(repositoryMock.updateErrorCluster).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'ec_1',
|
||||
insightId: expect.stringMatching(/^di_/),
|
||||
})
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /ai-diagnostics/alerts/:id/acknowledge records the admin user and note', async () => {
|
||||
const app = await buildApp({ sub: 'admin_2', productId: 'lysnrai', role: 'admin' });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ai-diagnostics/alerts/pa_1/acknowledge',
|
||||
payload: {
|
||||
note: 'Assigned to the mobile on-call engineer.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(repositoryMock.acknowledgeAlert).toHaveBeenCalledWith('pa_1', {
|
||||
acknowledgedBy: 'admin_2',
|
||||
note: 'Assigned to the mobile on-call engineer.',
|
||||
});
|
||||
expect(res.json()).toEqual({ success: true, message: 'Alert acknowledged' });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user