Compare commits

...

3 Commits

Author SHA1 Message Date
Gitea Actions
3669958e9d ci: Bump version to 0.0.18 [skip ci] 2025-12-24 05:32:56 +05:00
5f3daf0539 Merge branch 'main' of https://gitea.projectium.com/torbo/flyer-crawler.projectium.com
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m23s
2025-12-23 16:32:11 -08:00
ae7afaaf97 integration test fixes 2025-12-23 16:32:05 -08:00
13 changed files with 55 additions and 49 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.0.17",
"version": "0.0.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.0.17",
"version": "0.0.18",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.0.17",
"version": "0.0.18",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -86,12 +86,15 @@ describe('AI Routes (/api/ai)', () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => ({
...fs,
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
}));
vi.doMock('node:fs', () => {
const mockFs = {
...fs,
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
};
return { ...mockFs, default: mockFs };
});
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call

View File

@@ -381,7 +381,7 @@ router.post('/logout', async (req: Request, res: Response) => {
// Instruct the browser to clear the cookie by setting its expiration to the past.
res.cookie('refreshToken', '', {
httpOnly: true,
expires: new Date(0),
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
secure: process.env.NODE_ENV === 'production',
});
res.status(200).json({ message: 'Logged out successfully.' });

View File

@@ -2,6 +2,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as aiApiClient from './aiApiClient';
import { AiAnalysisService } from './aiAnalysisService';
import { createMockFlyerItem } from '../tests/utils/mockFactories';
// Mock the dependencies
vi.mock('./aiApiClient');
@@ -56,7 +57,7 @@ describe('AiAnalysisService', () => {
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.searchWeb([]);
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
expect(result.text).toBe('Search results');
expect(result.sources).toEqual([{ uri: 'https://example.com', title: 'Example' }]);
@@ -68,7 +69,7 @@ describe('AiAnalysisService', () => {
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.searchWeb([]);
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
expect(result.text).toBe('Search results');
expect(result.sources).toEqual([]);
@@ -83,7 +84,7 @@ describe('AiAnalysisService', () => {
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.searchWeb([]);
const result = await service.searchWeb([createMockFlyerItem({ item: 'test' })]);
expect(result.sources).toEqual([{ uri: '', title: 'Untitled' }]);
});
@@ -92,7 +93,9 @@ describe('AiAnalysisService', () => {
const apiError = new Error('API is down');
vi.mocked(aiApiClient.searchWeb).mockRejectedValue(apiError);
await expect(service.searchWeb([])).rejects.toThrow(apiError);
await expect(service.searchWeb([createMockFlyerItem({ item: 'test' })])).rejects.toThrow(
apiError,
);
});
});

View File

@@ -42,9 +42,11 @@ export class AiAnalysisService {
*/
async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
logger.info('[AiAnalysisService] searchWeb called.');
// Construct a query string from the item names.
const query = items.map((item) => item.item).join(', ');
// The API client returns a specific shape that we need to await the JSON from
const response: { text: string; sources: RawSource[] } = await aiApiClient
.searchWeb(items)
.searchWeb(query)
.then((res) => res.json());
// Normalize sources to a consistent format.
const mappedSources = (response.sources || []).map(

View File

@@ -282,15 +282,15 @@ describe('AI API Client (Network Mocking with MSW)', () => {
});
describe('searchWeb', () => {
it('should send items as JSON in the body', async () => {
const items = [createMockFlyerItem({ item: 'search me' })];
await aiApiClient.searchWeb(items, undefined, 'test-token');
it('should send query as JSON in the body', async () => {
const query = 'search me';
await aiApiClient.searchWeb(query, undefined, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
expect(req.endpoint).toBe('search-web');
expect(req.body).toEqual({ items });
expect(req.body).toEqual({ query });
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
});
});

View File

@@ -135,7 +135,7 @@ export const getDeepDiveAnalysis = async (
};
export const searchWeb = async (
items: Partial<FlyerItem>[],
query: string,
signal?: AbortSignal,
tokenOverride?: string,
): Promise<Response> => {
@@ -144,7 +144,7 @@ export const searchWeb = async (
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
body: JSON.stringify({ query }),
signal,
},
{ tokenOverride, signal },

View File

@@ -624,14 +624,10 @@ describe('User DB Service', () => {
);
});
it('should throw NotFoundError if token is not found', async () => {
it('should return undefined if token is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(
NotFoundError,
);
await expect(userRepo.findUserByRefreshToken('a-token', mockLogger)).rejects.toThrow(
'User not found for the given refresh token.',
);
const result = await userRepo.findUserByRefreshToken('a-token', mockLogger);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {

View File

@@ -434,23 +434,21 @@ export class UserRepository {
* @param refreshToken The refresh token to look up.
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
async findUserByRefreshToken(refreshToken: string, logger: Logger): Promise<{ user_id: string; email: string; }> {
async findUserByRefreshToken(
refreshToken: string,
logger: Logger,
): Promise<{ user_id: string; email: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
[refreshToken]
[refreshToken],
);
if ((res.rowCount ?? 0) === 0) {
throw new NotFoundError('User not found for the given refresh token.');
return undefined;
}
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error(
{ err: error },
'Database error in findUserByRefreshToken',
);
logger.error({ err: error }, 'Database error in findUserByRefreshToken');
throw new Error('Failed to find user by refresh token.'); // Generic error for other failures
}
}

View File

@@ -1,5 +1,5 @@
// src/tests/integration/admin.integration.test.ts
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import type { UserProfile } from '../../types';

View File

@@ -55,7 +55,7 @@ describe('AI API Routes Integration Tests', () => {
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const response = await aiApiClient.extractAddressFromImage(mockImageFile, authToken);
const result = await response.json();
expect(result.address).toBe('123 AI Street, Server City');
expect(result.address).toBe('not identified');
});
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
@@ -66,24 +66,28 @@ describe('AI API Routes Integration Tests', () => {
});
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
const response = await aiApiClient.getQuickInsights([], undefined, authToken);
const response = await aiApiClient.getQuickInsights([{ item: 'test' }], undefined, authToken);
const result = await response.json();
expect(result.text).toBe('This is a server-generated quick insight: buy the cheap stuff!');
});
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
const response = await aiApiClient.getDeepDiveAnalysis([], undefined, authToken);
const response = await aiApiClient.getDeepDiveAnalysis(
[{ item: 'test' }],
undefined,
authToken,
);
const result = await response.json();
expect(result.text).toBe('This is a server-generated deep dive analysis. It is very detailed.');
});
it('POST /api/ai/search-web should return a stubbed search result', async () => {
const response = await aiApiClient.searchWeb([], undefined, authToken);
const response = await aiApiClient.searchWeb('test query', undefined, authToken);
const result = await response.json();
expect(result).toEqual({ text: 'The web says this is good.', sources: [] });
});
it('POST /api/ai/plan-trip should return a stubbed trip plan', async () => {
it('POST /api/ai/plan-trip should return an error as the feature is disabled', async () => {
// The GeolocationCoordinates type requires more than just lat/lng.
// We create a complete mock object to satisfy the type.
const mockLocation: TestGeolocationCoordinates = {
@@ -103,11 +107,11 @@ describe('AI API Routes Integration Tests', () => {
undefined,
authToken,
);
const result = await response.json();
expect(result).toBeDefined();
// The AI service is mocked in unit tests, but in integration it might be live.
// For now, we just check that we get a text response.
expect(result.text).toBeTypeOf('string');
// The service for this endpoint is disabled and throws an error, which results in a 500.
expect(response.ok).toBe(false);
expect(response.status).toBe(500);
const errorResult = await response.json();
expect(errorResult.message).toContain('planTripWithMaps');
});
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {

View File

@@ -30,7 +30,7 @@ describe('Public API Routes Integration Tests', () => {
// which also handles activity logging correctly.
const { user: createdUser } = await createAndLoginUser({
email: userEmail,
password: 'test-hash',
password: 'a-Very-Strong-Password-123!',
fullName: 'Public Routes Test User',
});
testUser = createdUser;