Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions server/activityRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import express, { type Application, type Request, type Response, type NextFunction } from 'express';
import activityRoutes from './activityRoutes';
import { storage } from './storage';

jest.mock('./storage', () => ({
storage: {
getRecentActivities: jest.fn(),
createActivity: jest.fn(),
},
}));

const createApp = (): Application => {
const app = express();
app.use(express.json());
app.use('/api/activities', activityRoutes);
return app;
};

describe('Activity Routes', () => {
let app: Application;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction = jest.fn();

beforeEach(() => {
app = createApp();
mockRequest = {};
mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
};
(storage.getRecentActivities as jest.Mock).mockClear();
(storage.createActivity as jest.Mock).mockClear();
});

describe('GET /api/activities', () => {
it('should get recent activities and return 200', async () => {
const mockActivities = [{ id: 1, description: 'Test Activity' }];
(storage.getRecentActivities as jest.Mock).mockResolvedValue(mockActivities);
mockRequest = { query: { limit: '5' }, method: 'GET' };

await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(storage.getRecentActivities).toHaveBeenCalledWith(5);
expect(mockResponse.json).toHaveBeenCalledWith(mockActivities);
});
Comment on lines +36 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Router invoked directly – Express middleware chain is bypassed

Calling await activityRoutes(mockReq, mockRes, next) exercises none of Express’s routing logic (method/path matching, async error propagation, etc.). Use supertest (or at least issue the request against the app instance) so the test environment mirrors production behaviour.

-import activityRoutes from './activityRoutes';
-...
-await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);
+import request from 'supertest';
+...
+const res = await request(app).get('/api/activities?limit=5');
+expect(res.status).toBe(200);
+expect(res.body).toEqual(mockActivities);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In server/activityRoutes.test.ts around lines 36 to 46, the test calls the route
handler function directly, bypassing Express's routing and middleware logic. To
fix this, refactor the test to use supertest or send the request through the
Express app instance so that method/path matching and error handling behave as
in production. This involves importing the app and using supertest to make a GET
request to /api/activities with the appropriate query parameters, then asserting
on the response.


it('should use default limit if not provided', async () => {
(storage.getRecentActivities as jest.Mock).mockResolvedValue([]);
mockRequest = { query: {}, method: 'GET' };

await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);
expect(storage.getRecentActivities).toHaveBeenCalledWith(undefined); // or specific default if defined in handler
});

it('should return 500 if fetching activities fails', async () => {
(storage.getRecentActivities as jest.Mock).mockRejectedValue(new Error('DB Error'));
mockRequest = { query: {}, method: 'GET' };

await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Failed to fetch activities' });
});
});

describe('POST /api/activities', () => {
it('should create an activity and return 201', async () => {
const newActivityData = { type: 'test', description: 'New Activity', icon: 'test', iconBg: 'test', timestamp: 'test' };
const createdActivity = { id: 2, ...newActivityData };
(storage.createActivity as jest.Mock).mockResolvedValue(createdActivity);

mockRequest = { body: newActivityData, method: 'POST' };
await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(storage.createActivity).toHaveBeenCalledWith(newActivityData);
expect(mockResponse.status).toHaveBeenCalledWith(201);
expect(mockResponse.json).toHaveBeenCalledWith(createdActivity);
});

it('should return 400 for invalid activity data (simulated ZodError)', async () => {
// This test path is challenging to hit directly without supertest
// if Zod parsing happens inside the route handler before the mock.
const invalidActivityData = { type: '' };
const zodError = new Error("Invalid activity data") as any;
zodError.errors = [{ message: "Type cannot be empty" }];
zodError.name = "ZodError";

// If storage.createActivity was called and it threw an error that was then
// identified as a ZodError equivalent by the route handler:
(storage.createActivity as jest.Mock).mockImplementation(() => {
throw zodError;
});

mockRequest = { body: invalidActivityData, method: 'POST' };
await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ message: "Invalid activity data" }));
});
Comment on lines +81 to +100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Zod error path is unreachable with current mocking strategy

insertActivitySchema.parse() runs before storage.createActivity is called, so simulating a ZodError via storage.createActivity will never hit the catch branch. Patch insertActivitySchema.parse itself or send the request through supertest with invalid JSON to cover this path.

