Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions
696c547238 ci: Bump version to 0.9.35 [skip ci] 2026-01-06 08:11:42 +05:00
38165bdb9a get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
2026-01-05 19:10:46 -08:00
Gitea Actions
6139dca072 ci: Bump version to 0.9.34 [skip ci] 2026-01-06 06:33:46 +05:00
68bfaa50e6 more baseurl work - hopefully that does it for now
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m5s
2026-01-05 17:33:00 -08:00
Gitea Actions
9c42621f74 ci: Bump version to 0.9.33 [skip ci] 2026-01-06 04:34:48 +05:00
1b98282202 more rate limiting
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m19s
2026-01-05 15:31:01 -08:00
33 changed files with 141 additions and 122 deletions

View File

@@ -113,7 +113,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# --- Integration test specific variables ---
FRONTEND_URL: 'http://localhost:3000'
FRONTEND_URL: 'http://example.com'
VITE_API_BASE_URL: 'http://localhost:3001/api'
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
@@ -389,7 +389,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# Application Secrets
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
FRONTEND_URL: 'http://example.com'
JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.32",
"version": "0.9.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.32",
"version": "0.9.35",
"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.9.32",
"version": "0.9.35",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -28,6 +28,7 @@ const uploadAndProcessSchema = z.object({
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
baseUrl: z.string().url().optional(),
}),
});
@@ -198,6 +199,7 @@ router.post(
userProfile,
req.ip ?? 'unknown',
req.log,
body.baseUrl,
);
// Respond immediately to the client with 202 Accepted

View File

@@ -113,8 +113,8 @@ describe('Deals Routes (/api/users/deals)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -319,8 +319,8 @@ describe('Flyer Routes (/api/flyers)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
@@ -331,8 +331,8 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
it('should apply batchLimiter to POST /items/batch-count', async () => {
@@ -343,8 +343,8 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
@@ -357,8 +357,8 @@ describe('Flyer Routes (/api/flyers)', () => {
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(200);
});
});
});

View File

@@ -345,8 +345,8 @@ describe('Gamification Routes (/api/achievements)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply userReadLimiter to GET /me', async () => {
@@ -360,8 +360,8 @@ describe('Gamification Routes (/api/achievements)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply adminTriggerLimiter to POST /award', async () => {
@@ -378,8 +378,8 @@ describe('Gamification Routes (/api/achievements)', () => {
.send({ userId: 'some-user', achievementName: 'some-achievement' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(30);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(30);
});
});
});

View File

@@ -115,7 +115,7 @@ describe('Personalization Routes (/api/personalization)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-limit');
});
});
});

View File

@@ -159,8 +159,8 @@ describe('Price Routes (/api/price-history)', () => {
.send({ masterItemIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
});
});

View File

@@ -218,7 +218,7 @@ describe('Reaction Routes (/api/reactions)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-limit');
});
it('should apply userUpdateLimiter to POST /toggle', async () => {
@@ -236,8 +236,8 @@ describe('Reaction Routes (/api/reactions)', () => {
.send({ entity_type: 'recipe', entity_id: '1', reaction_type: 'like' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(150);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(150);
});
});
});

View File

@@ -350,7 +350,7 @@ describe('Recipe Routes (/api/recipes)', () => {
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many recipe suggestion requests');
expect(blockedResponse.text).toContain('Too many AI generation requests');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
@@ -375,8 +375,8 @@ describe('Recipe Routes (/api/recipes)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -75,7 +75,7 @@ describe('Stats Routes (/api/stats)', () => {
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-limit');
});
});
});

View File

@@ -171,10 +171,10 @@ describe('System Routes (/api/system)', () => {
.send({ address });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
expect(parseInt(response.headers['x-ratelimit-remaining'])).toBeLessThan(limit);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-remaining');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(limit);
expect(parseInt(response.headers['ratelimit-remaining'])).toBeLessThan(limit);
});
});
});

View File

@@ -1030,7 +1030,7 @@ describe('User Routes (/api/users)', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = createMockUserProfile({
...mockUserProfile,
avatar_url: 'http://localhost:3001/uploads/avatars/new-avatar.png',
avatar_url: 'http://example.com/uploads/avatars/new-avatar.png',
});
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
@@ -1042,7 +1042,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('http://localhost:3001/uploads/avatars/');
expect(response.body.avatar_url).toContain('http://example.com/uploads/avatars/');
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expect.any(Object),
@@ -1237,6 +1237,19 @@ describe('User Routes (/api/users)', () => {
}); // End of Recipe Routes
describe('Rate Limiting', () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(() => {
// Advance time to ensure rate limits are reset between tests
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
});
afterAll(() => {
vi.useRealTimers();
});
it('should apply userUpdateLimiter to PUT /profile', async () => {
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUserProfile);
@@ -1246,8 +1259,8 @@ describe('User Routes (/api/users)', () => {
.send({ full_name: 'Rate Limit Test' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply userSensitiveUpdateLimiter to PUT /profile/password and block after limit', async () => {
@@ -1283,32 +1296,35 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(20);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(20);
});
});
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
const limit = 5;
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
// Explicitly advance time to ensure the rate limiter window has reset from previous tests
vi.advanceTimersByTime(60 * 60 * 1000 + 5000);
// Consume the limit
for (let i = 0; i < limit; i++) {
const limit = 5;
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
// Consume the limit
for (let i = 0; i < limit; i++) {
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
});
});
});
});

