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