;
+ let authToken: string;
+
+ beforeAll(async () => {
+ const app = (await import('../../../server')).default;
+ request = supertest(app);
+ const { token } = await createAndLoginUser(request);
+ authToken = token;
+ });
+
+ it('GET /api/auth/me returns user profile', async () => {
+ const response = await request
+ .get('/api/auth/me')
+ .set('Authorization', `Bearer ${authToken}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.user.email).toBeDefined();
+ });
+});
+```
+
+**Database Cleanup**:
+
+```typescript
+import { cleanupDb } from '@/tests/utils/cleanup';
+
+afterAll(async () => {
+ await cleanupDb({ users: [testUserId] });
+});
+```
+
+### E2E Tests
+
+**Purpose**: Test complete user journeys through the application.
+
+**Timeout**: 120 seconds (for long-running flows)
+
+**Current E2E Tests**:
+
+- `auth.e2e.test.ts` - Registration, login, password reset
+- `flyer-upload.e2e.test.ts` - Complete flyer upload pipeline
+- `user-journey.e2e.test.ts` - Full user workflow
+- `admin-authorization.e2e.test.ts` - Admin-specific flows
+- `admin-dashboard.e2e.test.ts` - Admin dashboard functionality
+
+### Mock Factories
+
+The project uses comprehensive mock factories (`src/tests/utils/mockFactories.ts`, 1553 lines) for creating test data:
+
+```typescript
+import {
+ createMockUser,
+ createMockFlyer,
+ createMockFlyerItem,
+ createMockRecipe,
+ resetMockIds,
+} from '@/tests/utils/mockFactories';
+
+beforeEach(() => {
+ resetMockIds(); // Ensure deterministic IDs
+});
+
+it('creates flyer with items', () => {
+ const flyer = createMockFlyer({ store_name: 'TestMart' });
+ const items = [createMockFlyerItem({ flyer_id: flyer.flyer_id })];
+ // ...
+});
+```
+
+**Factory Coverage**: 90+ factory functions for all domain entities including users, flyers, recipes, shopping lists, budgets, achievements, etc.
+
+### Test Utilities
+
+| Utility | Purpose |
+| ------- | ------- |
+| `renderWithProviders()` | Wrap components with AppProviders + Router |
+| `createAndLoginUser()` | Create user and return auth token |
+| `cleanupDb()` | Database cleanup respecting FK constraints |
+| `createTestApp()` | Create Express app for route testing |
+| `poll()` | Polling utility for async operations |
+
+### Coverage Configuration
+
+**Coverage Provider**: v8 (built-in Vitest)
+
+**Report Directories**:
+
+- `.coverage/unit/` - Unit test coverage
+- `.coverage/integration/` - Integration test coverage
+- `.coverage/e2e/` - E2E test coverage
+
+**Excluded from Coverage**:
+
+- `src/index.tsx`, `src/main.tsx` (entry points)
+- `src/tests/**` (test files themselves)
+- `src/**/*.d.ts` (type declarations)
+- `src/components/icons/**` (icon components)
+- `src/db/seed*.ts` (database seeding scripts)
+
+### npm Scripts
+
+```bash
+# Run all tests
+npm run test
+
+# Run by level
+npm run test:unit # Unit tests only (jsdom)
+npm run test:integration # Integration tests only (node)
+
+# With coverage
+npm run test:coverage # Unit + Integration with reports
+
+# Clean coverage directories
+npm run clean
+```
+
+### Test Timeouts
+
+| Test Type | Timeout | Rationale |
+| --------- | ------- | --------- |
+| Unit | 5 seconds | Fast, isolated tests |
+| Integration | 60 seconds | AI service calls, DB operations |
+| E2E | 120 seconds | Full user flow with multiple API calls |
+
+## Best Practices
+
+### When to Write Each Test Type
+
+1. **Unit Tests** (required):
+ - Pure functions and utilities
+ - React components (rendering, user interactions)
+ - Custom hooks
+ - Service methods with mocked dependencies
+ - Repository methods
+
+2. **Integration Tests** (required for API changes):
+ - New API endpoints
+ - Authentication/authorization flows
+ - Middleware behavior
+ - Database query correctness
+
+3. **E2E Tests** (for critical paths):
+ - User registration and login
+ - Core business flows (flyer upload, shopping lists)
+ - Admin operations
+
+### Test Isolation Guidelines
+
+1. **Reset mock IDs**: Call `resetMockIds()` in `beforeEach()`
+2. **Unique test data**: Use timestamps or UUIDs for emails/usernames
+3. **Clean up after tests**: Use `cleanupDb()` in `afterAll()`
+4. **Don't share state**: Each test should be independent
+
+### Mocking Guidelines
+
+1. **Unit tests**: Mock external dependencies (DB, APIs, services)
+2. **Integration tests**: Mock only external APIs (AI services)
+3. **E2E tests**: Minimal mocking, use real services where possible
+
+## Key Files
+
+- `vite.config.ts` - Unit test configuration
+- `vitest.config.integration.ts` - Integration test configuration
+- `vitest.config.e2e.ts` - E2E test configuration
+- `vitest.workspace.ts` - Workspace orchestration
+- `src/tests/setup/tests-setup-unit.ts` - Global mocks (488 lines)
+- `src/tests/setup/integration-global-setup.ts` - Server + DB setup
+- `src/tests/utils/mockFactories.ts` - Mock factories (1553 lines)
+- `src/tests/utils/testHelpers.ts` - Test utilities
+
+## Future Enhancements
+
+1. **Browser E2E Tests**: Consider adding Playwright for actual browser testing
+2. **Visual Regression**: Screenshot comparison for UI components
+3. **Performance Testing**: Add benchmarks for critical paths
+4. **Mutation Testing**: Verify test quality with mutation testing tools
+5. **Coverage Thresholds**: Define minimum coverage requirements per module
diff --git a/docs/adr/0012-frontend-component-library-and-design-system.md b/docs/adr/0012-frontend-component-library-and-design-system.md
index 2d7080f..beaf264 100644
--- a/docs/adr/0012-frontend-component-library-and-design-system.md
+++ b/docs/adr/0012-frontend-component-library-and-design-system.md
@@ -2,7 +2,7 @@
**Date**: 2025-12-12
-**Status**: Proposed
+**Status**: Partially Implemented
## Context
@@ -16,3 +16,255 @@ We will establish a formal Design System and Component Library. This will involv
- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt.
- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development.
+
+## Implementation Status
+
+### What's Implemented
+
+The codebase has a solid foundation for a design system:
+
+- ✅ **Tailwind CSS v4.1.17** as the styling solution
+- ✅ **Dark mode** fully implemented with system preference detection
+- ✅ **55 custom icon components** for consistent iconography
+- ✅ **Component organization** with shared vs. feature-specific separation
+- ✅ **Accessibility patterns** with ARIA attributes and focus management
+
+### What's Not Yet Implemented
+
+- ❌ **Storybook** is not yet installed or configured
+- ❌ **Formal design token documentation** (colors, typography, spacing)
+- ❌ **Visual regression testing** for component changes
+
+## Implementation Details
+
+### Component Library Structure
+
+```text
+src/
+├── components/ # 30+ shared UI components
+│ ├── icons/ # 55 SVG icon components
+│ ├── Header.tsx
+│ ├── Footer.tsx
+│ ├── LoadingSpinner.tsx
+│ ├── ErrorDisplay.tsx
+│ ├── ConfirmationModal.tsx
+│ ├── DarkModeToggle.tsx
+│ ├── StatCard.tsx
+│ ├── PasswordInput.tsx
+│ └── ...
+├── features/ # Feature-specific components
+│ ├── charts/ # PriceChart, PriceHistoryChart
+│ ├── flyer/ # FlyerDisplay, FlyerList, FlyerUploader
+│ ├── shopping/ # ShoppingListComponent, WatchedItemsList
+│ └── voice-assistant/ # VoiceAssistant
+├── layouts/ # Page layouts
+│ └── MainLayout.tsx
+├── pages/ # Page components
+│ └── admin/components/ # Admin-specific components
+└── providers/ # Context providers
+```
+
+### Styling Approach
+
+**Tailwind CSS** with utility-first classes:
+
+```typescript
+// Component example with consistent styling patterns
+
+```
+
+**Common Utility Patterns**:
+
+| Pattern | Classes |
+| ------- | ------- |
+| Card container | `bg-white dark:bg-gray-800 rounded-lg shadow-md p-6` |
+| Primary button | `bg-brand-primary hover:bg-brand-dark text-white rounded-lg px-4 py-2` |
+| Secondary button | `bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200` |
+| Input field | `border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2` |
+| Focus ring | `focus:outline-none focus:ring-2 focus:ring-brand-primary` |
+
+### Color System
+
+**Brand Colors** (Tailwind theme extensions):
+
+- `brand-primary` - Primary brand color (blue/teal)
+- `brand-light` - Lighter variant
+- `brand-dark` - Darker variant for hover states
+- `brand-secondary` - Secondary accent color
+
+**Semantic Colors**:
+
+- Gray scale: `gray-50` through `gray-950`
+- Error: `red-500`, `red-600`
+- Success: `green-500`, `green-600`
+- Warning: `yellow-500`, `orange-500`
+- Info: `blue-500`, `blue-600`
+
+### Dark Mode Implementation
+
+Dark mode is fully implemented using Tailwind's `dark:` variant:
+
+```typescript
+// Initialization in useAppInitialization hook
+const initializeDarkMode = () => {
+ // Priority: user profile > localStorage > system preference
+ const stored = localStorage.getItem('darkMode');
+ const systemPreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ const isDarkMode = stored ? stored === 'true' : systemPreference;
+
+ document.documentElement.classList.toggle('dark', isDarkMode);
+ return isDarkMode;
+};
+```
+
+**Usage in components**:
+
+```typescript
+
+ Content adapts to theme
+
+```
+
+### Icon System
+
+**55 custom SVG icon components** in `src/components/icons/`:
+
+```typescript
+// Icon component pattern
+interface IconProps extends React.SVGProps {
+ title?: string;
+}
+
+export const CheckCircleIcon: React.FC = ({ title, ...props }) => (
+
+);
+```
+
+**Usage**:
+
+```typescript
+
+```
+
+**External icons**: Lucide React (`lucide-react` v0.555.0) used for additional icons.
+
+### Accessibility Patterns
+
+**ARIA Attributes**:
+
+```typescript
+// Modal pattern
+
+
Modal Title
+
+
+// Button with label
+
+
+// Loading state
+
+
+
+```
+
+**Focus Management**:
+
+- Consistent focus rings: `focus:ring-2 focus:ring-brand-primary focus:ring-offset-2`
+- Dark mode offset: `dark:focus:ring-offset-gray-800`
+- No outline: `focus:outline-none` (using ring instead)
+
+### State Management
+
+**Context Providers** (see ADR-005):
+
+| Provider | Purpose |
+| -------- | ------- |
+| `AuthProvider` | Authentication state |
+| `ModalProvider` | Modal open/close state |
+| `FlyersProvider` | Flyer data |
+| `MasterItemsProvider` | Grocery items |
+| `UserDataProvider` | User-specific data |
+
+**Provider Hierarchy** in `AppProviders.tsx`:
+
+```typescript
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+```
+
+## Key Files
+
+- `tailwind.config.js` - Tailwind CSS configuration
+- `src/index.css` - Tailwind CSS entry point
+- `src/components/` - Shared UI components
+- `src/components/icons/` - Icon component library (55 icons)
+- `src/providers/AppProviders.tsx` - Context provider composition
+- `src/hooks/useAppInitialization.ts` - Dark mode initialization
+
+## Component Guidelines
+
+### When to Create Shared Components
+
+Create a shared component in `src/components/` when:
+
+1. Used in 3+ places across the application
+2. Represents a reusable UI pattern (buttons, cards, modals)
+3. Has consistent styling/behavior requirements
+
+### Naming Conventions
+
+- **Components**: PascalCase (`LoadingSpinner.tsx`)
+- **Icons**: PascalCase with `Icon` suffix (`CheckCircleIcon.tsx`)
+- **Hooks**: camelCase with `use` prefix (`useModal.ts`)
+- **Contexts**: PascalCase with `Context` suffix (`AuthContext.tsx`)
+
+### Styling Guidelines
+
+1. Use Tailwind utility classes exclusively
+2. Include dark mode variants for all colors: `bg-white dark:bg-gray-800`
+3. Add focus states for interactive elements
+4. Use semantic color names from the design system
+
+## Future Enhancements (Storybook Setup)
+
+To complete ADR-012 implementation:
+
+1. **Install Storybook**:
+
+ ```bash
+ npx storybook@latest init
+ ```
+
+2. **Create stories for core components**:
+ - Button variants
+ - Form inputs (PasswordInput, etc.)
+ - Modal components
+ - Loading states
+ - Icon showcase
+
+3. **Add visual regression testing** with Chromatic or Percy
+
+4. **Document design tokens** formally in Storybook
+
+5. **Create component composition guidelines**
diff --git a/src/services/db/address.db.test.ts b/src/services/db/address.db.test.ts
index 4bf0ce5..8d28397 100644
--- a/src/services/db/address.db.test.ts
+++ b/src/services/db/address.db.test.ts
@@ -18,6 +18,7 @@ describe('Address DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockDb.query.mockReset();
addressRepo = new AddressRepository(mockDb);
});
diff --git a/src/services/db/admin.db.test.ts b/src/services/db/admin.db.test.ts
index 55524a0..e9a02ce 100644
--- a/src/services/db/admin.db.test.ts
+++ b/src/services/db/admin.db.test.ts
@@ -40,6 +40,7 @@ describe('Admin DB Service', () => {
beforeEach(() => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
+ mockDb.query.mockReset();
// Reset the withTransaction mock before each test
vi.mocked(withTransaction).mockImplementation(async (callback) => {
diff --git a/src/services/db/budget.db.test.ts b/src/services/db/budget.db.test.ts
index a3d4fde..b99e33a 100644
--- a/src/services/db/budget.db.test.ts
+++ b/src/services/db/budget.db.test.ts
@@ -47,6 +47,7 @@ describe('Budget DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockDb.query.mockReset();
// Instantiate the repository with the minimal mock db for each test
budgetRepo = new BudgetRepository(mockDb);
});
diff --git a/src/services/db/conversion.db.test.ts b/src/services/db/conversion.db.test.ts
index fe85634..92c6cf6 100644
--- a/src/services/db/conversion.db.test.ts
+++ b/src/services/db/conversion.db.test.ts
@@ -28,6 +28,7 @@ import { logger as mockLogger } from '../logger.server';
describe('Conversion DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
// Make getPool return our mock instance for each test
vi.mocked(getPool).mockReturnValue(mockPoolInstance as any);
});
diff --git a/src/services/db/flyer.db.test.ts b/src/services/db/flyer.db.test.ts
index ea53ce2..18069db 100644
--- a/src/services/db/flyer.db.test.ts
+++ b/src/services/db/flyer.db.test.ts
@@ -46,6 +46,7 @@ describe('Flyer DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
//In a transaction, `pool.connect()` returns a client. That client has a `release` method.
// For these tests, we simulate this by having `connect` resolve to the pool instance itself,
// and we ensure the `release` method is mocked on that instance.
@@ -586,18 +587,6 @@ describe('Flyer DB Service', () => {
});
describe('getFlyers', () => {
- const expectedQuery = `
- SELECT
- f.*,
- json_build_object(
- 'store_id', s.store_id,
- 'name', s.name,
- 'logo_url', s.logo_url
- ) as store
- FROM public.flyers f
- JOIN public.stores s ON f.store_id = s.store_id
- ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
-
it('should use default limit and offset when none are provided', async () => {
console.log('[TEST DEBUG] Running test: getFlyers > should use default limit and offset');
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
@@ -611,7 +600,7 @@ describe('Flyer DB Service', () => {
);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
- expectedQuery,
+ expect.stringContaining('FROM public.flyers f'),
[20, 0], // Default values
);
});
@@ -629,7 +618,7 @@ describe('Flyer DB Service', () => {
);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
- expectedQuery,
+ expect.stringContaining('FROM public.flyers f'),
[10, 5], // Provided values
);
});
diff --git a/src/services/db/gamification.db.test.ts b/src/services/db/gamification.db.test.ts
index bb3d0ee..f0b6179 100644
--- a/src/services/db/gamification.db.test.ts
+++ b/src/services/db/gamification.db.test.ts
@@ -29,6 +29,7 @@ describe('Gamification DB Service', () => {
beforeEach(() => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
+ mockDb.query.mockReset();
// Instantiate the repository with the mock pool for each test
gamificationRepo = new GamificationRepository(mockDb);
diff --git a/src/services/db/notification.db.test.ts b/src/services/db/notification.db.test.ts
index 40bd227..34daea3 100644
--- a/src/services/db/notification.db.test.ts
+++ b/src/services/db/notification.db.test.ts
@@ -30,6 +30,7 @@ describe('Notification DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
// Instantiate the repository with the mock pool for each test
notificationRepo = new NotificationRepository(mockPoolInstance as unknown as Pool);
diff --git a/src/services/db/personalization.db.test.ts b/src/services/db/personalization.db.test.ts
index 98b3e34..11aabb5 100644
--- a/src/services/db/personalization.db.test.ts
+++ b/src/services/db/personalization.db.test.ts
@@ -35,6 +35,7 @@ describe('Personalization DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockQuery.mockReset();
// Reset the withTransaction mock before each test
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
diff --git a/src/services/db/price.db.test.ts b/src/services/db/price.db.test.ts
index cab185d..6cde4e6 100644
--- a/src/services/db/price.db.test.ts
+++ b/src/services/db/price.db.test.ts
@@ -27,6 +27,7 @@ import { logger as mockLogger } from '../logger.server';
describe('Price DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
// Make getPool return our mock instance for each test
vi.mocked(getPool).mockReturnValue(mockPoolInstance as any);
});
diff --git a/src/services/db/reaction.db.test.ts b/src/services/db/reaction.db.test.ts
index f145c9f..587291e 100644
--- a/src/services/db/reaction.db.test.ts
+++ b/src/services/db/reaction.db.test.ts
@@ -34,6 +34,7 @@ describe('Reaction DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockDb.query.mockReset();
reactionRepo = new ReactionRepository(mockDb);
});
diff --git a/src/services/db/recipe.db.test.ts b/src/services/db/recipe.db.test.ts
index da7d9d6..a74371b 100644
--- a/src/services/db/recipe.db.test.ts
+++ b/src/services/db/recipe.db.test.ts
@@ -28,6 +28,7 @@ describe('Recipe DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockQuery.mockReset();
// Instantiate the repository with the mock pool for each test
recipeRepo = new RecipeRepository(mockPoolInstance as unknown as Pool);
});
diff --git a/src/services/db/shopping.db.test.ts b/src/services/db/shopping.db.test.ts
index 4237bcc..3d253a6 100644
--- a/src/services/db/shopping.db.test.ts
+++ b/src/services/db/shopping.db.test.ts
@@ -36,6 +36,7 @@ describe('Shopping DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
// Instantiate the repository with the mock pool for each test
shoppingRepo = new ShoppingRepository(mockPoolInstance as unknown as Pool);
});
diff --git a/src/services/db/user.db.test.ts b/src/services/db/user.db.test.ts
index b10ad39..30bdd78 100644
--- a/src/services/db/user.db.test.ts
+++ b/src/services/db/user.db.test.ts
@@ -62,6 +62,7 @@ describe('User DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPoolInstance.query.mockReset();
userRepo = new UserRepository(mockPoolInstance as unknown as PoolClient);
// Provide a default mock implementation for withTransaction for all tests.
vi.mocked(withTransaction).mockImplementation(
diff --git a/src/tests/integration/gamification.integration.test.ts b/src/tests/integration/gamification.integration.test.ts
index ba48f52..71cbcf4 100644
--- a/src/tests/integration/gamification.integration.test.ts
+++ b/src/tests/integration/gamification.integration.test.ts
@@ -1,5 +1,5 @@
// src/tests/integration/gamification.integration.test.ts
-import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import path from 'path';
import fs from 'node:fs/promises';
@@ -70,8 +70,13 @@ describe('Gamification Flow Integration Test', () => {
fullName: 'Gamification Tester',
request,
}));
+ });
- // Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Reset AI Service Mock to default success state
+ mockExtractCoreData.mockReset();
mockExtractCoreData.mockResolvedValue({
store_name: 'Gamification Test Store',
valid_from: null,
@@ -87,6 +92,9 @@ describe('Gamification Flow Integration Test', () => {
},
],
});
+
+ // Reset Image Processor Mock
+ vi.mocked(imageProcessor.generateFlyerIcon).mockResolvedValue('mock-icon.webp');
});
afterAll(async () => {
diff --git a/src/tests/integration/recipe.integration.test.ts b/src/tests/integration/recipe.integration.test.ts
index 05f8c47..b075de5 100644
--- a/src/tests/integration/recipe.integration.test.ts
+++ b/src/tests/integration/recipe.integration.test.ts
@@ -1,5 +1,5 @@
// src/tests/integration/recipe.integration.test.ts
-import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
@@ -49,6 +49,12 @@ describe('Recipe API Routes Integration Tests', () => {
createdRecipeIds.push(testRecipe.recipe_id);
});
+ afterEach(() => {
+ vi.clearAllMocks();
+ // Reset the mock to its default state for the next test
+ vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Default Mock Suggestion');
+ });
+
afterAll(async () => {
vi.unstubAllEnvs();
// Clean up all created resources