unit test fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 8m50s

This commit is contained in:
2025-12-11 00:53:24 -08:00
parent 99d0dba296
commit 6aa72dd90b
28 changed files with 204 additions and 142 deletions

View File

@@ -22,7 +22,7 @@ import gamificationRouter from './src/routes/gamification.routes';
import systemRouter from './src/routes/system.routes';
import healthRouter from './src/routes/health.routes';
import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService.ts';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
import { analyticsQueue, weeklyAnalyticsQueue, gracefulShutdown } from './src/services/queueService.server';
// --- START DEBUG LOGGING ---

View File

@@ -874,12 +874,12 @@ CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_it
-- 54. Store schema metadata to detect changes during deployment.
CREATE TABLE IF NOT EXISTS public.schema_info (
id INT PRIMARY KEY DEFAULT 1,
environment TEXT PRIMARY KEY,
schema_hash TEXT NOT NULL,
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT single_row_check CHECK (id = 1)
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.schema_info IS 'Stores metadata about the deployed schema, such as a hash of the schema file, to detect changes.';
COMMENT ON COLUMN public.schema_info.environment IS 'The deployment environment (e.g., ''development'', ''test'', ''production'').';
COMMENT ON COLUMN public.schema_info.schema_hash IS 'A SHA-256 hash of the master_schema_rollup.sql file at the time of deployment.';
-- 55. Store user reactions to various entities (e.g., recipes, comments).

View File

@@ -893,12 +893,12 @@ CREATE INDEX IF NOT EXISTS idx_receipt_items_master_item_id ON public.receipt_it
-- 54. Store schema metadata to detect changes during deployment.
CREATE TABLE IF NOT EXISTS public.schema_info (
id INT PRIMARY KEY DEFAULT 1,
environment TEXT PRIMARY KEY,
schema_hash TEXT NOT NULL,
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL,
CONSTRAINT single_row_check CHECK (id = 1)
deployed_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.schema_info IS 'Stores metadata about the deployed schema, such as a hash of the schema file, to detect changes.';
COMMENT ON COLUMN public.schema_info.environment IS 'The deployment environment (e.g., ''development'', ''test'', ''production'').';
COMMENT ON COLUMN public.schema_info.schema_hash IS 'A SHA-256 hash of the master_schema_rollup.sql file at the time of deployment.';
-- 55. Store user reactions to various entities (e.g., recipes, comments).
@@ -2610,4 +2610,3 @@ BEGIN
bp.price_rank = 1;
END;
$$ LANGUAGE plpgsql;

View File

@@ -41,6 +41,14 @@ vi.mock('pdfjs-dist', () => ({
})),
}));
// Mock the new config module
vi.mock('./config', () => ({
default: {
app: { version: '1.0.0', commitMessage: 'Initial commit', commitUrl: '#' },
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
const mockedApiClient = vi.mocked(apiClient);
@@ -369,17 +377,16 @@ describe('App Component', () => {
describe('Version and "What\'s New" Modal', () => {
it('should show the "What\'s New" modal if the app version is new', async () => {
// Mock the import.meta.env properties
Object.defineProperty(import.meta, 'env', {
value: {
VITE_APP_VERSION: '1.0.1',
VITE_APP_COMMIT_MESSAGE: 'New feature!',
// Mock the config module for this specific test
vi.mock('./config', () => ({
default: {
app: { version: '1.0.1', commitMessage: 'New feature!', commitUrl: '#' },
google: { mapsEmbedApiKey: 'mock-key' },
},
configurable: true,
});
}));
localStorageMock.setItem('lastSeenVersion', '1.0.0');
renderApp();
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument();
});
});
@@ -458,29 +465,31 @@ describe('App Component', () => {
describe('Version Display and What\'s New', () => {
const mockEnv = {
VITE_APP_VERSION: '2.0.0',
VITE_APP_COMMIT_MESSAGE: 'A new version!',
VITE_APP_COMMIT_URL: 'http://example.com/commit/2.0.0',
};
beforeEach(() => {
Object.defineProperty(import.meta, 'env', {
value: mockEnv,
configurable: true,
});
// Also mock the config module to reflect this change
vi.mock('./config', () => ({
default: {
app: { version: '2.0.0', commitMessage: 'A new version!', commitUrl: 'http://example.com/commit/2.0.0' },
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
});
it('should display the version number and commit link', () => {
renderApp();
const versionLink = screen.getByText(`Version: ${mockEnv.VITE_APP_VERSION}`);
const versionLink = screen.getByText(`Version: 2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', mockEnv.VITE_APP_COMMIT_URL);
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
renderApp();
expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument();
const openButton = screen.getByTitle("Show what's new in this version");
const openButton = await screen.findByTitle("Show what's new in this version");
fireEvent.click(openButton);
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
@@ -527,7 +536,13 @@ describe('App Component', () => {
renderApp();
fireEvent.click(screen.getByText('Open Profile'));
fireEvent.click(await screen.findByText('Login'));
const loginButton = await screen.findByText('Login');
fireEvent.click(loginButton);
// We need to wait for the async login function to be called and reject.
await waitFor(() => {
expect(mockLogin).toHaveBeenCalled();
});
});
});
});

View File

@@ -20,6 +20,7 @@ import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIco
import { useAuth } from './hooks/useAuth';
import { useData } from './hooks/useData';
import { MainLayout } from './layouts/MainLayout';
import config from './config';
import { HomePage } from './pages/HomePage';
// pdf.js worker configuration
@@ -171,9 +172,8 @@ function App() {
// Read the application version injected at build time.
// This will only be available in the production build, not during local development.
const appVersion = import.meta.env.VITE_APP_VERSION;
const commitMessage = import.meta.env.VITE_APP_COMMIT_MESSAGE;
const commitUrl = import.meta.env.VITE_APP_COMMIT_URL;
const appVersion = config.app.version;
const commitMessage = config.app.commitMessage;
useEffect(() => {
if (appVersion) {
logger.info(`Application version: ${appVersion}`);
@@ -274,7 +274,7 @@ function App() {
{appVersion && (
<div className="fixed bottom-2 left-3 z-50 flex items-center space-x-2">
<a
href={commitUrl || '#'}
href={config.app.commitUrl || '#'}
target="_blank"
rel="noopener noreferrer"
title="View commit details on Gitea"

View File

@@ -1,8 +1,14 @@
// src/components/MapView.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MapView } from './MapView';
import config from '../config';
// Mock the new config module
vi.mock('../config', () => ({
default: { google: { mapsEmbedApiKey: undefined } },
}));
describe('MapView', () => {
const defaultProps = {
@@ -10,18 +16,10 @@ describe('MapView', () => {
longitude: -74.0060,
};
// Store the original import.meta.env to restore it after tests
const originalEnv = import.meta.env;
afterEach(() => {
// Restore original environment variables to avoid side-effects between tests
vi.stubGlobal('import', { meta: { env: originalEnv } });
});
describe('when API key is not configured', () => {
beforeEach(() => {
// Mock the environment variable to be undefined for this test suite
vi.stubGlobal('import', { meta: { env: { VITE_GOOGLE_MAPS_EMBED_API_KEY: undefined } } });
// Reset the mock to its default (undefined key)
(vi.mocked(config).google.mapsEmbedApiKey as string | undefined) = undefined;
});
it('should render a disabled message', () => {
@@ -39,8 +37,8 @@ describe('MapView', () => {
const mockApiKey = 'test-api-key';
beforeEach(() => {
// Mock the environment variable to have a value for this test suite
vi.stubGlobal('import', { meta: { env: { VITE_GOOGLE_MAPS_EMBED_API_KEY: mockApiKey } } });
// Set the API key for this test suite
(vi.mocked(config).google.mapsEmbedApiKey as string | undefined) = mockApiKey;
});
it('should render the iframe with the correct src URL', () => {

View File

@@ -1,12 +1,13 @@
// src/components/MapView.tsx
import React from 'react';
import config from '../config';
interface MapViewProps {
latitude: number;
longitude: number;
}
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_EMBED_API_KEY;
const apiKey = config.google.mapsEmbedApiKey;
export const MapView: React.FC<MapViewProps> = ({ latitude, longitude }) => {
if (!apiKey) {

19
src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
// src/config.ts
/**
* A centralized configuration module that reads environment variables
* from `import.meta.env`. This provides a single, explicit place to manage
* environment-specific settings and makes mocking for tests significantly easier.
*/
const config = {
app: {
version: import.meta.env.VITE_APP_VERSION,
commitMessage: import.meta.env.VITE_APP_COMMIT_MESSAGE,
commitUrl: import.meta.env.VITE_APP_COMMIT_URL,
},
google: {
mapsEmbedApiKey: import.meta.env.VITE_GOOGLE_MAPS_EMBED_API_KEY,
},
};
export default config;

View File

@@ -75,12 +75,14 @@ describe('BulkImporter', () => {
it('should call onFilesChange when files are dropped', () => {
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
const dropzone = screen.getByLabelText(/click to upload/i);
// The drop event handlers are on the <label>, not the <input>
const dropzone = screen.getByText(/click to upload/i).closest('label')!;
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
items: [file], // Also mock the 'items' property for full DataTransfer simulation
},
});
@@ -90,7 +92,8 @@ describe('BulkImporter', () => {
it('should not call onFilesChange if no files are dropped', () => {
render(<BulkImporter onFilesChange={mockOnFilesChange} isProcessing={false} />);
const dropzone = screen.getByLabelText(/click to upload/i);
// The drop event handlers are on the <label>, not the <input>
const dropzone = screen.getByText(/click to upload/i).closest('label')!;
fireEvent.drop(dropzone, {
dataTransfer: {
@@ -138,7 +141,8 @@ describe('BulkImporter', () => {
// Check that a generic document icon is rendered for the PDF
const pdfPreviewContainer = screen.getByText('document.pdf').closest('div.relative') as HTMLElement;
expect(within(pdfPreviewContainer!).getByTestId('document-text-icon')).toBeInTheDocument();
// The icon itself doesn't have a test-id, but we can find it by its role and class.
expect(pdfPreviewContainer.querySelector('svg.w-12.h-12')).toBeInTheDocument();
});
it('should allow removing a file from the preview list', async () => {

View File

@@ -43,10 +43,7 @@ const renderComponent = (onProcessingComplete = vi.fn()) => {
};
describe('FlyerUploader', { timeout: 20000 }, () => {
beforeEach(() => {
// FIX: Do not enable fake timers globally. It causes waitFor to hang in non-polling tests.
// vi.useFakeTimers();
vi.clearAllMocks();
beforeEach(() => { vi.clearAllMocks();
// Access the mock implementation directly from the mocked module.
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
@@ -57,7 +54,7 @@ describe('FlyerUploader', { timeout: 20000 }, () => {
afterEach(() => {
// Restore real timers after each test to avoid side effects.
vi.useRealTimers();
vi.useRealTimers(); // Ensure timers are reset after each test
});
it('should render the initial state correctly', () => {
@@ -139,6 +136,9 @@ describe('FlyerUploader', { timeout: 20000 }, () => {
await vi.runAllTimersAsync();
});
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
// Clean up fake timers for this test
vi.useRealTimers();
});
it('should handle a failed job', async () => {
@@ -218,5 +218,8 @@ describe('FlyerUploader', { timeout: 20000 }, () => {
expect(screen.getByText('Select a flyer (PDF or image) to begin.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Stop Watching Progress' })).not.toBeInTheDocument();
});
// Clean up fake timers for this test
vi.useRealTimers();
});
});

View File

@@ -47,28 +47,28 @@ describe('ProcessingStatus', () => {
// Completed stage
const completedStageText = screen.getByTestId('stage-text-0');
expect(completedStageText).toHaveClass('text-gray-700');
expect(completedStageText.className).toContain('text-gray-700');
expect(screen.getByTestId('stage-icon-0').querySelector('svg')).toHaveClass('text-green-500');
// In-progress stage`
const inProgressStageText = screen.getByTestId('stage-text-1');
expect(inProgressStageText).toHaveClass('text-brand-primary');
expect(inProgressStageText.className).toContain('text-brand-primary');
expect(screen.getByTestId('stage-icon-1').querySelector('svg')).toHaveClass('animate-spin');
// Pending stage
const pendingStageText = screen.getByTestId('stage-text-2');
expect(pendingStageText).toHaveClass('text-gray-400');
expect(pendingStageText.className).toContain('text-gray-400');
expect(screen.getByTestId('stage-icon-2').querySelector('div')).toHaveClass('border-gray-400');
// Non-critical error stage
const nonCriticalErrorStageText = screen.getByTestId('stage-text-3');
expect(nonCriticalErrorStageText).toHaveClass('text-yellow-600');
expect(nonCriticalErrorStageText.className).toContain('text-yellow-600');
expect(screen.getByTestId('stage-icon-3').querySelector('svg')).toHaveClass('text-yellow-500');
expect(screen.getByText(/optional/i)).toBeInTheDocument();
// Critical error stage
const criticalErrorStageText = screen.getByTestId('stage-text-4');
expect(criticalErrorStageText).toHaveClass('text-red-500');
expect(criticalErrorStageText.className).toContain('text-red-500');
expect(screen.getByTestId('stage-icon-4').querySelector('svg')).toHaveClass('text-red-500');
});

View File

@@ -121,10 +121,12 @@ describe('useAuth Hook and AuthProvider', () => {
} as Response);
const { result } = renderHook(() => useAuth(), { wrapper });
// Wait for initial check to complete
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
// Wait for the initial "Determining..." state to pass
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await result.current.login(mockUser, 'new-valid-token');
});

View File

@@ -75,7 +75,7 @@ describe('MainLayout Component', () => {
updateProfile: vi.fn(),
});
mockedUseData.mockReturnValue({
flyers: [{ flyer_id: 1, file_name: 'flyer.jpg' } as any],
flyers: [{ flyer_id: 1, file_name: 'flyer.jpg' }],
masterItems: [],
watchedItems: [],
shoppingLists: [],
@@ -84,7 +84,7 @@ describe('MainLayout Component', () => {
refetchFlyers: vi.fn(),
isLoading: false,
error: null,
});
} as any);
mockedUseShoppingLists.mockReturnValue({
shoppingLists: [],
activeListId: null,
@@ -95,19 +95,19 @@ describe('MainLayout Component', () => {
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
error: null,
});
} as any);
mockedUseWatchedItems.mockReturnValue({
watchedItems: [],
addWatchedItem: vi.fn(),
removeWatchedItem: vi.fn(),
error: null,
});
} as any);
mockedUseActiveDeals.mockReturnValue({
activeDeals: [],
totalActiveItems: 0,
isLoading: false,
error: null,
});
error: null
} as any);
});
const defaultProps = {
@@ -137,7 +137,7 @@ describe('MainLayout Component', () => {
});
it('does not show the AnonymousUserBanner if there are no flyers', () => {
mockedUseData.mockReturnValueOnce({ ...mockedUseData(), flyers: [] } as any);
mockedUseData.mockReturnValueOnce({ ...mockedUseData.mock.results[0].value, flyers: [] });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.queryByTestId('anonymous-banner')).not.toBeInTheDocument();
});

View File

@@ -180,16 +180,15 @@ describe('ProfileManager Authentication Flows', () => {
// 2. Fill out all fields in the form
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Test User' } });
fireEvent.change(screen.getByLabelText(/avatar url/i), { target: { value: 'http://example.com/new.png' } });
fireEvent.change(screen.getByLabelText(/^Email Address$/i), { target: { value: 'newuser@test.com' } });
fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'newsecurepassword' } });
// 3. Submit the registration form
fireEvent.submit(screen.getByTestId('auth-form'));
// 4. Assert that the correct functions were called with the correct data
// 4. Assert that the correct functions were called with the correct data (without avatar_url)
await waitFor(() => {
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', 'New Test User', 'http://example.com/new.png');
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', 'New Test User', '');
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
{ user_id: '123', email: 'test@example.com' },
'mock-token',

View File

@@ -1,6 +1,6 @@
// src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes.ts';
import passport from './passport.routes';
import multer from 'multer';
import path from 'path';
import fs from 'node:fs/promises';

View File

@@ -72,10 +72,9 @@ describe('Budget DB Service', () => {
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // INSERT...RETURNING
.mockResolvedValueOnce({ rows: [] }) // award_achievement
.mockResolvedValueOnce({ rows: [] }); // COMMIT
.mockResolvedValueOnce({ rows: [] }) // For BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // For the INSERT...RETURNING
.mockResolvedValueOnce({ rows: [] }); // For both award_achievement and COMMIT
const result = await budgetRepo.createBudget('user-123', budgetData);

View File

@@ -204,6 +204,7 @@ describe('Flyer DB Service', () => {
// Mock the sequence of calls within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }) // insertFlyerItems
.mockResolvedValueOnce({ rows: [] }); // COMMIT

View File

@@ -338,11 +338,10 @@ describe('Personalization DB Service', () => {
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
// FIX: Make assertion robust for array parameters
expect(mockQuery).toHaveBeenNthCalledWith(3,
// The implementation uses unnest, so it's one call with an array parameter
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.user_dietary_restrictions'),
['user-123', [1, 2]]
);
['user-123', [1, 2]]);
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});

View File

@@ -196,7 +196,10 @@ describe('Recipe DB Service', () => {
const updates = { name: 'Updated Recipe', description: 'New desc' };
const result = await recipeRepo.updateRecipe(1, 'user-123', updates);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['Updated Recipe', 'New desc', 1, 'user-123']);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.recipes'),
['Updated Recipe', 'New desc', 1, 'user-123']
);
expect(result).toEqual(mockRecipe);
});

View File

@@ -423,7 +423,6 @@ describe('Shopping DB Service', () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query.mockResolvedValue({ rows: [] });
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await shoppingRepo.processReceiptItems(1, items);
@@ -431,14 +430,7 @@ describe('Shopping DB Service', () => {
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT public.process_receipt_items($1, $2, $3)',
[
1,
// The order of keys in the stringified JSON is not guaranteed.
// Instead, we'll parse the JSON string from the mock call and check its contents.
expect.stringContaining('"raw_item_description":"Milk"'),
expect.stringContaining('"raw_item_description":"Milk"'),
]
'SELECT public.process_receipt_items($1, $2, $3)', [1, JSON.stringify(expectedItemsWithQuantity), JSON.stringify(expectedItemsWithQuantity)]
);
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
@@ -452,14 +444,7 @@ describe('Shopping DB Service', () => {
const items = [{ raw_item_description: 'Bag', price_paid_cents: 0 }];
await shoppingRepo.processReceiptItems(1, items);
const expectedItems = [{ raw_item_description: 'Bag', price_paid_cents: 0, quantity: 1 }];
const call = mockClient.query.mock.calls.find(c => c[0].includes('process_receipt_items'));
expect(call).toBeDefined();
const passedJson = JSON.parse(call![1]);
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT public.process_receipt_items($1, $2, $3)',
[1, expect.any(String), expect.any(String)]
);
expect(passedJson).toEqual(expect.arrayContaining([expect.objectContaining(expectedItems[0])]));
expect(mockClient.query).toHaveBeenCalledWith('SELECT public.process_receipt_items($1, $2, $3)', [1, JSON.stringify(expectedItems), JSON.stringify(expectedItems)]);
});
it('should update receipt status to "failed" on error', async () => {
@@ -474,7 +459,7 @@ describe('Shopping DB Service', () => {
await expect(shoppingRepo.processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
// Verify that the status was updated to 'failed' in the catch block
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [1]);
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [1]);
});
// Note: The `processReceiptItems` method in shopping.db.ts has a potential bug where it calls `client.query('ROLLBACK')`

View File

@@ -94,10 +94,9 @@ describe('User DB Service', () => {
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }) // SELECT profile
.mockResolvedValueOnce({ rows: [] }); // COMMIT
.mockResolvedValueOnce({ rows: [] }) // COMMIT;
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
@@ -113,13 +112,11 @@ describe('User DB Service', () => {
// Arrange: Mock the user insert query to fail after BEGIN and set_config
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(new Error('User insert failed')); // INSERT fails
// Act & Assert
// FIX: The repo now throws "Failed to create user in database." instead of the original error.
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('User insert failed');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); // This will be called inside the try block
// The createUser function now throws the original error, so we check for that.
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
@@ -132,13 +129,10 @@ describe('User DB Service', () => {
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config in trigger
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(new Error('Profile fetch failed')); // SELECT profile fails
// FIX: The repo wraps the error, so expect the wrapped message or generic failure.
// Based on implementation: "Failed to create user in database."
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Failed to create user in database.');
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Profile fetch failed');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
@@ -157,7 +151,6 @@ describe('User DB Service', () => {
// 4. ROLLBACK (success)
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockRejectedValueOnce(dbError) // INSERT fails
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK

View File

@@ -1,4 +1,8 @@
// src/utils/checksum.test.ts
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { generateFileChecksum } from './checksum';

View File

@@ -1,4 +1,8 @@
// src/utils/pdfConverter.test.ts
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { convertPdfToImageFiles } from './pdfConverter';

13
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
// src/vite-env.d.ts
interface ImportMetaEnv {
readonly VITE_APP_VERSION: string;
readonly VITE_APP_COMMIT_MESSAGE: string;
readonly VITE_APP_COMMIT_URL: string;
readonly VITE_GOOGLE_MAPS_EMBED_API_KEY: string;
// Add any other environment variables you use here
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,35 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"typeRoots": ["./node_modules/@types", "./src/types"],
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"types": [
"node",
"vite/client", // Add this line to include Vite's client-side types
],
"moduleResolution": "bundler",
"esModuleInterop": true, // Often helpful for broader library compatibility
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"
// This line makes Vitest's global APIs (describe, it, expect) available everywhere
// without needing to import them.
"types": [
"vitest/globals"
]
},
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true, // Enforcing strict mode is a best practice for new projects.
"forceConsistentCasingInFileNames": true // Helps prevent casing-related import errors.
},
"include": ["src", ".vitepress", "vite.config.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist", "coverage"]
// This is the most important part: It tells TypeScript to include ALL files
// within the 'src' directory, including your new 'vite-env.d.ts' file.
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

13
tsconfig.node.json Normal file
View File

@@ -0,0 +1,13 @@
// tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler", // Changed from "Node"
"allowSyntheticDefaultImports": true,
"strict": true, // It's good practice to keep tooling config strict
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts", "vitest.config.integration.ts", "vitest.workspace.ts"]
}

View File

@@ -38,10 +38,12 @@ export default defineConfig({
// Disable file parallelism to run tests sequentially (replaces --no-threads)
fileParallelism: false,
environment: 'jsdom',
// Explicitly point Vitest to the correct tsconfig and enable globals.
globals: true, // tsconfig is auto-detected, so the explicit property is not needed and causes an error.
globalSetup: './src/tests/setup/global-setup.ts',
setupFiles: ['./src/tests/setup/tests-setup-unit.ts'],
// Explicitly include all test files that are NOT integration tests.
include: ['src/**/*.test.{ts,tsx}'],
include: ['src/**/*.test.{ts,tsx}', 'src/vite-env.d.ts'],
// Exclude integration tests and other non-test files from the unit test runner.
exclude: [
'**/node_modules/**',

13
vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
// This setup file is where we can add global test configurations
setupFiles: './src/tests/setup/tests-setup-unit.ts',
// This line is the key fix: it tells Vitest to include the type definitions
include: ['src/**/*.test.tsx', 'src/vite-env.d.ts'],
},
});