- (storage.createActivity as jest.Mock).mockImplementation(() => {
-    throw zodError;
- });
+ jest.spyOn(insertActivitySchema, 'parse').mockImplementation(() => { throw zodError; });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should return 400 for invalid activity data (simulated ZodError)', async () => {
// This test path is challenging to hit directly without supertest
// if Zod parsing happens inside the route handler before the mock.
const invalidActivityData = { type: '' };
const zodError = new Error("Invalid activity data") as any;
zodError.errors = [{ message: "Type cannot be empty" }];
zodError.name = "ZodError";
// If storage.createActivity was called and it threw an error that was then
// identified as a ZodError equivalent by the route handler:
(storage.createActivity as jest.Mock).mockImplementation(() => {
throw zodError;
});
mockRequest = { body: invalidActivityData, method: 'POST' };
await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ message: "Invalid activity data" }));
});
it('should return 400 for invalid activity data (simulated ZodError)', async () => {
// This test path is challenging to hit directly without supertest
// if Zod parsing happens inside the route handler before the mock.
const invalidActivityData = { type: '' };
const zodError = new Error("Invalid activity data") as any;
zodError.errors = [{ message: "Type cannot be empty" }];
zodError.name = "ZodError";
jest.spyOn(insertActivitySchema, 'parse').mockImplementation(() => {
throw zodError;
});
mockRequest = { body: invalidActivityData, method: 'POST' };
await activityRoutes(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ message: "Invalid activity data" })
);
});
🤖 Prompt for AI Agents
In server/activityRoutes.test.ts around lines 81 to 100, the test attempts to
simulate a ZodError by mocking storage.createActivity to throw the error, but
since insertActivitySchema.parse runs before that call, this error path is never
reached. To fix this, modify the test to either mock insertActivitySchema.parse
directly to throw the ZodError or use supertest to send an invalid request that
triggers the schema validation failure, ensuring the catch block handling
ZodError is properly tested.

});
});
[end of server/activityRoutes.test.ts]
39 changes: 39 additions & 0 deletions server/activityRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import express from "express";
import { storage } from "./storage";
import { insertActivitySchema } from "@shared/schema";
import { z } from "zod";

const router = express.Router();

// Activities
router.get("/", async (req, res) => {
try {
const { limit } = req.query;
console.time("storage.getRecentActivities");
const activities = await storage.getRecentActivities(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Handle invalid limit query parameter

Validate or default limit if parseInt returns NaN to prevent issues when calling storage.

limit ? parseInt(limit as string) : undefined
);
console.timeEnd("storage.getRecentActivities");
Comment on lines +11 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate and parse limit more defensively

parseInt(limit as string) is called without a radix and the result is passed straight to storage.getRecentActivities.
• If the client passes something that is not an integer (?limit=foo) parseInt will return NaN and you’ll forward that to the storage layer.
• Omitting the radix is discouraged (parseInt("08")8 in some engines).

-const activities = await storage.getRecentActivities(
-  limit ? parseInt(limit as string) : undefined
-);
+const parsedLimit =
+  typeof limit === "string" && /^\d+$/.test(limit) ? parseInt(limit, 10) : undefined;
+
+if (limit && parsedLimit === undefined) {
+  return res.status(400).json({ message: "Query param 'limit' must be a positive integer" });
+}
+
+const activities = await storage.getRecentActivities(parsedLimit);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { limit } = req.query;
console.time("storage.getRecentActivities");
const activities = await storage.getRecentActivities(
limit ? parseInt(limit as string) : undefined
);
console.timeEnd("storage.getRecentActivities");
const { limit } = req.query;
console.time("storage.getRecentActivities");
const parsedLimit =
typeof limit === "string" && /^\d+$/.test(limit)
? parseInt(limit, 10)
: undefined;
if (limit && parsedLimit === undefined) {
return res
.status(400)
.json({ message: "Query param 'limit' must be a positive integer" });
}
const activities = await storage.getRecentActivities(parsedLimit);
console.timeEnd("storage.getRecentActivities");
🤖 Prompt for AI Agents
In server/activityRoutes.ts around lines 11 to 16, the limit query parameter is
parsed using parseInt without specifying a radix and without validating the
result, which can lead to NaN or incorrect values being passed to
storage.getRecentActivities. Fix this by explicitly passing a radix of 10 to
parseInt and adding validation to ensure the parsed limit is a valid positive
integer before passing it to storage.getRecentActivities; if invalid, pass
undefined or handle the error appropriately.

res.json(activities);
} catch (error) {
res.status(500).json({ message: "Failed to fetch activities" });
}
});

