Implement real OpenAI integration for the AI task suggestions feature: - Create aiService.js with GPT-3.5/GPT-4 integration - Add context-aware prompt engineering (street data, past tasks, weather, priorities) - Implement automatic fallback to high-quality mock suggestions - Add graceful degradation when API key not configured - Create comprehensive error handling and timeout protection - Add input validation with aiValidator middleware - Implement /api/ai/status endpoint for service monitoring - Add 12 passing test cases covering all functionality - Document OpenAI model configuration in .env.example - Create detailed AI_SERVICE.md documentation Key features: - Uses axios to call OpenAI API directly (no SDK dependency) - Analyzes street conditions and past 30 days of completed tasks - Generates structured JSON responses with task metadata - 10-second timeout with automatic fallback - Comprehensive logging using centralized logger service - Returns warnings when running in mock mode All tests pass (12/12). Ready for production use with or without API key. 🤖 Generated with AI Assistant Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
256 lines
8.0 KiB
JavaScript
256 lines
8.0 KiB
JavaScript
const request = require("supertest");
|
|
const express = require("express");
|
|
|
|
// Mock CouchDB service before importing routes
|
|
jest.mock("../../services/couchdbService", () => ({
|
|
initialize: jest.fn().mockResolvedValue(true),
|
|
create: jest.fn(),
|
|
getById: jest.fn(),
|
|
find: jest.fn(),
|
|
createDocument: jest.fn(),
|
|
updateDocument: jest.fn(),
|
|
deleteDocument: jest.fn(),
|
|
getDocument: jest.fn(),
|
|
}));
|
|
|
|
// Mock logger to avoid console output during tests
|
|
jest.mock("../../utils/logger", () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
debug: jest.fn(),
|
|
}));
|
|
|
|
// Mock auth middleware
|
|
jest.mock("../../middleware/auth", () => (req, res, next) => {
|
|
req.user = { id: "test-user-id" };
|
|
next();
|
|
});
|
|
|
|
const aiRoute = require("../../routes/ai");
|
|
const aiService = require("../../services/aiService");
|
|
const Street = require("../../models/Street");
|
|
const Task = require("../../models/Task");
|
|
const couchdbService = require("../../services/couchdbService");
|
|
|
|
// Setup Express app for testing
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api/ai", aiRoute);
|
|
|
|
describe("AI Routes", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe("GET /api/ai/status", () => {
|
|
it("should return AI service status", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/status")
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toHaveProperty("configured");
|
|
expect(response.body.data).toHaveProperty("status");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/ai/task-suggestions", () => {
|
|
it("should return task suggestions without streetId", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions")
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
expect(response.body.data.length).toBeGreaterThan(0);
|
|
expect(response.body.meta).toHaveProperty("source");
|
|
expect(response.body.meta).toHaveProperty("aiConfigured");
|
|
|
|
// Check suggestion structure
|
|
const suggestion = response.body.data[0];
|
|
expect(suggestion).toHaveProperty("description");
|
|
expect(suggestion).toHaveProperty("details");
|
|
expect(suggestion).toHaveProperty("estimatedTime");
|
|
expect(suggestion).toHaveProperty("difficulty");
|
|
expect(suggestion).toHaveProperty("priority");
|
|
expect(suggestion).toHaveProperty("source");
|
|
});
|
|
|
|
it("should return task suggestions with custom count", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions?count=3")
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
expect(response.body.data.length).toBe(3);
|
|
});
|
|
|
|
it("should validate count parameter maximum", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions?count=15")
|
|
.expect(400);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.errors).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it("should validate count is a positive number", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions?count=0")
|
|
.expect(400);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.errors).toBeInstanceOf(Array);
|
|
});
|
|
|
|
it("should return task suggestions for specific street", async () => {
|
|
const streetId = "test-street-123";
|
|
const streetData = {
|
|
_id: streetId,
|
|
_rev: "1-abc",
|
|
type: "street",
|
|
name: "Test Avenue",
|
|
location: {
|
|
type: "Point",
|
|
coordinates: [-122.4194, 37.7749]
|
|
},
|
|
status: "available",
|
|
stats: {
|
|
completedTasksCount: 0,
|
|
reportsCount: 0,
|
|
openReportsCount: 0
|
|
}
|
|
};
|
|
|
|
// Mock street lookup
|
|
couchdbService.getDocument.mockResolvedValueOnce(streetData);
|
|
|
|
// Mock task lookup
|
|
couchdbService.find.mockResolvedValueOnce([
|
|
{
|
|
_id: "task-1",
|
|
type: "task",
|
|
street: { streetId, name: "Test Avenue" },
|
|
description: "Remove graffiti from wall",
|
|
status: "completed",
|
|
completedAt: new Date().toISOString()
|
|
},
|
|
{
|
|
_id: "task-2",
|
|
type: "task",
|
|
street: { streetId, name: "Test Avenue" },
|
|
description: "Clear storm drains",
|
|
status: "completed",
|
|
completedAt: new Date().toISOString()
|
|
}
|
|
]);
|
|
|
|
const response = await request(app)
|
|
.get(`/api/ai/task-suggestions?streetId=${streetId}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
expect(response.body.data.length).toBeGreaterThan(0);
|
|
|
|
// Check that street name is included
|
|
const suggestion = response.body.data[0];
|
|
expect(suggestion.street).toBe("Test Avenue");
|
|
});
|
|
|
|
it("should return 404 for non-existent street", async () => {
|
|
// Mock street not found
|
|
couchdbService.getDocument.mockResolvedValueOnce(null);
|
|
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions?streetId=nonexistent-id")
|
|
.expect(404);
|
|
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.msg).toBe("Street not found");
|
|
});
|
|
|
|
it("should include warning when OpenAI is not configured", async () => {
|
|
const response = await request(app)
|
|
.get("/api/ai/task-suggestions")
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
|
|
if (!aiService.isAvailable()) {
|
|
expect(response.body.warning).toBeDefined();
|
|
expect(response.body.warning).toContain("OpenAI API not configured");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("AI Service Integration", () => {
|
|
it("should generate suggestions with proper structure", async () => {
|
|
const suggestions = await aiService.generateTaskSuggestions({
|
|
streetName: "Main Street",
|
|
numberOfSuggestions: 5
|
|
});
|
|
|
|
expect(suggestions).toBeInstanceOf(Array);
|
|
expect(suggestions.length).toBe(5);
|
|
|
|
suggestions.forEach(suggestion => {
|
|
expect(suggestion).toHaveProperty("street");
|
|
expect(suggestion).toHaveProperty("description");
|
|
expect(suggestion).toHaveProperty("details");
|
|
expect(suggestion).toHaveProperty("estimatedTime");
|
|
expect(suggestion).toHaveProperty("difficulty");
|
|
expect(suggestion).toHaveProperty("priority");
|
|
expect(suggestion).toHaveProperty("source");
|
|
|
|
expect(["easy", "moderate", "hard"]).toContain(suggestion.difficulty);
|
|
expect(["low", "medium", "high"]).toContain(suggestion.priority);
|
|
});
|
|
});
|
|
|
|
it("should respect numberOfSuggestions parameter", async () => {
|
|
const suggestions1 = await aiService.generateTaskSuggestions({
|
|
streetName: "Oak Avenue",
|
|
numberOfSuggestions: 3
|
|
});
|
|
|
|
const suggestions2 = await aiService.generateTaskSuggestions({
|
|
streetName: "Elm Street",
|
|
numberOfSuggestions: 7
|
|
});
|
|
|
|
expect(suggestions1.length).toBe(3);
|
|
expect(suggestions2.length).toBe(7);
|
|
});
|
|
|
|
it("should include street name in suggestions", async () => {
|
|
const streetName = "Custom Street Name";
|
|
const suggestions = await aiService.generateTaskSuggestions({
|
|
streetName,
|
|
numberOfSuggestions: 3
|
|
});
|
|
|
|
suggestions.forEach(suggestion => {
|
|
expect(suggestion.street).toBe(streetName);
|
|
});
|
|
});
|
|
|
|
it("should return mock data when OpenAI is not configured", async () => {
|
|
const suggestions = await aiService.generateTaskSuggestions({
|
|
streetName: "Test Street",
|
|
numberOfSuggestions: 5
|
|
});
|
|
|
|
expect(suggestions).toBeInstanceOf(Array);
|
|
expect(suggestions.length).toBe(5);
|
|
|
|
// Mock suggestions should have source: "mock"
|
|
if (!aiService.isAvailable()) {
|
|
expect(suggestions[0].source).toBe("mock");
|
|
}
|
|
});
|
|
});
|
|
});
|