Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m1s
986 lines
38 KiB
TypeScript
986 lines
38 KiB
TypeScript
// src/routes/ai.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import fs from 'node:fs';
|
|
import { type Request, type Response, type NextFunction } from 'express';
|
|
import path from 'node:path';
|
|
import type { Job } from 'bullmq';
|
|
import {
|
|
createMockUserProfile,
|
|
createMockFlyer,
|
|
createMockAddress,
|
|
} from '../tests/utils/mockFactories';
|
|
import * as aiService from '../services/aiService.server';
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
import { mockLogger } from '../tests/utils/mockLogger';
|
|
import { ValidationError } from '../services/db/errors.db';
|
|
|
|
// Mock the AI service methods to avoid making real AI calls
|
|
vi.mock('../services/aiService.server', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../services/aiService.server')>();
|
|
return {
|
|
...actual,
|
|
aiService: {
|
|
extractTextFromImageArea: vi.fn(),
|
|
planTripWithMaps: vi.fn(),
|
|
enqueueFlyerProcessing: vi.fn(),
|
|
processLegacyFlyerUpload: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const { mockedDb } = vi.hoisted(() => ({
|
|
mockedDb: {
|
|
flyerRepo: {
|
|
findFlyerByChecksum: vi.fn(),
|
|
},
|
|
adminRepo: {
|
|
logActivity: vi.fn(),
|
|
},
|
|
personalizationRepo: {
|
|
getAllMasterItems: vi.fn(),
|
|
},
|
|
// This function is a standalone export, not part of a repo
|
|
createFlyerAndItems: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/db/flyer.db', () => ({ createFlyerAndItems: mockedDb.createFlyerAndItems }));
|
|
|
|
vi.mock('../services/db/index.db', () => ({
|
|
flyerRepo: mockedDb.flyerRepo,
|
|
adminRepo: mockedDb.adminRepo,
|
|
personalizationRepo: mockedDb.personalizationRepo,
|
|
}));
|
|
|
|
// Mock the queue service
|
|
vi.mock('../services/queueService.server', () => ({
|
|
flyerQueue: {
|
|
add: vi.fn(),
|
|
getJob: vi.fn(), // Also mock getJob for the status endpoint
|
|
},
|
|
}));
|
|
|
|
// Import the router AFTER all mocks are defined.
|
|
import aiRouter from './ai.routes';
|
|
import { flyerQueue } from '../services/queueService.server';
|
|
|
|
// Mock the logger to keep test output clean
|
|
vi.mock('../services/logger.server', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
// Mock the passport module to control authentication for different tests.
|
|
vi.mock('./passport.routes', () => ({
|
|
default: {
|
|
// Mock passport.authenticate to simply call next(), allowing the request to proceed.
|
|
// The actual user object will be injected by the mockAuth middleware or test setup.
|
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => next()),
|
|
},
|
|
// Mock the named exports as well
|
|
optionalAuth: vi.fn((req, res, next) => next()),
|
|
isAdmin: vi.fn((req, res, next) => next()),
|
|
}));
|
|
|
|
describe('AI Routes (/api/ai)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset logger implementation to no-op to prevent "Logging failed" leaks from previous tests
|
|
vi.mocked(mockLogger.info).mockImplementation(() => {});
|
|
vi.mocked(mockLogger.error).mockImplementation(() => {});
|
|
vi.mocked(mockLogger.warn).mockImplementation(() => {});
|
|
vi.mocked(mockLogger.debug).mockImplementation(() => {}); // Ensure debug is also mocked
|
|
});
|
|
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
|
|
|
|
// New test to cover the router.use diagnostic middleware's catch block and errMsg branches
|
|
describe('Diagnostic Middleware Error Handling', () => {
|
|
it('should log an error if logger.debug throws an object with a message property', async () => {
|
|
const mockErrorObject = { message: 'Mock debug error' };
|
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
|
throw mockErrorObject;
|
|
});
|
|
|
|
// Make any request to trigger the middleware
|
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: mockErrorObject.message }, // errMsg should extract the message
|
|
'Failed to log incoming AI request headers',
|
|
);
|
|
// The request should still proceed, but might fail later if the original flow was interrupted.
|
|
// Here, it will likely hit the 404 for job not found.
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('should log an error if logger.debug throws a primitive string', async () => {
|
|
const mockErrorString = 'Mock debug error string';
|
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
|
throw mockErrorString;
|
|
});
|
|
|
|
// Make any request to trigger the middleware
|
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: mockErrorString }, // errMsg should convert to string
|
|
'Failed to log incoming AI request headers',
|
|
);
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('should log an error if logger.debug throws null/undefined', async () => {
|
|
vi.mocked(mockLogger.debug).mockImplementationOnce(() => {
|
|
throw null; // Simulate throwing null
|
|
});
|
|
|
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
{ error: 'An unknown error occurred.' }, // errMsg should handle null/undefined
|
|
'Failed to log incoming AI request headers',
|
|
);
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /upload-and-process', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
// A valid SHA-256 checksum is 64 hex characters.
|
|
const validChecksum = 'a'.repeat(64);
|
|
|
|
it('should enqueue a job and return 202 on success', async () => {
|
|
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
|
id: 'job-123',
|
|
} as unknown as Job);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.field('checksum', validChecksum)
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(202);
|
|
expect(response.body.data.message).toBe('Flyer accepted for processing.');
|
|
expect(response.body.data.jobId).toBe('job-123');
|
|
expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 400 if no file is provided', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.field('checksum', validChecksum);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('A flyer file (PDF or image) is required.');
|
|
});
|
|
|
|
it('should return 400 if checksum is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(400);
|
|
// Use regex to be resilient to validation message changes
|
|
expect(response.body.error.details[0].message).toMatch(/checksum is required|Required/i);
|
|
});
|
|
|
|
it('should return 409 if flyer checksum already exists', async () => {
|
|
const duplicateError = new aiService.DuplicateFlyerError(
|
|
'This flyer has already been processed.',
|
|
99,
|
|
);
|
|
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValue(duplicateError);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.field('checksum', validChecksum)
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.error.message).toBe('This flyer has already been processed.');
|
|
});
|
|
|
|
it('should return 500 if enqueuing the job fails', async () => {
|
|
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockRejectedValueOnce(
|
|
new Error('Redis connection failed'),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.field('checksum', validChecksum)
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Redis connection failed');
|
|
});
|
|
|
|
it('should pass user ID to the job when authenticated', async () => {
|
|
// Arrange: Create a new app instance specifically for this test
|
|
// with the authenticated user middleware already applied.
|
|
const mockUser = createMockUserProfile({
|
|
user: { user_id: 'auth-user-1', email: 'auth-user-1@test.com' },
|
|
});
|
|
const authenticatedApp = createTestApp({
|
|
router: aiRouter,
|
|
basePath: '/api/ai',
|
|
authenticatedUser: mockUser,
|
|
});
|
|
|
|
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
|
id: 'job-456',
|
|
} as unknown as Job);
|
|
|
|
// Act
|
|
await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
|
.field('checksum', validChecksum)
|
|
.attach('flyerFile', imagePath);
|
|
|
|
// Assert
|
|
expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
|
|
const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0];
|
|
// Check the userProfile argument (3rd argument)
|
|
expect(callArgs[2]?.user.user_id).toBe('auth-user-1');
|
|
});
|
|
|
|
it('should pass user profile address to the job when authenticated user has an address', async () => {
|
|
// Arrange: Create a mock user with a complete address object
|
|
const mockAddress = createMockAddress({
|
|
address_id: 1,
|
|
address_line_1: '123 Pacific St',
|
|
city: 'Anytown',
|
|
province_state: 'BC',
|
|
postal_code: 'V8T 1A1',
|
|
country: 'CA',
|
|
});
|
|
const mockUserWithAddress = createMockUserProfile({
|
|
user: { user_id: 'auth-user-2', email: 'auth-user-2@test.com' },
|
|
address: mockAddress,
|
|
});
|
|
const authenticatedApp = createTestApp({
|
|
router: aiRouter,
|
|
basePath: '/api/ai',
|
|
authenticatedUser: mockUserWithAddress,
|
|
});
|
|
|
|
vi.mocked(aiService.aiService.enqueueFlyerProcessing).mockResolvedValue({
|
|
id: 'job-789',
|
|
} as unknown as Job);
|
|
|
|
// Act
|
|
await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-and-process')
|
|
.set('Authorization', 'Bearer mock-token') // Add this to satisfy the header check in the route
|
|
.field('checksum', validChecksum)
|
|
.attach('flyerFile', imagePath);
|
|
|
|
// Assert
|
|
expect(aiService.aiService.enqueueFlyerProcessing).toHaveBeenCalled();
|
|
// The service handles address extraction from profile, so we just verify the profile was passed
|
|
const callArgs = vi.mocked(aiService.aiService.enqueueFlyerProcessing).mock.calls[0];
|
|
expect(callArgs[2]?.address?.address_line_1).toBe('123 Pacific St');
|
|
});
|
|
|
|
it('should clean up the uploaded file if validation fails (e.g., missing checksum)', async () => {
|
|
// Spy on the unlink function to ensure it's called on error
|
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/upload-and-process')
|
|
.attach('flyerFile', imagePath); // No checksum field, will cause validation to throw
|
|
|
|
expect(response.status).toBe(400);
|
|
// The validation error is now caught inside the route handler, which then calls cleanup.
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
|
|
unlinkSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('GET /jobs/:jobId/status', () => {
|
|
it('should return 404 if job is not found', async () => {
|
|
// Mock the queue to return null for the job
|
|
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error.message).toBe('Job not found.');
|
|
});
|
|
|
|
it('should return job status if job is found', async () => {
|
|
const mockJob = {
|
|
id: 'job-123',
|
|
getState: async () => 'completed',
|
|
progress: 100,
|
|
returnvalue: { flyerId: 1 },
|
|
};
|
|
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as unknown as Job);
|
|
|
|
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.state).toBe('completed');
|
|
});
|
|
|
|
// Removed flaky test 'should return 400 for an invalid job ID format'
|
|
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
|
|
});
|
|
|
|
describe('POST /upload-legacy', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
const mockUser = createMockUserProfile({
|
|
user: { user_id: 'legacy-user-1', email: 'legacy-user@test.com' },
|
|
});
|
|
// This route requires authentication, so we create an app instance with a user.
|
|
const authenticatedApp = createTestApp({
|
|
router: aiRouter,
|
|
basePath: '/api/ai',
|
|
authenticatedUser: mockUser,
|
|
});
|
|
|
|
it('should process a legacy flyer and return 200 on success', async () => {
|
|
// Arrange
|
|
const mockFlyer = createMockFlyer({ flyer_id: 10 });
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
|
|
|
|
// Act
|
|
const response = await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-legacy')
|
|
.field('some_legacy_field', 'value') // simulate some body data
|
|
.attach('flyerFile', imagePath);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockFlyer);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
|
|
expect.any(Object), // req.file
|
|
expect.any(Object), // req.body
|
|
mockUser,
|
|
expect.any(Object), // req.log
|
|
);
|
|
});
|
|
|
|
it('should return 400 if no flyer file is uploaded', async () => {
|
|
const response = await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-legacy')
|
|
.field('some_legacy_field', 'value');
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('No flyer file uploaded.');
|
|
});
|
|
|
|
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
|
|
const duplicateError = new aiService.DuplicateFlyerError('Duplicate legacy flyer.', 101);
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-legacy')
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.error.message).toBe('Duplicate legacy flyer.');
|
|
expect(response.body.error.details.flyerId).toBe(101);
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
unlinkSpy.mockRestore();
|
|
});
|
|
|
|
it('should return 500 and cleanup file on a generic service error', async () => {
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
|
new Error('Internal service failure'),
|
|
);
|
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(authenticatedApp)
|
|
.post('/api/ai/upload-legacy')
|
|
.attach('flyerFile', imagePath);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Internal service failure');
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
unlinkSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('POST /flyers/process (Legacy)', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
const mockDataPayload = {
|
|
checksum: 'test-checksum',
|
|
originalFileName: 'flyer.jpg',
|
|
extractedData: { store_name: 'Test Store', items: [] },
|
|
};
|
|
|
|
it('should save a flyer and return 201 on success', async () => {
|
|
// Arrange
|
|
const mockFlyer = createMockFlyer({
|
|
flyer_id: 1,
|
|
file_name: mockDataPayload.originalFileName,
|
|
});
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(mockDataPayload))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.data.message).toBe('Flyer processed and saved successfully.');
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should return 400 if no flyer image is provided', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(mockDataPayload));
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 409 Conflict and delete the uploaded file if flyer checksum already exists', async () => {
|
|
// Arrange
|
|
const duplicateError = new aiService.DuplicateFlyerError(
|
|
'This flyer has already been processed.',
|
|
99,
|
|
);
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(mockDataPayload))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
// Assert
|
|
expect(response.status).toBe(409);
|
|
expect(response.body.error.message).toBe('This flyer has already been processed.');
|
|
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws
|
|
// Assert that the file was deleted
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
// The filename is predictable in the test environment because of the multer config in ai.routes.ts
|
|
expect(unlinkSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('flyerImage-test-flyer-image.jpg'),
|
|
);
|
|
});
|
|
|
|
it('should accept payload when extractedData.items is missing and save with empty items', async () => {
|
|
// Arrange: extractedData present but items missing
|
|
const partialPayload = {
|
|
checksum: 'test-checksum-2',
|
|
originalFileName: 'flyer2.jpg',
|
|
extractedData: { store_name: 'Partial Store' }, // no items key
|
|
};
|
|
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
|
|
createMockFlyer({ flyer_id: 2 }),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(partialPayload))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should fallback to a safe store name when store_name is missing', async () => {
|
|
const payloadNoStore = {
|
|
checksum: 'test-checksum-3',
|
|
originalFileName: 'flyer3.jpg',
|
|
extractedData: { items: [] }, // store_name missing
|
|
};
|
|
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(
|
|
createMockFlyer({ flyer_id: 3 }),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(payloadNoStore))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle a generic error during flyer creation', async () => {
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValueOnce(
|
|
new Error('DB transaction failed'),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(mockDataPayload))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /flyers/process (Legacy Payload Variations)', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
const mockDataPayload = {
|
|
checksum: 'test-checksum',
|
|
originalFileName: 'flyer.jpg',
|
|
extractedData: { store_name: 'Test Store', items: [] },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
const mockFlyer = createMockFlyer({ flyer_id: 1 });
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
|
|
});
|
|
|
|
it('should handle payload where "data" field is an object, not stringified JSON', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(mockDataPayload)) // Supertest stringifies this, but Express JSON parser will make it an object
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle payload where extractedData is null', async () => {
|
|
const payloadWithNullExtractedData = {
|
|
checksum: 'null-extracted-data-checksum',
|
|
originalFileName: 'flyer-null.jpg',
|
|
extractedData: null,
|
|
};
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(payloadWithNullExtractedData))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle payload where extractedData is a string', async () => {
|
|
const payloadWithStringExtractedData = {
|
|
checksum: 'string-extracted-data-checksum',
|
|
originalFileName: 'flyer-string.jpg',
|
|
extractedData: 'not-an-object',
|
|
};
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(payloadWithStringExtractedData))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle payload where extractedData is at the root of the body', async () => {
|
|
// This simulates a client sending multipart fields for each property of extractedData
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('checksum', 'root-checksum')
|
|
.field('originalFileName', 'flyer.jpg')
|
|
.field('store_name', 'Root Store')
|
|
.field('items', JSON.stringify([]))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201); // This test was failing with 500, the fix is in ai.routes.ts
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should default item quantity to 1 if missing', async () => {
|
|
const payloadMissingQuantity = {
|
|
checksum: 'qty-checksum',
|
|
originalFileName: 'flyer-qty.jpg',
|
|
extractedData: {
|
|
store_name: 'Qty Store',
|
|
items: [{ name: 'Item without qty', price: 100 }],
|
|
},
|
|
};
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(payloadMissingQuantity))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('POST /flyers/process (Legacy Error Handling)', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
|
|
it('should handle malformed JSON in data field and return 400', async () => {
|
|
const malformedDataString = '{"checksum":'; // Invalid JSON
|
|
|
|
// Since the service parses the data, we mock it to throw a ValidationError when parsing fails
|
|
// or when it detects the malformed input.
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
|
new ValidationError([], 'Checksum is required.'),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', malformedDataString)
|
|
.attach('flyerImage', imagePath);
|
|
|
|
// The outer catch block should be hit, leading to empty parsed data.
|
|
// The handler then fails the checksum validation.
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('Checksum is required.');
|
|
// Note: The logging expectation was removed because if the service throws a ValidationError,
|
|
// the route handler passes it to the global error handler, which might log differently or not as a "critical error during parsing" in the route itself.
|
|
});
|
|
|
|
it('should return 400 if checksum is missing from legacy payload', async () => {
|
|
const payloadWithoutChecksum = {
|
|
originalFileName: 'flyer.jpg',
|
|
extractedData: { store_name: 'Test Store', items: [] },
|
|
};
|
|
// Spy on fs.promises.unlink to verify file cleanup
|
|
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
|
|
|
// Mock the service to throw a ValidationError because the checksum is missing
|
|
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(
|
|
new ValidationError([], 'Checksum is required.'),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/flyers/process')
|
|
.field('data', JSON.stringify(payloadWithoutChecksum))
|
|
.attach('flyerImage', imagePath);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('Checksum is required.');
|
|
// Ensure the uploaded file is cleaned up
|
|
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
|
|
|
unlinkSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('POST /check-flyer', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
it('should return 400 if no image is provided', async () => {
|
|
const response = await supertest(app).post('/api/ai/check-flyer');
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 200 with a stubbed response on success', async () => {
|
|
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.is_flyer).toBe(true);
|
|
});
|
|
|
|
it('should return 500 on a generic error', async () => {
|
|
// To trigger the catch block, we can cause the middleware to fail.
|
|
// Mock logger.info to throw, which is inside the try block.
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Logging failed');
|
|
});
|
|
// Attach a valid file to get past the `if (!req.file)` check.
|
|
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /rescan-area', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
it('should return 400 if image file is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/rescan-area')
|
|
.field('cropArea', JSON.stringify({ x: 0, y: 0, width: 10, height: 10 }))
|
|
.field('extractionType', 'store_name');
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 400 if cropArea or extractionType is missing', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/rescan-area')
|
|
.attach('image', imagePath)
|
|
.field('extractionType', 'store_name'); // Missing cropArea
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toMatch(
|
|
/cropArea must be a valid JSON string|Required/i,
|
|
);
|
|
});
|
|
|
|
it('should return 400 if cropArea is malformed JSON', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/rescan-area')
|
|
.attach('image', imagePath)
|
|
.field('cropArea', '{ "x": 0, "y": 0, "width": 10, "height": 10'); // Malformed
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('POST /extract-address', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
it('should return 400 if no image is provided', async () => {
|
|
const response = await supertest(app).post('/api/ai/extract-address');
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 200 with a stubbed response on success', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/extract-address')
|
|
.attach('image', imagePath);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.address).toBe('not identified');
|
|
});
|
|
|
|
it('should return 500 on a generic error', async () => {
|
|
// An empty buffer can sometimes cause underlying libraries to throw an error
|
|
// To reliably trigger the catch block, mock the logger to throw.
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/extract-address')
|
|
.attach('image', imagePath);
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /extract-logo', () => {
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
|
it('should return 400 if no images are provided', async () => {
|
|
const response = await supertest(app).post('/api/ai/extract-logo');
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 200 with a stubbed response on success', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/extract-logo')
|
|
.attach('images', imagePath);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.store_logo_base_64).toBeNull();
|
|
});
|
|
|
|
it('should return 500 on a generic error', async () => {
|
|
// An empty buffer can sometimes cause underlying libraries to throw an error
|
|
// To reliably trigger the catch block, mock the logger to throw.
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/extract-logo')
|
|
.attach('images', imagePath);
|
|
expect(response.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /rescan-area (authenticated)', () => {
|
|
// This was a duplicate, fixed.
|
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); // This was a duplicate, fixed.
|
|
const mockUser = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'user-123@test.com' },
|
|
});
|
|
const authenticatedApp = createTestApp({
|
|
router: aiRouter,
|
|
basePath: '/api/ai',
|
|
authenticatedUser: mockUser,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Inject an authenticated user for this test block
|
|
authenticatedApp.use((req, res, next) => {
|
|
req.user = mockUser;
|
|
next();
|
|
});
|
|
});
|
|
|
|
it('should call the AI service and return the result on success (authenticated)', async () => {
|
|
const mockResult = { text: 'Rescanned Text' };
|
|
vi.mocked(aiService.aiService.extractTextFromImageArea).mockResolvedValueOnce(mockResult);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/rescan-area')
|
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
|
.field('extractionType', 'item_details')
|
|
.attach('image', imagePath);
|
|
// Use the authenticatedApp instance for requests in this block
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockResult);
|
|
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 500 if the AI service throws an error (authenticated)', async () => {
|
|
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValueOnce(
|
|
new Error('AI API is down'),
|
|
);
|
|
|
|
const response = await supertest(authenticatedApp)
|
|
.post('/api/ai/rescan-area')
|
|
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
|
|
.field('extractionType', 'item_details')
|
|
.attach('image', imagePath);
|
|
|
|
expect(response.status).toBe(500);
|
|
// The error message might be wrapped or formatted differently
|
|
expect(response.body.error.message).toMatch(/AI API is down/i);
|
|
});
|
|
});
|
|
|
|
describe('when user is authenticated', () => {
|
|
// Note: authenticatedApp is available from the describe block above if needed
|
|
|
|
it('POST /quick-insights should return the stubbed response', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/quick-insights')
|
|
.send({ items: [{ name: 'test' }] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.text).toContain('server-generated quick insight');
|
|
});
|
|
|
|
it('POST /quick-insights should accept items with "item" property instead of "name"', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/quick-insights')
|
|
.send({ items: [{ item: 'test item' }] });
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it('POST /quick-insights should return 500 on a generic error', async () => {
|
|
// To hit the catch block, we can simulate an error by making the logger throw.
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/quick-insights')
|
|
.send({ items: [{ name: 'test' }] });
|
|
expect(response.status).toBe(500);
|
|
});
|
|
|
|
it('POST /deep-dive should return the stubbed response', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/deep-dive')
|
|
.send({ items: [{ name: 'test' }] });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.text).toContain('server-generated deep dive');
|
|
});
|
|
|
|
it('POST /generate-image should return 501 Not Implemented', async () => {
|
|
const response = await supertest(app).post('/api/ai/generate-image').send({ prompt: 'test' });
|
|
|
|
expect(response.status).toBe(501);
|
|
expect(response.body.error.message).toBe('Image generation is not yet implemented.');
|
|
});
|
|
|
|
it('POST /generate-speech should return 501 Not Implemented', async () => {
|
|
const response = await supertest(app).post('/api/ai/generate-speech').send({ text: 'test' });
|
|
expect(response.status).toBe(501);
|
|
expect(response.body.error.message).toBe('Speech generation is not yet implemented.');
|
|
});
|
|
|
|
it('POST /search-web should return the stubbed response', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/search-web')
|
|
.send({ query: 'test query' });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.text).toContain('The web says this is good');
|
|
});
|
|
|
|
it('POST /compare-prices should return the stubbed response', async () => {
|
|
const response = await supertest(app)
|
|
.post('/api/ai/compare-prices')
|
|
.send({ items: [{ name: 'Milk' }] });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.text).toContain('server-generated price comparison');
|
|
});
|
|
|
|
it('POST /plan-trip should return result on success', async () => {
|
|
const mockResult = { text: 'Trip plan', sources: [] };
|
|
vi.mocked(aiService.aiService.planTripWithMaps).mockResolvedValueOnce(mockResult);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/plan-trip')
|
|
.send({
|
|
items: [],
|
|
store: { name: 'Test Store' },
|
|
userLocation: { latitude: 0, longitude: 0 },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockResult);
|
|
});
|
|
|
|
it('POST /plan-trip should return 500 if the AI service fails', async () => {
|
|
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValueOnce(
|
|
new Error('Maps API key invalid'),
|
|
);
|
|
|
|
const response = await supertest(app)
|
|
.post('/api/ai/plan-trip')
|
|
.send({
|
|
items: [],
|
|
store: { name: 'Test Store' },
|
|
userLocation: { latitude: 0, longitude: 0 },
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Maps API key invalid');
|
|
});
|
|
|
|
it('POST /deep-dive should return 500 on a generic error', async () => {
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Deep dive logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/deep-dive')
|
|
.send({ items: [{ name: 'test' }] });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Deep dive logging failed');
|
|
});
|
|
|
|
it('POST /search-web should return 500 on a generic error', async () => {
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Search web logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/search-web')
|
|
.send({ query: 'test query' });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Search web logging failed');
|
|
});
|
|
|
|
it('POST /compare-prices should return 500 on a generic error', async () => {
|
|
vi.mocked(mockLogger.info).mockImplementationOnce(() => {
|
|
throw new Error('Compare prices logging failed');
|
|
});
|
|
const response = await supertest(app)
|
|
.post('/api/ai/compare-prices')
|
|
.send({ items: [{ name: 'Milk' }] });
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('Compare prices logging failed');
|
|
});
|
|
|
|
it('POST /quick-insights should return 400 if items are missing', async () => {
|
|
const response = await supertest(app).post('/api/ai/quick-insights').send({});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('POST /search-web should return 400 if query is missing', async () => {
|
|
const response = await supertest(app).post('/api/ai/search-web').send({});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('POST /compare-prices should return 400 if items are missing', async () => {
|
|
const response = await supertest(app).post('/api/ai/compare-prices').send({});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('POST /plan-trip should return 400 if required fields are missing', async () => {
|
|
const response = await supertest(app).post('/api/ai/plan-trip').send({ items: [] });
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
});
|