Files
adopt-a-street/backend/__tests__/routes/ai.test.js
William Valentin 771d39a52b feat: integrate OpenAI API for intelligent task suggestions
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>
2025-11-03 13:19:00 -08:00

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");
}
});
});
});