Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c1161587 | ||
| 0010396780 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -165,6 +165,38 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow registration with an empty string for avatar_url', async () => {
|
||||
// Arrange
|
||||
const email = 'avatar-user@test.com';
|
||||
const mockNewUser = createMockUserProfile({
|
||||
user: { user_id: 'avatar-user-id', email },
|
||||
});
|
||||
mockedAuthService.registerAndLoginUser.mockResolvedValue({
|
||||
newUserProfile: mockNewUser,
|
||||
accessToken: 'avatar-access-token',
|
||||
refreshToken: 'avatar-refresh-token',
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/auth/register').send({
|
||||
email,
|
||||
password: strongPassword,
|
||||
full_name: 'Avatar User',
|
||||
avatar_url: '', // Send an empty string
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.message).toBe('User registered successfully!');
|
||||
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
|
||||
email,
|
||||
strongPassword,
|
||||
'Avatar User',
|
||||
undefined, // The preprocess step in the Zod schema should convert '' to undefined
|
||||
mockLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set a refresh token cookie on successful registration', async () => {
|
||||
const mockNewUser = createMockUserProfile({
|
||||
user: { user_id: 'new-user-id', email: 'cookie@test.com' },
|
||||
|
||||
@@ -51,7 +51,11 @@ const registerSchema = z.object({
|
||||
}),
|
||||
// Sanitize optional string inputs.
|
||||
full_name: z.string().trim().optional(),
|
||||
avatar_url: z.string().trim().url().optional(),
|
||||
// Allow empty string or valid URL. If empty string is received, convert to undefined.
|
||||
avatar_url: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().trim().url().optional(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -585,6 +585,27 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
});
|
||||
|
||||
it('should allow updating the profile with an empty string for avatar_url', async () => {
|
||||
// Arrange
|
||||
const profileUpdates = { avatar_url: '' };
|
||||
// The service should receive `undefined` after Zod preprocessing
|
||||
const updatedProfile = createMockUserProfile({ ...mockUserProfile, avatar_url: undefined });
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedProfile);
|
||||
// Verify that the Zod schema preprocessed the empty string to undefined
|
||||
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
|
||||
mockUserProfile.user.user_id,
|
||||
{ avatar_url: undefined },
|
||||
expectLogger,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 500 on a generic database error', async () => {
|
||||
const dbError = new Error('DB Connection Failed');
|
||||
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);
|
||||
|
||||
@@ -26,7 +26,13 @@ const router = express.Router();
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
body: z
|
||||
.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() })
|
||||
.object({
|
||||
full_name: z.string().optional(),
|
||||
avatar_url: z.preprocess(
|
||||
(val) => (val === '' ? undefined : val),
|
||||
z.string().trim().url().optional(),
|
||||
),
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: 'At least one field to update must be provided.',
|
||||
}),
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
|
||||
// Import the class, not the singleton instance, so we can instantiate it with mocks.
|
||||
import {
|
||||
AIService,
|
||||
AiFlyerDataSchema,
|
||||
aiService as aiServiceSingleton,
|
||||
DuplicateFlyerError,
|
||||
type RawFlyerItem,
|
||||
} from './aiService.server';
|
||||
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
|
||||
import { ValidationError } from './db/errors.db';
|
||||
import { AiFlyerDataSchema } from '../types/ai';
|
||||
|
||||
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
|
||||
vi.mock('./logger.server', () => ({
|
||||
@@ -1058,4 +1059,56 @@ describe('AI Service (Server)', () => {
|
||||
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_normalizeExtractedItems (private method)', () => {
|
||||
it('should correctly normalize items with null or undefined price_in_cents', () => {
|
||||
const rawItems: RawFlyerItem[] = [
|
||||
{
|
||||
item: 'Valid Item',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: '1',
|
||||
category_name: 'Category A',
|
||||
master_item_id: 1,
|
||||
},
|
||||
{
|
||||
item: 'Item with Null Price',
|
||||
price_display: null,
|
||||
price_in_cents: null, // Test case for null
|
||||
quantity: '1',
|
||||
category_name: 'Category B',
|
||||
master_item_id: 2,
|
||||
},
|
||||
{
|
||||
item: 'Item with Undefined Price',
|
||||
price_display: '$2.99',
|
||||
price_in_cents: undefined, // Test case for undefined
|
||||
quantity: '1',
|
||||
category_name: 'Category C',
|
||||
master_item_id: 3,
|
||||
},
|
||||
{
|
||||
item: null, // Test null item name
|
||||
price_display: undefined, // Test undefined display price
|
||||
price_in_cents: 50,
|
||||
quantity: null, // Test null quantity
|
||||
category_name: undefined, // Test undefined category
|
||||
master_item_id: null, // Test null master_item_id
|
||||
},
|
||||
];
|
||||
|
||||
// Access the private method for testing
|
||||
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems);
|
||||
|
||||
expect(normalized).toHaveLength(4);
|
||||
expect(normalized[0].price_in_cents).toBe(199);
|
||||
expect(normalized[1].price_in_cents).toBe(null); // null should remain null
|
||||
expect(normalized[2].price_in_cents).toBe(null); // undefined should become null
|
||||
expect(normalized[3].item).toBe('Unknown Item');
|
||||
expect(normalized[3].quantity).toBe('');
|
||||
expect(normalized[3].category_name).toBe('Other/Miscellaneous');
|
||||
expect(normalized[3].master_item_id).toBeUndefined(); // nullish coalescing to undefined
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code.
|
||||
* The `.server.ts` naming convention helps enforce this separation.
|
||||
*/
|
||||
|
||||
import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import type { Logger } from 'pino';
|
||||
@@ -26,29 +25,11 @@ import type { Job } from 'bullmq';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
import path from 'path';
|
||||
import { ValidationError } from './db/errors.db';
|
||||
|
||||
// Helper for consistent required string validation (handles missing/null/empty)
|
||||
const requiredString = (message: string) =>
|
||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
||||
|
||||
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
|
||||
const ExtractedFlyerItemSchema = z.object({
|
||||
item: z.string(),
|
||||
price_display: z.string(),
|
||||
price_in_cents: z.number().nullable(),
|
||||
quantity: z.string(),
|
||||
category_name: z.string(),
|
||||
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
|
||||
});
|
||||
|
||||
export const AiFlyerDataSchema = z.object({
|
||||
store_name: z.string().nullable(),
|
||||
valid_from: z.string().nullable(),
|
||||
valid_to: z.string().nullable(),
|
||||
store_address: z.string().nullable(),
|
||||
items: z.array(ExtractedFlyerItemSchema),
|
||||
});
|
||||
import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
|
||||
import {
|
||||
AiFlyerDataSchema,
|
||||
ExtractedFlyerItemSchema,
|
||||
} from '../types/ai'; // Import consolidated schemas
|
||||
|
||||
interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
|
||||
checksum?: string;
|
||||
@@ -89,10 +70,10 @@ interface IAiClient {
|
||||
* This type is intentionally loose to accommodate potential null/undefined values
|
||||
* from the AI before they are cleaned and normalized.
|
||||
*/
|
||||
type RawFlyerItem = {
|
||||
item: string;
|
||||
export type RawFlyerItem = {
|
||||
item: string | null;
|
||||
price_display: string | null | undefined;
|
||||
price_in_cents: number | null;
|
||||
price_in_cents: number | null | undefined;
|
||||
quantity: string | null | undefined;
|
||||
category_name: string | null | undefined;
|
||||
master_item_id?: number | null | undefined;
|
||||
@@ -606,6 +587,8 @@ export class AIService {
|
||||
item.category_name === null || item.category_name === undefined
|
||||
? 'Other/Miscellaneous'
|
||||
: String(item.category_name),
|
||||
// Ensure undefined is converted to null to match the Zod schema.
|
||||
price_in_cents: item.price_in_cents ?? null,
|
||||
master_item_id: item.master_item_id ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FlyerAiProcessor } from './flyerAiProcessor.server';
|
||||
import { AiDataValidationError } from './processingErrors';
|
||||
import { logger } from './logger.server';
|
||||
import { logger } from './logger.server'; // Keep this import for the logger instance
|
||||
import type { AIService } from './aiService.server';
|
||||
import type { PersonalizationRepository } from './db/personalization.db';
|
||||
import type { FlyerJobData } from '../types/job-data';
|
||||
|
||||
@@ -5,28 +5,11 @@ import type { AIService } from './aiService.server';
|
||||
import type { PersonalizationRepository } from './db/personalization.db';
|
||||
import { AiDataValidationError } from './processingErrors';
|
||||
import type { FlyerJobData } from '../types/job-data';
|
||||
|
||||
// Helper for consistent required string validation (handles missing/null/empty)
|
||||
const requiredString = (message: string) =>
|
||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
||||
|
||||
// --- Zod Schemas for AI Response Validation ---
|
||||
const ExtractedFlyerItemSchema = z.object({
|
||||
item: z.string().nullable(),
|
||||
price_display: z.string().nullable(),
|
||||
price_in_cents: z.number().nullable(),
|
||||
quantity: z.string().nullable(),
|
||||
category_name: z.string().nullable(),
|
||||
master_item_id: z.number().nullish(),
|
||||
});
|
||||
|
||||
export const AiFlyerDataSchema = z.object({
|
||||
store_name: z.string().nullable(),
|
||||
valid_from: z.string().nullable(),
|
||||
valid_to: z.string().nullable(),
|
||||
store_address: z.string().nullable(),
|
||||
items: z.array(ExtractedFlyerItemSchema),
|
||||
});
|
||||
import {
|
||||
AiFlyerDataSchema,
|
||||
ExtractedFlyerItemSchema,
|
||||
requiredString,
|
||||
} from '../types/ai'; // Import consolidated schemas and helper
|
||||
|
||||
export type ValidatedAiDataType = z.infer<typeof AiFlyerDataSchema>;
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import path from 'path';
|
||||
import type { z } from 'zod';
|
||||
import type { Logger } from 'pino';
|
||||
import type { FlyerInsert, FlyerItemInsert, FlyerStatus } from '../types';
|
||||
import type { AiFlyerDataSchema, AiProcessorResult } from './flyerAiProcessor.server';
|
||||
import type { FlyerInsert, FlyerItemInsert } from '../types';
|
||||
import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult
|
||||
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,16 +21,18 @@ const request = supertest(app);
|
||||
describe('Authentication API Integration', () => {
|
||||
let testUserEmail: string;
|
||||
let testUser: UserProfile;
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Use a unique email for this test suite to prevent collisions with other tests.
|
||||
const email = `auth-integration-test-${Date.now()}@example.com`;
|
||||
({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request }));
|
||||
testUserEmail = testUser.user.email;
|
||||
createdUserIds.push(testUser.user.user_id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupDb({ userIds: testUser ? [testUser.user.user_id] : [] });
|
||||
await cleanupDb({ userIds: createdUserIds });
|
||||
});
|
||||
|
||||
// This test migrates the logic from the old DevTestRunner.tsx component.
|
||||
@@ -83,6 +85,38 @@ describe('Authentication API Integration', () => {
|
||||
expect(errorData.message).toBe('Incorrect email or password.');
|
||||
});
|
||||
|
||||
it('should allow registration with an empty string for avatar_url and save it as null', async () => {
|
||||
// Arrange: Define user data with an empty avatar_url.
|
||||
const email = `empty-avatar-user-${Date.now()}@example.com`;
|
||||
const userData = {
|
||||
email,
|
||||
password: TEST_PASSWORD,
|
||||
full_name: 'Empty Avatar',
|
||||
avatar_url: '',
|
||||
};
|
||||
|
||||
// Act: Register the new user.
|
||||
const registerResponse = await request.post('/api/auth/register').send(userData);
|
||||
|
||||
// Assert 1: Check that the registration was successful and the returned profile is correct.
|
||||
expect(registerResponse.status).toBe(201);
|
||||
const registeredProfile = registerResponse.body.userprofile;
|
||||
const registeredToken = registerResponse.body.token;
|
||||
expect(registeredProfile.user.email).toBe(email);
|
||||
expect(registeredProfile.avatar_url).toBeNull(); // The API should return null for the avatar_url.
|
||||
|
||||
// Add the newly created user's ID to the array for cleanup in afterAll.
|
||||
createdUserIds.push(registeredProfile.user.user_id);
|
||||
|
||||
// Assert 2 (Verification): Fetch the profile using the new token to confirm the value in the DB is null.
|
||||
const profileResponse = await request
|
||||
.get('/api/users/profile')
|
||||
.set('Authorization', `Bearer ${registeredToken}`);
|
||||
|
||||
expect(profileResponse.status).toBe(200);
|
||||
expect(profileResponse.body.avatar_url).toBeNull();
|
||||
});
|
||||
|
||||
it('should successfully refresh an access token using a refresh token cookie', async () => {
|
||||
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
|
||||
// This ensures the test is self-contained and not affected by other tests.
|
||||
|
||||
@@ -75,6 +75,32 @@ describe('User API Routes Integration Tests', () => {
|
||||
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
||||
});
|
||||
|
||||
it('should allow updating the profile with an empty string for avatar_url', async () => {
|
||||
// Arrange: Define the profile updates.
|
||||
const profileUpdates = {
|
||||
full_name: 'Empty Avatar User',
|
||||
avatar_url: '',
|
||||
};
|
||||
|
||||
// Act: Call the update endpoint with the new data and the auth token.
|
||||
const response = await request
|
||||
.put('/api/users/profile')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(profileUpdates);
|
||||
const updatedProfile = response.body;
|
||||
|
||||
// Assert: Check that the returned profile reflects the changes.
|
||||
expect(response.status).toBe(200);
|
||||
expect(updatedProfile.full_name).toBe('Empty Avatar User');
|
||||
expect(updatedProfile.avatar_url).toBeNull();
|
||||
|
||||
// Also, fetch the profile again to ensure the change was persisted in the database as NULL.
|
||||
const refetchResponse = await request
|
||||
.get('/api/users/profile')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
expect(refetchResponse.body.avatar_url).toBeNull();
|
||||
});
|
||||
|
||||
it('should update user preferences via PUT /api/users/profile/preferences', async () => {
|
||||
// Arrange: Define the preference updates.
|
||||
const preferenceUpdates = {
|
||||
|
||||
29
src/types/ai.ts
Normal file
29
src/types/ai.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/types/ai.ts
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// Helper for consistent required string validation (handles missing/null/empty)
|
||||
// This is moved here as it's directly related to the schemas.
|
||||
export const requiredString = (message: string) =>
|
||||
z.preprocess((val) => val ?? '', z.string().min(1, message));
|
||||
|
||||
// --- Zod Schemas for AI Response Validation ---
|
||||
// These schemas define the expected structure of data returned by the AI.
|
||||
// They are used for validation and type inference across multiple services.
|
||||
|
||||
export const ExtractedFlyerItemSchema = z.object({
|
||||
item: z.string().nullable(),
|
||||
price_display: z.string().nullable(),
|
||||
price_in_cents: z.number().nullable(),
|
||||
quantity: z.string().nullable(),
|
||||
category_name: z.string().nullable(),
|
||||
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
|
||||
});
|
||||
|
||||
export const AiFlyerDataSchema = z.object({
|
||||
store_name: z.string().nullable(),
|
||||
valid_from: z.string().nullable(),
|
||||
valid_to: z.string().nullable(),
|
||||
store_address: z.string().nullable(),
|
||||
items: z.array(ExtractedFlyerItemSchema),
|
||||
});
|
||||
Reference in New Issue
Block a user