View File

@@ -116,7 +116,7 @@ interface MockFlyer {
updated_at: string;
}
const baseUrl = 'http://localhost:3001';
const baseUrl = 'http://example.com';
describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
@@ -1015,7 +1015,7 @@ describe('AI Service (Server)', () => {
userId: 'user123',
submitterIp: '127.0.0.1',
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
});
expect(result.id).toBe('job123');
});
@@ -1037,7 +1037,7 @@ describe('AI Service (Server)', () => {
expect.objectContaining({
userId: undefined,
userProfileAddress: undefined,
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
}),
);
});

View File

@@ -753,6 +753,7 @@ async enqueueFlyerProcessing(
userProfile: UserProfile | undefined,
submitterIp: string,
logger: Logger,
baseUrlOverride?: string,
): Promise<Job> {
// 1. Check for duplicate flyer
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
@@ -779,7 +780,7 @@ async enqueueFlyerProcessing(
.join(', ');
}
const baseUrl = getBaseUrl(logger);
const baseUrl = baseUrlOverride || getBaseUrl(logger);
// --- START DEBUGGING ---
// Add a fail-fast check to ensure the baseUrl is a valid URL before enqueuing.
// This will make the test fail at the upload step if the URL is the problem,

View File

@@ -59,7 +59,7 @@ describe('AuthService', () => {
// Set environment variables before any modules are imported
vi.stubEnv('JWT_SECRET', 'test-secret');
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
// Mock all dependencies before dynamically importing the service
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts

View File

@@ -132,8 +132,8 @@ describe('Flyer DB Service', () => {
it('should execute an INSERT query and return the new flyer', async () => {
const flyerData: FlyerDbInsert = {
file_name: 'test.jpg',
image_url: 'http://localhost:3001/images/test.jpg',
icon_url: 'http://localhost:3001/images/icons/test.jpg',
image_url: 'http://example.com/images/test.jpg',
icon_url: 'http://example.com/images/icons/test.jpg',
checksum: 'checksum123',
store_id: 1,
valid_from: '2024-01-01',
@@ -155,8 +155,8 @@ describe('Flyer DB Service', () => {
expect.stringContaining('INSERT INTO flyers'),
[
'test.jpg',
'http://localhost:3001/images/test.jpg',
'http://localhost:3001/images/icons/test.jpg',
'http://example.com/images/test.jpg',
'http://example.com/images/icons/test.jpg',
'checksum123',
1,
'2024-01-01',

View File

@@ -21,7 +21,7 @@ const createMockJobData = (data: Partial<FlyerJobData>): FlyerJobData => ({
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
...data,
});

View File

@@ -5,6 +5,7 @@ import { logger as mockLogger } from './logger.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { AiProcessorResult } from './flyerAiProcessor.server';
import type { FlyerItemInsert } from '../types';
import { getBaseUrl } from '../utils/serverUtils';
// Mock the dependencies
vi.mock('../utils/imageProcessor', () => ({
@@ -15,6 +16,10 @@ vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
vi.mock('../utils/serverUtils', () => ({
getBaseUrl: vi.fn(),
}));
describe('FlyerDataTransformer', () => {
let transformer: FlyerDataTransformer;
@@ -23,12 +28,13 @@ describe('FlyerDataTransformer', () => {
transformer = new FlyerDataTransformer();
// Stub environment variables to ensure consistency and predictability.
// Prioritize FRONTEND_URL to match the updated service logic.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
vi.stubEnv('PORT', ''); // Ensure this is not used
// Provide a default mock implementation for generateFlyerIcon
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
vi.mocked(getBaseUrl).mockReturnValue('http://example.com');
});
it('should transform AI data into database-ready format with a user ID', async () => {
@@ -244,7 +250,7 @@ describe('FlyerDataTransformer', () => {
);
});
it('should use fallback baseUrl if none is provided and log a warning', async () => {
it('should use fallback baseUrl from getBaseUrl if none is provided', async () => {
// Arrange
const aiResult: AiProcessorResult = {
data: {
@@ -258,9 +264,8 @@ describe('FlyerDataTransformer', () => {
};
const baseUrl = undefined; // Explicitly pass undefined for this test
// The fallback logic uses process.env.PORT || 3000.
// The beforeEach sets PORT to '', so it should fallback to 3000.
const expectedFallbackUrl = 'http://localhost:3000';
const expectedFallbackUrl = 'http://fallback-url.com';
vi.mocked(getBaseUrl).mockReturnValue(expectedFallbackUrl);
// Act
const { flyerData } = await transformer.transform(
@@ -275,10 +280,8 @@ describe('FlyerDataTransformer', () => {
);
// Assert
// 1. Check that a warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
`Base URL not provided in job data. Falling back to default local URL: ${expectedFallbackUrl}`,
);
// 1. Check that getBaseUrl was called
expect(getBaseUrl).toHaveBeenCalledWith(mockLogger);
// 2. Check that the URLs were constructed with the fallback
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);

View File

@@ -7,6 +7,7 @@ import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
import { TransformationError } from './processingErrors';
import { parsePriceToCents } from '../utils/priceParser';
import { getBaseUrl } from '../utils/serverUtils';
/**
* This class is responsible for transforming the validated data from the AI service
@@ -62,13 +63,7 @@ export class FlyerDataTransformer {
logger: Logger,
): { imageUrl: string; iconUrl: string } {
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
let finalBaseUrl = baseUrl;
if (!finalBaseUrl) {
const port = process.env.PORT || 3000;
finalBaseUrl = `http://localhost:${port}`;
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
}
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
const finalBaseUrl = baseUrl || getBaseUrl(logger);
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');

View File

@@ -189,7 +189,7 @@ describe('FlyerProcessingService', () => {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
...data,
},
updateProgress: vi.fn(),
@@ -241,7 +241,7 @@ describe('FlyerProcessingService', () => {
'checksum-123', // checksum
undefined, // userId
expect.any(Object), // logger
'http://localhost:3000', // baseUrl
'http://example.com', // baseUrl
);
// 5. DB transaction was initiated
@@ -695,8 +695,8 @@ describe('FlyerProcessingService', () => {
it('should derive paths from DB and delete files if job paths are empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
const mockFlyer = createMockFlyer({
image_url: 'http://localhost:3000/flyer-images/flyer-abc.jpg',
icon_url: 'http://localhost:3000/flyer-images/icons/icon-flyer-abc.webp',
image_url: 'http://example.com/flyer-images/flyer-abc.jpg',
icon_url: 'http://example.com/flyer-images/icons/icon-flyer-abc.webp',
});
// Mock DB call to return a flyer
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);

View File

@@ -7,6 +7,7 @@ import { ValidationError, NotFoundError } from './db/errors.db';
import { DatabaseError } from './processingErrors';
import type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data';
import { getTestBaseUrl } from '../tests/utils/testHelpers';
// Un-mock the service under test to ensure we are testing the real implementation,
// not the global mock from `tests/setup/tests-setup-unit.ts`.
@@ -240,12 +241,12 @@ describe('UserService', () => {
describe('updateUserAvatar', () => {
it('should construct avatar URL and update profile', async () => {
const { logger } = await import('./logger.server');
const testBaseUrl = 'http://localhost:3001';
const testBaseUrl = getTestBaseUrl();
vi.stubEnv('FRONTEND_URL', testBaseUrl);
const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const expectedUrl = `${testBaseUrl}/uploads/avatars/avatar.jpg`;
const expectedUrl = `${testBaseUrl}/uploads/avatars/${file.filename}`;
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import app from '../../../server';
import { getPool } from '../../services/db/connection.db';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
/**
@@ -164,7 +164,7 @@ describe('Admin API Routes Integration Tests', () => {
beforeEach(async () => {
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 'https://example.com/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/asdmin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
// The checksum must be a unique 64-character string to satisfy the DB constraint.
// We generate a dynamic string and pad it to 64 characters.
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],

View File

@@ -9,7 +9,7 @@ import { getPool } from '../../services/db/connection.db';
import { generateFileChecksum } from '../../utils/checksum';
import { logger } from '../../services/logger.server';
import type { UserProfile, ExtractedFlyerItem } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { cleanupFiles } from '../utils/cleanupFiles';
@@ -57,7 +57,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
beforeAll(async () => {
// FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated
// for the database, satisfying the 'url_check' constraint.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
});
// FIX: Reset mocks before each test to ensure isolation.
@@ -130,7 +130,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
.field('checksum', checksum)
// Pass the baseUrl directly in the form data to ensure the worker receives it,
// bypassing issues with vi.stubEnv in multi-threaded test environments.
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.attach('flyerFile', uniqueContent, uniqueFileName);
if (token) {
uploadReq.set('Authorization', `Bearer ${token}`);
@@ -248,7 +248,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
@@ -333,7 +333,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
@@ -399,7 +399,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -451,7 +451,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -505,7 +505,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);

View File

@@ -5,6 +5,7 @@ import { getPool } from '../../services/db/connection.db';
import app from '../../../server';
import type { Flyer, FlyerItem } from '../../types';
import { cleanupDb } from '../utils/cleanup';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -27,7 +28,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 'https://example.com/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
VALUES ($1, 'integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
createdFlyerId = flyerRes.rows[0].flyer_id;

View File

@@ -5,7 +5,7 @@ import app from '../../../server';
import path from 'path';
import fs from 'node:fs/promises';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
import { generateFileChecksum } from '../../utils/checksum';
import * as db from '../../services/db/index.db';
import { cleanupDb } from '../utils/cleanup';
@@ -69,7 +69,7 @@ describe('Gamification Flow Integration Test', () => {
// Stub environment variables for URL generation in the background worker.
// This needs to be in beforeAll to ensure it's set before any code that might use it is imported.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3001');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
mockExtractCoreData.mockResolvedValue({
@@ -253,7 +253,8 @@ describe('Gamification Flow Integration Test', () => {
// 8. Assert that the URLs are fully qualified.
expect(savedFlyer.image_url).to.equal(newFlyer.image_url);
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
expect(newFlyer.image_url).toContain('http://localhost:3001/flyer-images/');
const expectedBaseUrl = getTestBaseUrl();
expect(newFlyer.image_url).toContain(`${expectedBaseUrl}/flyer-images/`);
});
});
});

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import app from '../../../server';
import { getPool } from '../../services/db/connection.db';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -35,21 +36,21 @@ describe('Price History API Integration Test (/api/price-history)', () => {
// 3. Create two flyers with different dates
const flyerRes1 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 'https://example.com/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
VALUES ($1, 'price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
);
flyerId1 = flyerRes1.rows[0].flyer_id;
const flyerRes2 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 'https://example.com/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
VALUES ($1, 'price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
);
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
const flyerRes3 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 'https://example.com/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
VALUES ($1, 'price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
);
flyerId3 = flyerRes3.rows[0].flyer_id;

View File

@@ -14,7 +14,7 @@ import type {
import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -64,7 +64,7 @@ describe('Public API Routes Integration Tests', () => {
testStoreId = storeRes.rows[0].store_id;
const flyerRes = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 'https://example.com/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
VALUES ($1, 'public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
testFlyer = flyerRes.rows[0];

View File

@@ -178,7 +178,7 @@ export const createMockFlyer = (
store_id: overrides.store_id ?? overrides.store?.store_id,
});
const baseUrl = 'http://localhost:3001'; // A reasonable default for tests
const baseUrl = 'http://example.com'; // A reasonable default for tests
// Determine the final file_name to generate dependent properties from.
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;

View File

@@ -5,6 +5,12 @@ import type { UserProfile } from '../../types';
import supertest from 'supertest';
export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
export const TEST_EXAMPLE_DOMAIN = 'https://example.com';
export const getTestBaseUrl = (): string => {
const url = process.env.FRONTEND_URL || `http://example.com`;
return url.endsWith('/') ? url.slice(0, -1) : url;
};
interface CreateUserOptions {
email?: string;

View File

@@ -56,29 +56,21 @@ describe('serverUtils', () => {
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should fall back to localhost with default port 3000 if no URL is provided', () => {
it('should fall back to example.com with default port 3000 if no URL is provided', () => {
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
delete process.env.PORT;
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000');
expect(baseUrl).toBe('http://example.com:3000');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should fall back to localhost with the specified PORT if no URL is provided', () => {
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
process.env.PORT = '8888';
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:8888');
});
it('should log a warning and fall back if FRONTEND_URL is invalid (does not start with http)', () => {
process.env.FRONTEND_URL = 'invalid.url.com';
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000');
expect(baseUrl).toBe('http://example.com');
expect(mockLogger.warn).toHaveBeenCalledWith(
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: http://localhost:3000",
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: http://example.com",
);
});
});

View File

@@ -14,7 +14,7 @@ export function getBaseUrl(logger: Logger): string {
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
if (!baseUrl || !baseUrl.startsWith('http')) {
const port = process.env.PORT || 3000;
const fallbackUrl = `http://localhost:${port}`;
const fallbackUrl = `http://example.com:${port}`;
if (baseUrl) {
logger.warn(
`[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,