router.post("/", async (req, res) => {
try {
const activityData = insertActivitySchema.parse(req.body);
console.time("storage.createActivity");
const activity = await storage.createActivity(activityData);
console.timeEnd("storage.createActivity");
res.status(201).json(activity);
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ message: "Invalid activity data", errors: error.errors });
} else {
res.status(500).json({ message: "Failed to create activity" });
}
}
});

export default router;
1 change: 1 addition & 0 deletions server/ai-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const aiAnalysisSchema = z.object({
reasoning: z.string(),
suggestedLabels: z.array(z.string()),
riskFlags: z.array(z.string()).optional(),
categoryId: z.number().optional(), // Added categoryId
});

export type AIAnalysis = z.infer<typeof aiAnalysisSchema>;
Expand Down
217 changes: 217 additions & 0 deletions server/aiRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import express, { type Application, type Request, type Response, type NextFunction } from 'express';
import aiRoutes from './aiRoutes';
import { storage } from './storage';
import { pythonNLP } from './python-bridge';
import { type MappedNLPResult } from './python-bridge';

// Mock dependencies
jest.mock('./storage', () => ({
storage: {
getEmailById: jest.fn(),
updateEmail: jest.fn(),
createActivity: jest.fn(),
getAllCategories: jest.fn(), // Though this might not be used anymore post-refactor
getCategoryById: jest.fn(),
},
}));

jest.mock('./python-bridge', () => ({
pythonNLP: {
analyzeEmail: jest.fn(),
testConnection: jest.fn(),
},
}));

const createApp = (): Application => {
const app = express();
app.use(express.json());
app.use('/api/ai', aiRoutes);
return app;
};

describe('AI Routes', () => {
let app: Application;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction = jest.fn();

const mockEmail = { id: 1, subject: 'Test Email', content: 'Test content' };
const mockNLPAnalysisResult: MappedNLPResult = {
topic: 'Work',
sentiment: 'neutral',
intent: 'informational',
urgency: 'low',
confidence: 0.9,
categories: ['Work & Business'], // Raw categories from NLP
keywords: ['test', 'email'],
reasoning: 'Analyzed by mock',
suggestedLabels: ['TestLabel'],
riskFlags: [],
categoryId: 5, // Assume Python now returns a categoryId
validation: {
validationMethod: 'confidence_threshold',
score: 0.9,
reliable: true,
feedback: 'Mock validation',
},
};

beforeEach(() => {
app = createApp();
mockRequest = {};
mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
};
// Clear mocks
(storage.getEmailById as jest.Mock).mockClear();
(storage.updateEmail as jest.Mock).mockClear();
(storage.createActivity as jest.Mock).mockClear();
(storage.getAllCategories as jest.Mock).mockClear();
(storage.getCategoryById as jest.Mock).mockClear();
(pythonNLP.analyzeEmail as jest.Mock).mockClear();
(pythonNLP.testConnection as jest.Mock).mockClear();
});

describe('POST /api/ai/analyze', () => {
it('should analyze email and return analysis', async () => {
(pythonNLP.analyzeEmail as jest.Mock).mockResolvedValue(mockNLPAnalysisResult);
mockRequest = { body: { subject: 'Test', content: 'Content' } , method: 'POST'};

await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(pythonNLP.analyzeEmail).toHaveBeenCalledWith('Test', 'Content');
expect(mockResponse.json).toHaveBeenCalledWith(mockNLPAnalysisResult);
});

it('should return 400 if subject or content is missing', async () => {
mockRequest = { body: { subject: 'Test' }, method: 'POST' }; // Missing content

await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Subject and content are required' });
});
});

describe('POST /api/ai/categorize', () => {
it('should auto-categorize an email using categoryId from AI and return success', async () => {
(storage.getEmailById as jest.Mock).mockResolvedValue(mockEmail);
(pythonNLP.analyzeEmail as jest.Mock).mockResolvedValue(mockNLPAnalysisResult); // which includes categoryId: 5
const updatedEmail = { ...mockEmail, categoryId: 5, confidence: 90 };
(storage.updateEmail as jest.Mock).mockResolvedValue(updatedEmail);
(storage.getCategoryById as jest.Mock).mockResolvedValue({ id: 5, name: 'Work', description: '', color: '', count: 1 });


mockRequest = { body: { emailId: 1, autoAnalyze: true }, method: 'POST' };
await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(storage.getEmailById).toHaveBeenCalledWith(1);
expect(pythonNLP.analyzeEmail).toHaveBeenCalledWith(mockEmail.subject, mockEmail.content);
expect(storage.updateEmail).toHaveBeenCalledWith(1, {
categoryId: mockNLPAnalysisResult.categoryId,
confidence: Math.round(mockNLPAnalysisResult.confidence * 100),
labels: mockNLPAnalysisResult.suggestedLabels,
});
expect(storage.createActivity).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
email: updatedEmail,
categoryAssigned: 'Work'
}));
});

it('should return success:false if AI does not provide categoryId', async () => {
(storage.getEmailById as jest.Mock).mockResolvedValue(mockEmail);
const nlpResultWithoutCategoryId = { ...mockNLPAnalysisResult, categoryId: undefined };
(pythonNLP.analyzeEmail as jest.Mock).mockResolvedValue(nlpResultWithoutCategoryId);

mockRequest = { body: { emailId: 1, autoAnalyze: true }, method: 'POST' };
await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
success: false,
message: "No matching category found or AI analysis did not provide a category ID."
}));
});

it('should handle manual categorization', async () => {
(storage.getEmailById as jest.Mock).mockResolvedValue(mockEmail);
const manualCategoryId = 2;
const updatedEmail = { ...mockEmail, categoryId: manualCategoryId, confidence: 95 };
(storage.updateEmail as jest.Mock).mockResolvedValue(updatedEmail);

mockRequest = { body: { emailId: 1, autoAnalyze: false, categoryId: manualCategoryId, confidence: 95 }, method: 'POST' };
await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(storage.updateEmail).toHaveBeenCalledWith(1, { categoryId: manualCategoryId, confidence: 95 });
expect(storage.createActivity).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith({ success: true, email: updatedEmail });
});
});

describe('POST /api/ai/batch-analyze', () => {
it('should batch analyze emails and use categoryId from AI', async () => {
const emailIds = [1, 2];
const email1 = { id: 1, subject: 'S1', content: 'C1' };
const email2 = { id: 2, subject: 'S2', content: 'C2' };
const nlpResult1: MappedNLPResult = { ...mockNLPAnalysisResult, categoryId: 5, categories: ["Work"] };
const nlpResult2: MappedNLPResult = { ...mockNLPAnalysisResult, categoryId: undefined, categories: ["Personal"] }; // No categoryId for second

(storage.getEmailById as jest.Mock).mockImplementation(id => {
if (id === 1) return Promise.resolve(email1);
if (id === 2) return Promise.resolve(email2);
return Promise.resolve(undefined);
});
(pythonNLP.analyzeEmail as jest.Mock).mockImplementation((subject) => {
if (subject === 'S1') return Promise.resolve(nlpResult1);
if (subject === 'S2') return Promise.resolve(nlpResult2);
return Promise.resolve(mockNLPAnalysisResult);
});
(storage.updateEmail as jest.Mock).mockResolvedValue(email1); // Simulate update success
(storage.getCategoryById as jest.Mock).mockResolvedValue({ id: 5, name: 'Work', description: '', color: '', count: 1 });


mockRequest = { body: { emailIds }, method: 'POST' };
await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(pythonNLP.analyzeEmail).toHaveBeenCalledTimes(2);
expect(storage.updateEmail).toHaveBeenCalledTimes(1); // Only for email1 with categoryId
expect(storage.updateEmail).toHaveBeenCalledWith(1, expect.objectContaining({ categoryId: 5 }));
expect(storage.createActivity).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
results: expect.arrayContaining([
expect.objectContaining({ emailId: 1, success: true, category: 'Work' }),
expect.objectContaining({ emailId: 2, success: false, reason: 'no_matching_category_id_from_ai' })
])
}));
});
});

describe('POST /api/ai/validate', () => {
it('should validate AI analysis and return success', async () => {
(storage.getEmailById as jest.Mock).mockResolvedValue(mockEmail);
(pythonNLP.analyzeEmail as jest.Mock).mockResolvedValue(mockNLPAnalysisResult);

mockRequest = { body: { emailId: 1, userFeedback: 'correct' }, method: 'POST' };
await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(storage.createActivity).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
});
});

describe('GET /api/ai/health', () => {
it('should return health status from pythonNLP.testConnection', async () => {
(pythonNLP.testConnection as jest.Mock).mockResolvedValue(true);
mockRequest = { method: 'GET' };

await aiRoutes(mockRequest as Request, mockResponse as Response, nextFunction);

expect(pythonNLP.testConnection).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'healthy' }));
});
});
});
[end of server/aiRoutes.test.ts]
Loading