frontend direct testing result and fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s

This commit is contained in:
2026-01-18 13:57:47 -08:00
parent 3e85f839fe
commit c24103d9a0
25 changed files with 3384 additions and 166 deletions

View File

@@ -0,0 +1,349 @@
# Frontend Test Automation Plan
**Date**: 2026-01-18
**Status**: Awaiting Approval
**Related**: [2026-01-18-frontend-tests.md](../tests/2026-01-18-frontend-tests.md)
## Executive Summary
This plan formalizes the automated testing of 35+ API endpoints manually tested on 2026-01-18. The testing covered 7 major areas including end-to-end user flows, edge cases, queue behavior, authentication, performance, real-time features, and data integrity.
**Recommendation**: Most tests should be added as **integration tests** (Supertest-based), with select critical flows as **E2E tests**. This aligns with ADR-010 and ADR-040's guidance on testing economics.
---
## Analysis of Manual Tests vs Existing Coverage
### Current Test Coverage
| Test Type | Existing Files | Existing Tests |
| ----------- | -------------- | -------------- |
| Integration | 21 files | ~150+ tests |
| E2E | 9 files | ~40+ tests |
### Gap Analysis
| Manual Test Area | Existing Coverage | Gap | Priority |
| -------------------------- | ------------------------- | --------------------------- | -------- |
| Budget API | budget.integration.test | Partial - add validation | Medium |
| Deals API | None | **New file needed** | Low |
| Reactions API | None | **New file needed** | Low |
| Gamification API | gamification.integration | Good coverage | None |
| Recipe API | recipe.integration.test | Add fork error, comment | Medium |
| Receipt API | receipt.integration.test | Good coverage | None |
| UPC API | upc.integration.test | Good coverage | None |
| Price History API | price.integration.test | Good coverage | None |
| Personalization API | public.routes.integration | Good coverage | None |
| Admin Routes | admin.integration.test | Add queue/trigger endpoints | Medium |
| Edge Cases (Area 2) | Scattered | **Consolidate/add** | High |
| Queue/Worker (Area 3) | Partial | Add admin trigger tests | Medium |
| Auth Edge Cases (Area 4) | auth.integration.test | Add token malformation | Medium |
| Performance (Area 5) | None | **Not recommended** | Skip |
| Real-time/Polling (Area 6) | notification.integration | Add job status polling | Low |
| Data Integrity (Area 7) | Scattered | **Consolidate** | High |
---
## Implementation Plan
### Phase 1: New Integration Test Files (Priority: High)
#### 1.1 Create `deals.integration.test.ts`
**Rationale**: Routes were unmounted until this testing session; no tests exist.
```typescript
// Tests to add:
describe('Deals API', () => {
it('GET /api/deals/best-watched-prices requires auth');
it('GET /api/deals/best-watched-prices returns watched items for user');
it('Returns empty array when no watched items');
});
```
**Estimated effort**: 30 minutes
#### 1.2 Create `reactions.integration.test.ts`
**Rationale**: Routes were unmounted until this testing session; no tests exist.
```typescript
// Tests to add:
describe('Reactions API', () => {
it('GET /api/reactions/summary/:targetType/:targetId returns counts');
it('POST /api/reactions/toggle requires auth');
it('POST /api/reactions/toggle toggles reaction on/off');
it('Returns validation error for invalid target_type');
it('Returns validation error for non-string entity_id');
});
```
**Estimated effort**: 45 minutes
#### 1.3 Create `edge-cases.integration.test.ts`
**Rationale**: Consolidate edge case tests discovered during manual testing.
```typescript
// Tests to add:
describe('Edge Cases', () => {
describe('File Upload Validation', () => {
it('Accepts small files');
it('Processes corrupt file with IMAGE_CONVERSION_FAILED');
it('Rejects wrong checksum format');
it('Rejects short checksum');
});
describe('Input Sanitization', () => {
it('Handles XSS payloads in shopping list names (stores as-is)');
it('Handles unicode/emoji in text fields');
it('Rejects null bytes in JSON');
it('Handles very long input strings');
});
describe('Authorization Boundaries', () => {
it('Cross-user access returns 404 (not 403)');
it('SQL injection in query params is safely handled');
});
});
```
**Estimated effort**: 1.5 hours
#### 1.4 Create `data-integrity.integration.test.ts`
**Rationale**: Consolidate FK/cascade/constraint tests.
```typescript
// Tests to add:
describe('Data Integrity', () => {
describe('Cascade Deletes', () => {
it('User deletion cascades to shopping lists, budgets, notifications');
it('Shopping list deletion cascades to items');
it('Admin cannot delete own account');
});
describe('FK Constraints', () => {
it('Rejects invalid FK references via API');
it('Rejects invalid FK references via direct DB');
});
describe('Unique Constraints', () => {
it('Duplicate email returns CONFLICT');
it('Duplicate flyer checksum is handled');
});
describe('CHECK Constraints', () => {
it('Budget period rejects invalid values');
it('Budget amount rejects negative values');
});
});
```
**Estimated effort**: 2 hours
---
### Phase 2: Extend Existing Integration Tests (Priority: Medium)
#### 2.1 Extend `budget.integration.test.ts`
Add validation edge cases discovered during manual testing:
```typescript
// Tests to add:
it('Rejects period="yearly" (only weekly/monthly allowed)');
it('Rejects negative amount_cents');
it('Rejects invalid date format');
it('Returns 404 for update on non-existent budget');
it('Returns 404 for delete on non-existent budget');
```
**Estimated effort**: 30 minutes
#### 2.2 Extend `admin.integration.test.ts`
Add queue and trigger endpoint tests:
```typescript
// Tests to add:
describe('Queue Management', () => {
it('GET /api/admin/queues/status returns all queue counts');
it('POST /api/admin/trigger/analytics-report enqueues job');
it('POST /api/admin/trigger/weekly-analytics enqueues job');
it('POST /api/admin/trigger/daily-deal-check enqueues job');
it('POST /api/admin/jobs/:queue/:id/retry retries failed job');
it('POST /api/admin/system/clear-cache clears Redis cache');
it('Returns validation error for invalid queue name');
it('Returns 404 for retry on non-existent job');
});
```
**Estimated effort**: 1 hour
#### 2.3 Extend `auth.integration.test.ts`
Add token malformation edge cases:
```typescript
// Tests to add:
describe('Token Edge Cases', () => {
it('Empty Bearer token returns Unauthorized');
it('Token without dots returns Unauthorized');
it('Token with 2 parts returns Unauthorized');
it('Token with invalid signature returns Unauthorized');
it('Lowercase "bearer" scheme is accepted');
it('Basic auth scheme returns Unauthorized');
it('Tampered token payload returns Unauthorized');
});
describe('Login Security', () => {
it('Wrong password and non-existent user return same error');
it('Forgot password returns same response for existing/non-existing');
});
```
**Estimated effort**: 45 minutes
#### 2.4 Extend `recipe.integration.test.ts`
Add fork error case and comment tests:
```typescript
// Tests to add:
it('Fork fails for seed recipes (null user_id)');
it('POST /api/recipes/:id/comments adds comment');
it('GET /api/recipes/:id/comments returns comments');
```
**Estimated effort**: 30 minutes
#### 2.5 Extend `notification.integration.test.ts`
Add job status polling tests:
```typescript
// Tests to add:
describe('Job Status Polling', () => {
it('GET /api/ai/jobs/:id/status returns completed job');
it('GET /api/ai/jobs/:id/status returns failed job with error');
it('GET /api/ai/jobs/:id/status returns 404 for non-existent');
it('Job status endpoint works without auth (public)');
});
```
**Estimated effort**: 30 minutes
---
### Phase 3: E2E Tests (Priority: Low-Medium)
Per ADR-040, E2E tests should be limited to critical user flows. The existing E2E tests cover the main flows well. However, we should consider:
#### 3.1 Do NOT Add
- Performance tests (handle via monitoring, not E2E)
- Pagination tests (integration level is sufficient)
- Cache behavior tests (integration level is sufficient)
#### 3.2 Consider Adding (Optional)
**Budget flow E2E** - If budget management becomes a critical feature:
```typescript
// budget-journey.e2e.test.ts
describe('Budget Journey', () => {
it('User creates budget → tracks spending → sees analysis');
});
```
**Recommendation**: Defer unless budget becomes a core value proposition.
---
### Phase 4: Documentation Updates
#### 4.1 Update ADR-010
Add the newly discovered API gotchas to the testing documentation:
- `entity_id` must be STRING in reactions
- `customItemName` (camelCase) in shopping list items
- `scan_source` must be `manual_entry`, not `manual`
#### 4.2 Update CLAUDE.md
Add API reference section for correct endpoint calls (already captured in test doc).
---
## Tests NOT Recommended
Per ADR-040 (Testing Economics), the following tests from the manual session should NOT be automated:
| Test Area | Reason |
| --------------------------- | ------------------------------------------------- |
| Performance benchmarks | Use APM/monitoring tools instead (see ADR-015) |
| Concurrent request handling | Connection pool behavior is framework-level |
| Cache hit/miss timing | Observable via Redis metrics, not test assertions |
| Response time consistency | Better suited for production monitoring |
| WebSocket/SSE | Not implemented - polling is the architecture |
---
## Implementation Timeline
| Phase | Description | Effort | Priority |
| --------- | ------------------------------ | ------------ | -------- |
| 1.1 | deals.integration.test.ts | 30 min | High |
| 1.2 | reactions.integration.test.ts | 45 min | High |
| 1.3 | edge-cases.integration.test.ts | 1.5 hours | High |
| 1.4 | data-integrity.integration.ts | 2 hours | High |
| 2.1 | Extend budget tests | 30 min | Medium |
| 2.2 | Extend admin tests | 1 hour | Medium |
| 2.3 | Extend auth tests | 45 min | Medium |
| 2.4 | Extend recipe tests | 30 min | Medium |
| 2.5 | Extend notification tests | 30 min | Medium |
| 4.x | Documentation updates | 30 min | Low |
| **Total** | | **~8 hours** | |
---
## Verification Strategy
For each new test file, verify by running:
```bash
# In dev container
npm run test:integration -- --run src/tests/integration/<file>.test.ts
```
All tests should:
1. Pass consistently (no flaky tests)
2. Run in isolation (no shared state)
3. Clean up test data (use `cleanupDb()`)
4. Follow existing patterns in the codebase
---
## Risks and Mitigations
| Risk | Mitigation |
| ------------------------------------ | --------------------------------------------------- |
| Test flakiness from async operations | Use proper waitFor/polling utilities |
| Database state leakage between tests | Strict cleanup in afterEach/afterAll |
| Queue state affecting test isolation | Drain/pause queues in tests that interact with them |
| Port conflicts | Use dedicated test port (3099) |
---
## Approval Request
Please review and approve this plan. Upon approval, implementation will proceed in priority order (Phase 1 first).
**Questions for clarification**:
1. Should the deals/reactions routes remain mounted, or was that a temporary fix?
2. Is the recipe fork failure for seed recipes expected behavior or a bug to fix?
3. Any preference on splitting Phase 1 into multiple PRs vs one large PR?

File diff suppressed because it is too large Load Diff

View File

@@ -287,6 +287,18 @@ app.use('/api/reactions', reactionsRouter);
// --- Error Handling and Server Startup ---
// Catch-all 404 handler for unmatched routes.
// Returns JSON instead of HTML for API consistency.
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`,
},
});
});
// Sentry Error Handler (ADR-015) - captures errors and sends to Bugsink.
// Must come BEFORE the custom error handler but AFTER all routes.
app.use(sentryMiddleware.errorHandler);

View File

@@ -65,6 +65,13 @@ const activityLogSchema = z.object({
}),
});
const usersListSchema = z.object({
query: z.object({
limit: optionalNumeric({ integer: true, positive: true, max: 100 }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});
const jobRetrySchema = z.object({
params: z.object({
queueName: z.enum([
@@ -712,21 +719,35 @@ router.put(
* get:
* tags: [Admin]
* summary: Get all users
* description: Retrieve a list of all users. Requires admin role.
* description: Retrieve a list of all users with optional pagination. Requires admin role.
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* maximum: 100
* description: Maximum number of users to return. If omitted, returns all users.
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* description: Number of users to skip
* responses:
* 200:
* description: List of all users
* description: List of users with total count
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
router.get('/users', validateRequest(usersListSchema), async (req, res, next: NextFunction) => {
try {
const users = await db.adminRepo.getAllUsers(req.log);
sendSuccess(res, users);
const { limit, offset } = usersListSchema.shape.query.parse(req.query);
const result = await db.adminRepo.getAllUsers(req.log, limit, offset);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error }, 'Error fetching users');
next(error);
@@ -1298,6 +1319,43 @@ router.post(
},
);
/**
* @openapi
* /admin/trigger/token-cleanup:
* post:
* tags: [Admin]
* summary: Trigger token cleanup
* description: Manually trigger the expired token cleanup job. Requires admin role.
* security:
* - bearerAuth: []
* responses:
* 202:
* description: Job enqueued successfully
* 401:
* description: Unauthorized
* 403:
* description: Forbidden - admin role required
*/
router.post(
'/trigger/token-cleanup',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual trigger for token cleanup received from user: ${userProfile.user.user_id}`,
);
try {
const jobId = await backgroundJobService.triggerTokenCleanup();
sendSuccess(res, { message: 'Successfully enqueued token cleanup job.', jobId }, 202);
} catch (error) {
req.log.error({ error }, 'Error enqueuing token cleanup job');
next(error);
}
},
);
/**
* @openapi
* /admin/system/clear-cache:

View File

@@ -122,10 +122,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
createMockAdminUserView({ user_id: '1', email: 'user1@test.com', role: 'user' }),
createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
];
vi.mocked(adminRepo.getAllUsers).mockResolvedValue(mockUsers);
vi.mocked(adminRepo.getAllUsers).mockResolvedValue({ users: mockUsers, total: 2 });
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockUsers);
expect(response.body.data).toEqual({ users: mockUsers, total: 2 });
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
});

View File

@@ -158,7 +158,11 @@ const searchWebSchema = z.object({
body: z.object({ query: requiredString('A search query is required.') }),
});
const uploadToDisk = createUploadMiddleware({ storageType: 'flyer' });
const uploadToDisk = createUploadMiddleware({
storageType: 'flyer',
fileSize: 50 * 1024 * 1024, // 50MB limit for flyer uploads
fileFilter: 'image',
});
// Diagnostic middleware: log incoming AI route requests (headers and sizes)
router.use((req: Request, res: Response, next: NextFunction) => {

View File

@@ -38,14 +38,17 @@ describe('Personalization Routes (/api/personalization)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: mockItems,
total: 1,
});
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(200);
expect(response.body.data).toEqual(mockItems);
expect(response.body.data).toEqual({ items: mockItems, total: 1 });
});
it('should return 500 if the database call fails', async () => {
@@ -113,7 +116,10 @@ describe('Personalization Routes (/api/personalization)', () => {
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /master-items', async () => {
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: [],
total: 0,
});
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true');

View File

@@ -5,6 +5,7 @@ import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
import { optionalNumeric } from '../utils/zodUtils';
const router = Router();
@@ -13,16 +14,37 @@ const router = Router();
// to maintain a consistent validation pattern across the application.
const emptySchema = z.object({});
// Schema for master-items with optional pagination
const masterItemsSchema = z.object({
query: z.object({
limit: optionalNumeric({ integer: true, positive: true, max: 500 }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});
/**
* @openapi
* /personalization/master-items:
* get:
* tags: [Personalization]
* summary: Get master items list
* description: Get the master list of all grocery items. Response is cached for 1 hour.
* description: Get the master list of all grocery items with optional pagination. Response is cached for 1 hour.
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* maximum: 500
* description: Maximum number of items to return. If omitted, returns all items.
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* description: Number of items to skip
* responses:
* 200:
* description: List of all master grocery items
* description: List of master grocery items with total count
* content:
* application/json:
* schema:
@@ -31,17 +53,20 @@ const emptySchema = z.object({});
router.get(
'/master-items',
publicReadLimiter,
validateRequest(emptySchema),
validateRequest(masterItemsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
// Parse and apply defaults from schema
const { limit, offset } = masterItemsSchema.shape.query.parse(req.query);
// LOGGING: Track how often this heavy DB call is actually made vs served from cache
req.log.info('Fetching master items list from database...');
req.log.info({ limit, offset }, 'Fetching master items list from database...');
// Optimization: This list changes rarely. Instruct clients to cache it for 1 hour (3600s).
res.set('Cache-Control', 'public, max-age=3600');
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
sendSuccess(res, masterItems);
const result = await db.personalizationRepo.getAllMasterItems(req.log, limit, offset);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);

View File

@@ -239,6 +239,50 @@ router.get(
},
);
/**
* @openapi
* /users/notifications/unread-count:
* get:
* tags: [Users]
* summary: Get unread notification count
* description: Get the count of unread notifications for the authenticated user. Optimized for navbar badge UI.
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Unread notification count
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* count:
* type: integer
* example: 5
* 401:
* description: Unauthorized - invalid or missing token
*/
router.get(
'/notifications/unread-count',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userProfile = req.user as UserProfile;
const count = await db.notificationRepo.getUnreadCount(userProfile.user.user_id, req.log);
sendSuccess(res, { count });
} catch (error) {
req.log.error({ error }, 'Error fetching unread notification count');
next(error);
}
},
);
/**
* @openapi
* /users/notifications/mark-all-read:

View File

@@ -8,7 +8,7 @@ import type { Notification, WatchedItemDeal } from '../types';
// Import types for repositories from their source files
import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
import { analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } from './queueService.server';
type UserDealGroup = {
userProfile: { user_id: string; email: string; full_name: string | null };
@@ -54,6 +54,16 @@ export class BackgroundJobService {
return job.id;
}
public async triggerTokenCleanup(): Promise<string> {
const timestamp = new Date().toISOString();
const jobId = `manual-token-cleanup-${Date.now()}`;
const job = await tokenCleanupQueue.add('cleanup-tokens', { timestamp }, { jobId });
if (!job.id) {
throw new Error('Failed to enqueue token cleanup job: No job ID returned');
}
return job.id;
}
/**
* Prepares the data for an email notification job based on a user's deals.
* @param user The user to whom the email will be sent.
@@ -107,7 +117,10 @@ export class BackgroundJobService {
private async _processDealsForUser({
userProfile,
deals,
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
}: UserDealGroup): Promise<Omit<
Notification,
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
> | null> {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,

View File

@@ -627,14 +627,33 @@ export class AdminRepository {
}
}
async getAllUsers(logger: Logger): Promise<AdminUserView[]> {
async getAllUsers(
logger: Logger,
limit?: number,
offset?: number,
): Promise<{ users: AdminUserView[]; total: number }> {
try {
const query = `
// Get total count
const countRes = await this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
const total = parseInt(countRes.rows[0].count, 10);
// Build query with optional pagination
let query = `
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
`;
const res = await this.db.query<AdminUserView>(query);
return res.rows;
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC`;
const params: number[] = [];
if (limit !== undefined) {
query += ` LIMIT $${params.length + 1}`;
params.push(limit);
}
if (offset !== undefined) {
query += ` OFFSET $${params.length + 1}`;
params.push(offset);
}
const res = await this.db.query<AdminUserView>(query, params.length > 0 ? params : undefined);
return { users: res.rows, total };
} catch (error) {
handleDbError(
error,

View File

@@ -34,10 +34,16 @@ export class NotificationRepository {
);
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in createNotification', { userId, content, linkUrl }, {
fkMessage: 'The specified user does not exist.',
defaultMessage: 'Failed to create notification.',
});
handleDbError(
error,
logger,
'Database error in createNotification',
{ userId, content, linkUrl },
{
fkMessage: 'The specified user does not exist.',
defaultMessage: 'Failed to create notification.',
},
);
}
}
@@ -74,10 +80,16 @@ export class NotificationRepository {
await this.db.query(query, [userIds, contents, linkUrls]);
} catch (error) {
handleDbError(error, logger, 'Database error in createBulkNotifications', { notifications }, {
fkMessage: 'One or more of the specified users do not exist.',
defaultMessage: 'Failed to create bulk notifications.',
});
handleDbError(
error,
logger,
'Database error in createBulkNotifications',
{ notifications },
{
fkMessage: 'One or more of the specified users do not exist.',
defaultMessage: 'Failed to create bulk notifications.',
},
);
}
}
@@ -118,6 +130,32 @@ export class NotificationRepository {
}
}
/**
* Gets the count of unread notifications for a specific user.
* This is optimized for the navbar badge UI.
* @param userId The ID of the user.
* @returns A promise that resolves to the count of unread notifications.
*/
async getUnreadCount(userId: string, logger: Logger): Promise<number> {
try {
const res = await this.db.query<{ count: string }>(
`SELECT COUNT(*) FROM public.notifications WHERE user_id = $1 AND is_read = false`,
[userId],
);
return parseInt(res.rows[0].count, 10);
} catch (error) {
handleDbError(
error,
logger,
'Database error in getUnreadCount',
{ userId },
{
defaultMessage: 'Failed to get unread notification count.',
},
);
}
}
/**
* Marks all unread notifications for a user as read.
* @param userId The ID of the user whose notifications should be marked as read.
@@ -130,9 +168,15 @@ export class NotificationRepository {
[userId],
);
} catch (error) {
handleDbError(error, logger, 'Database error in markAllNotificationsAsRead', { userId }, {
defaultMessage: 'Failed to mark notifications as read.',
});
handleDbError(
error,
logger,
'Database error in markAllNotificationsAsRead',
{ userId },
{
defaultMessage: 'Failed to mark notifications as read.',
},
);
}
}
@@ -183,9 +227,15 @@ export class NotificationRepository {
);
return res.rowCount ?? 0;
} catch (error) {
handleDbError(error, logger, 'Database error in deleteOldNotifications', { daysOld }, {
defaultMessage: 'Failed to delete old notifications.',
});
handleDbError(
error,
logger,
'Database error in deleteOldNotifications',
{ daysOld },
{
defaultMessage: 'Failed to delete old notifications.',
},
);
}
}
}

View File

@@ -25,24 +25,58 @@ export class PersonalizationRepository {
}
/**
* Retrieves all master grocery items from the database.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
* Retrieves master grocery items from the database with optional pagination.
* @param logger The logger instance.
* @param limit Optional limit for pagination. If not provided, returns all items.
* @param offset Optional offset for pagination.
* @returns A promise that resolves to an object with items array and total count.
*/
async getAllMasterItems(logger: Logger): Promise<MasterGroceryItem[]> {
async getAllMasterItems(
logger: Logger,
limit?: number,
offset?: number,
): Promise<{ items: MasterGroceryItem[]; total: number }> {
try {
const query = `
// Get total count
const countRes = await this.db.query<{ count: string }>(
'SELECT COUNT(*) FROM public.master_grocery_items',
);
const total = parseInt(countRes.rows[0].count, 10);
// Build query with optional pagination
let query = `
SELECT
mgi.*,
c.name as category_name
FROM public.master_grocery_items mgi
LEFT JOIN public.categories c ON mgi.category_id = c.category_id
ORDER BY mgi.name ASC`;
const res = await this.db.query<MasterGroceryItem>(query);
return res.rows;
const params: number[] = [];
if (limit !== undefined) {
query += ` LIMIT $${params.length + 1}`;
params.push(limit);
}
if (offset !== undefined) {
query += ` OFFSET $${params.length + 1}`;
params.push(offset);
}
const res = await this.db.query<MasterGroceryItem>(
query,
params.length > 0 ? params : undefined,
);
return { items: res.rows, total };
} catch (error) {
handleDbError(error, logger, 'Database error in getAllMasterItems', {}, {
defaultMessage: 'Failed to retrieve master grocery items.',
});
handleDbError(
error,
logger,
'Database error in getAllMasterItems',
{},
{
defaultMessage: 'Failed to retrieve master grocery items.',
},
);
}
}
@@ -63,9 +97,15 @@ export class PersonalizationRepository {
const res = await this.db.query<MasterGroceryItem>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getWatchedItems', { userId }, {
defaultMessage: 'Failed to retrieve watched items.',
});
handleDbError(
error,
logger,
'Database error in getWatchedItems',
{ userId },
{
defaultMessage: 'Failed to retrieve watched items.',
},
);
}
}
@@ -81,9 +121,15 @@ export class PersonalizationRepository {
[userId, masterItemId],
);
} catch (error) {
handleDbError(error, logger, 'Database error in removeWatchedItem', { userId, masterItemId }, {
defaultMessage: 'Failed to remove item from watchlist.',
});
handleDbError(
error,
logger,
'Database error in removeWatchedItem',
{ userId, masterItemId },
{
defaultMessage: 'Failed to remove item from watchlist.',
},
);
}
}
@@ -103,9 +149,15 @@ export class PersonalizationRepository {
);
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in findPantryItemOwner', { pantryItemId }, {
defaultMessage: 'Failed to retrieve pantry item owner from database.',
});
handleDbError(
error,
logger,
'Database error in findPantryItemOwner',
{ pantryItemId },
{
defaultMessage: 'Failed to retrieve pantry item owner from database.',
},
);
}
}
@@ -189,9 +241,15 @@ export class PersonalizationRepository {
>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getBestSalePricesForAllUsers', {}, {
defaultMessage: 'Failed to get best sale prices for all users.',
});
handleDbError(
error,
logger,
'Database error in getBestSalePricesForAllUsers',
{},
{
defaultMessage: 'Failed to get best sale prices for all users.',
},
);
}
}
@@ -204,9 +262,15 @@ export class PersonalizationRepository {
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getAppliances', {}, {
defaultMessage: 'Failed to get appliances.',
});
handleDbError(
error,
logger,
'Database error in getAppliances',
{},
{
defaultMessage: 'Failed to get appliances.',
},
);
}
}
@@ -221,9 +285,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getDietaryRestrictions', {}, {
defaultMessage: 'Failed to get dietary restrictions.',
});
handleDbError(
error,
logger,
'Database error in getDietaryRestrictions',
{},
{
defaultMessage: 'Failed to get dietary restrictions.',
},
);
}
}
@@ -242,9 +312,15 @@ export class PersonalizationRepository {
const res = await this.db.query<DietaryRestriction>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getUserDietaryRestrictions', { userId }, {
defaultMessage: 'Failed to get user dietary restrictions.',
});
handleDbError(
error,
logger,
'Database error in getUserDietaryRestrictions',
{ userId },
{
defaultMessage: 'Failed to get user dietary restrictions.',
},
);
}
}
@@ -278,7 +354,10 @@ export class PersonalizationRepository {
logger,
'Database error in setUserDietaryRestrictions',
{ userId, restrictionIds },
{ fkMessage: 'One or more of the specified restriction IDs are invalid.', defaultMessage: 'Failed to set user dietary restrictions.' },
{
fkMessage: 'One or more of the specified restriction IDs are invalid.',
defaultMessage: 'Failed to set user dietary restrictions.',
},
);
}
}
@@ -309,10 +388,16 @@ export class PersonalizationRepository {
return newAppliances;
});
} catch (error) {
handleDbError(error, logger, 'Database error in setUserAppliances', { userId, applianceIds }, {
fkMessage: 'Invalid appliance ID',
defaultMessage: 'Failed to set user appliances.',
});
handleDbError(
error,
logger,
'Database error in setUserAppliances',
{ userId, applianceIds },
{
fkMessage: 'Invalid appliance ID',
defaultMessage: 'Failed to set user appliances.',
},
);
}
}
@@ -331,9 +416,15 @@ export class PersonalizationRepository {
const res = await this.db.query<Appliance>(query, [userId]);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getUserAppliances', { userId }, {
defaultMessage: 'Failed to get user appliances.',
});
handleDbError(
error,
logger,
'Database error in getUserAppliances',
{ userId },
{
defaultMessage: 'Failed to get user appliances.',
},
);
}
}
@@ -350,9 +441,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in findRecipesFromPantry', { userId }, {
defaultMessage: 'Failed to find recipes from pantry.',
});
handleDbError(
error,
logger,
'Database error in findRecipesFromPantry',
{ userId },
{
defaultMessage: 'Failed to find recipes from pantry.',
},
);
}
}
@@ -374,9 +471,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in recommendRecipesForUser', { userId, limit }, {
defaultMessage: 'Failed to recommend recipes.',
});
handleDbError(
error,
logger,
'Database error in recommendRecipesForUser',
{ userId, limit },
{
defaultMessage: 'Failed to recommend recipes.',
},
);
}
}
@@ -393,9 +496,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getBestSalePricesForUser', { userId }, {
defaultMessage: 'Failed to get best sale prices.',
});
handleDbError(
error,
logger,
'Database error in getBestSalePricesForUser',
{ userId },
{
defaultMessage: 'Failed to get best sale prices.',
},
);
}
}
@@ -415,9 +524,15 @@ export class PersonalizationRepository {
);
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in suggestPantryItemConversions', { pantryItemId }, {
defaultMessage: 'Failed to suggest pantry item conversions.',
});
handleDbError(
error,
logger,
'Database error in suggestPantryItemConversions',
{ pantryItemId },
{
defaultMessage: 'Failed to suggest pantry item conversions.',
},
);
}
}
@@ -434,9 +549,15 @@ export class PersonalizationRepository {
); // This is a standalone function, no change needed here.
return res.rows;
} catch (error) {
handleDbError(error, logger, 'Database error in getRecipesForUserDiets', { userId }, {
defaultMessage: 'Failed to get recipes compatible with user diet.',
});
handleDbError(
error,
logger,
'Database error in getRecipesForUserDiets',
{ userId },
{
defaultMessage: 'Failed to get recipes compatible with user diet.',
},
);
}
}
}

View File

@@ -139,7 +139,7 @@ export class FlyerAiProcessor {
logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.personalizationRepo.getAllMasterItems(logger);
const { items: masterItems } = await this.personalizationRepo.getAllMasterItems(logger);
logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`);
// BATCHING LOGIC: Process images in chunks to avoid hitting AI payload/token limits.

View File

@@ -182,7 +182,10 @@ describe('FlyerProcessingService', () => {
);
vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue({
items: [],
total: 0,
});
});
beforeEach(() => {
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');

View File

@@ -318,4 +318,183 @@ describe('Admin API Routes Integration Tests', () => {
expect(response.status).toBe(404);
});
});
describe('Queue Management Routes', () => {
describe('GET /api/admin/queues/status', () => {
it('should return queue status for all queues', async () => {
const response = await request
.get('/api/admin/queues/status')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
// Should have data for each queue
if (response.body.data.length > 0) {
const firstQueue = response.body.data[0];
expect(firstQueue).toHaveProperty('name');
expect(firstQueue).toHaveProperty('counts');
}
});
it('should forbid regular users from viewing queue status', async () => {
const response = await request
.get('/api/admin/queues/status')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe('Forbidden: Administrator access required.');
});
});
describe('POST /api/admin/trigger/analytics-report', () => {
it('should enqueue an analytics report job', async () => {
const response = await request
.post('/api/admin/trigger/analytics-report')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering analytics report', async () => {
const response = await request
.post('/api/admin/trigger/analytics-report')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/trigger/weekly-analytics', () => {
it('should enqueue a weekly analytics job', async () => {
const response = await request
.post('/api/admin/trigger/weekly-analytics')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering weekly analytics', async () => {
const response = await request
.post('/api/admin/trigger/weekly-analytics')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/trigger/daily-deal-check', () => {
it('should enqueue a daily deal check job', async () => {
const response = await request
.post('/api/admin/trigger/daily-deal-check')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('enqueued');
});
it('should forbid regular users from triggering daily deal check', async () => {
const response = await request
.post('/api/admin/trigger/daily-deal-check')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/system/clear-cache', () => {
it('should clear the application cache', async () => {
const response = await request
.post('/api/admin/system/clear-cache')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('cleared');
});
it('should forbid regular users from clearing cache', async () => {
const response = await request
.post('/api/admin/system/clear-cache')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('POST /api/admin/jobs/:queue/:id/retry', () => {
it('should return validation error for invalid queue name', async () => {
const response = await request
.post('/api/admin/jobs/invalid-queue-name/1/retry')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should return 404 for non-existent job', async () => {
const response = await request
.post('/api/admin/jobs/flyer-processing/999999999/retry')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
});
it('should forbid regular users from retrying jobs', async () => {
const response = await request
.post('/api/admin/jobs/flyer-processing/1/retry')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
});
describe('GET /api/admin/users', () => {
it('should return all users for admin', async () => {
const response = await request
.get('/api/admin/users')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should forbid regular users from listing all users', async () => {
const response = await request
.get('/api/admin/users')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
describe('GET /api/admin/review/flyers', () => {
it('should return pending review flyers for admin', async () => {
const response = await request
.get('/api/admin/review/flyers')
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should forbid regular users from viewing pending flyers', async () => {
const response = await request
.get('/api/admin/review/flyers')
.set('Authorization', `Bearer ${regularUserToken}`);
expect(response.status).toBe(403);
});
});
});

View File

@@ -206,4 +206,170 @@ describe('Authentication API Integration', () => {
);
}, 15000); // Increase timeout to handle multiple sequential requests
});
describe('Token Edge Cases', () => {
it('should reject empty Bearer token', async () => {
const response = await request.get('/api/users/profile').set('Authorization', 'Bearer ');
expect(response.status).toBe(401);
});
it('should reject token without dots (invalid JWT structure)', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer notavalidtoken');
expect(response.status).toBe(401);
});
it('should reject token with only 2 parts (missing signature)', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer header.payload');
expect(response.status).toBe(401);
});
it('should reject token with invalid signature', async () => {
// Valid structure but tampered signature
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.invalidsig');
expect(response.status).toBe(401);
});
it('should accept lowercase "bearer" scheme (case-insensitive)', async () => {
// First get a valid token
const loginResponse = await request
.post('/api/auth/login')
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
const token = loginResponse.body.data.token;
// Use lowercase "bearer"
const response = await request
.get('/api/users/profile')
.set('Authorization', `bearer ${token}`);
expect(response.status).toBe(200);
});
it('should reject Basic auth scheme', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=');
expect(response.status).toBe(401);
});
it('should reject missing Authorization header', async () => {
const response = await request.get('/api/users/profile');
expect(response.status).toBe(401);
});
});
describe('Login Security', () => {
it('should return same error for wrong password and non-existent user', async () => {
// Wrong password for existing user
const wrongPassResponse = await request
.post('/api/auth/login')
.send({ email: testUserEmail, password: 'wrong-password', rememberMe: false });
// Non-existent user
const nonExistentResponse = await request
.post('/api/auth/login')
.send({ email: 'nonexistent@example.com', password: 'any-password', rememberMe: false });
// Both should return 401 with the same message
expect(wrongPassResponse.status).toBe(401);
expect(nonExistentResponse.status).toBe(401);
expect(wrongPassResponse.body.error.message).toBe(nonExistentResponse.body.error.message);
expect(wrongPassResponse.body.error.message).toBe('Incorrect email or password.');
});
it('should return same response for forgot-password on existing and non-existing email', async () => {
// Request for existing user
const existingResponse = await request
.post('/api/auth/forgot-password')
.send({ email: testUserEmail });
// Request for non-existing user
const nonExistingResponse = await request
.post('/api/auth/forgot-password')
.send({ email: 'nonexistent-user@example.com' });
// Both should return 200 with similar success message (prevents email enumeration)
expect(existingResponse.status).toBe(200);
expect(nonExistingResponse.status).toBe(200);
expect(existingResponse.body.success).toBe(true);
expect(nonExistingResponse.body.success).toBe(true);
});
it('should return validation error for missing login fields', async () => {
const response = await request.post('/api/auth/login').send({ email: testUserEmail }); // Missing password
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('Password Reset', () => {
it('should reject reset with invalid token', async () => {
const response = await request.post('/api/auth/reset-password').send({
token: 'invalid-reset-token',
newPassword: TEST_PASSWORD,
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Registration Validation', () => {
it('should reject duplicate email registration', async () => {
const response = await request.post('/api/auth/register').send({
email: testUserEmail, // Already exists
password: TEST_PASSWORD,
full_name: 'Duplicate User',
});
expect(response.status).toBe(409); // CONFLICT
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('CONFLICT');
});
it('should reject invalid email format', async () => {
const response = await request.post('/api/auth/register').send({
email: 'not-an-email',
password: TEST_PASSWORD,
full_name: 'Invalid Email User',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should reject weak password', async () => {
const response = await request.post('/api/auth/register').send({
email: `weak-pass-${Date.now()}@example.com`,
password: '123456', // Too weak
full_name: 'Weak Password User',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Refresh Token Edge Cases', () => {
it('should return error when refresh token cookie is missing', async () => {
const response = await request.post('/api/auth/refresh-token');
expect(response.status).toBe(401);
expect(response.body.error.message).toBe('Refresh token not found.');
});
});
});

View File

@@ -143,6 +143,67 @@ describe('Budget API Routes Integration Tests', () => {
expect(response.status).toBe(401);
});
it('should reject period="yearly" (only weekly/monthly allowed)', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Yearly Budget',
amount_cents: 100000,
period: 'yearly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should reject negative amount_cents', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Negative Budget',
amount_cents: -500,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should reject invalid date format', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Invalid Date Budget',
amount_cents: 10000,
period: 'weekly',
start_date: '01-01-2025', // Wrong format, should be YYYY-MM-DD
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should require name field', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('PUT /api/budgets/:id', () => {

View File

@@ -0,0 +1,388 @@
// src/tests/integration/data-integrity.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { getPool } from '../../services/db/connection.db';
/**
* @vitest-environment node
*
* Integration tests for data integrity: FK constraints, cascades, unique constraints, and CHECK constraints.
* These tests verify that database-level constraints are properly enforced.
*/
describe('Data Integrity Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let adminToken: string;
let adminUser: UserProfile;
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create an admin user for admin-level tests
const { user, token } = await createAndLoginUser({
email: `data-integrity-admin-${Date.now()}@example.com`,
fullName: 'Data Integrity Admin',
role: 'admin',
request,
});
adminUser = user;
adminToken = token;
});
afterAll(async () => {
vi.unstubAllEnvs();
// Clean up admin user
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUser.user.user_id]);
});
describe('Cascade Deletes', () => {
it('should cascade delete shopping lists when user is deleted', async () => {
// Create a test user with shopping lists
const { token } = await createAndLoginUser({
email: `cascade-test-${Date.now()}@example.com`,
fullName: 'Cascade Test User',
request,
});
// Create some shopping lists
const listResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Cascade Test List' });
expect(listResponse.status).toBe(201);
const listId = listResponse.body.data.shopping_list_id;
// Verify list exists
const checkListBefore = await getPool().query(
'SELECT * FROM public.shopping_lists WHERE shopping_list_id = $1',
[listId],
);
expect(checkListBefore.rows.length).toBe(1);
// Delete the user account
const deleteResponse = await request
.delete('/api/users/account')
.set('Authorization', `Bearer ${token}`)
.send({ password: TEST_PASSWORD });
expect(deleteResponse.status).toBe(200);
// Verify list was cascade deleted
const checkListAfter = await getPool().query(
'SELECT * FROM public.shopping_lists WHERE shopping_list_id = $1',
[listId],
);
expect(checkListAfter.rows.length).toBe(0);
});
it('should cascade delete budgets when user is deleted', async () => {
// Create a test user with budgets
const { token } = await createAndLoginUser({
email: `budget-cascade-${Date.now()}@example.com`,
fullName: 'Budget Cascade User',
request,
});
// Create a budget
const budgetResponse = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Cascade Test Budget',
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(budgetResponse.status).toBe(201);
const budgetId = budgetResponse.body.data.budget_id;
// Verify budget exists
const checkBefore = await getPool().query(
'SELECT * FROM public.budgets WHERE budget_id = $1',
[budgetId],
);
expect(checkBefore.rows.length).toBe(1);
// Delete the user account
const deleteResponse = await request
.delete('/api/users/account')
.set('Authorization', `Bearer ${token}`)
.send({ password: TEST_PASSWORD });
expect(deleteResponse.status).toBe(200);
// Verify budget was cascade deleted
const checkAfter = await getPool().query(
'SELECT * FROM public.budgets WHERE budget_id = $1',
[budgetId],
);
expect(checkAfter.rows.length).toBe(0);
});
it('should cascade delete shopping list items when list is deleted', async () => {
// Create a test user
const { user, token } = await createAndLoginUser({
email: `item-cascade-${Date.now()}@example.com`,
fullName: 'Item Cascade User',
request,
});
// Create a shopping list
const listResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Item Cascade List' });
expect(listResponse.status).toBe(201);
const listId = listResponse.body.data.shopping_list_id;
// Add an item to the list
const itemResponse = await request
.post(`/api/users/shopping-lists/${listId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ customItemName: 'Test Item', quantity: 1 });
expect(itemResponse.status).toBe(201);
const itemId = itemResponse.body.data.shopping_list_item_id;
// Verify item exists
const checkItemBefore = await getPool().query(
'SELECT * FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
[itemId],
);
expect(checkItemBefore.rows.length).toBe(1);
// Delete the shopping list
const deleteResponse = await request
.delete(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${token}`);
expect(deleteResponse.status).toBe(204);
// Verify item was cascade deleted
const checkItemAfter = await getPool().query(
'SELECT * FROM public.shopping_list_items WHERE shopping_list_item_id = $1',
[itemId],
);
expect(checkItemAfter.rows.length).toBe(0);
// Clean up user
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('Admin Self-Deletion Prevention', () => {
it('should prevent admin from deleting their own account via admin route', async () => {
const response = await request
.delete(`/api/admin/users/${adminUser.user.user_id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.message).toContain('cannot delete');
});
});
describe('FK Constraint Enforcement', () => {
it('should return error when adding item with invalid shopping list ID', async () => {
// Create a test user
const { user, token } = await createAndLoginUser({
email: `fk-test-${Date.now()}@example.com`,
fullName: 'FK Test User',
request,
});
// Try to add item to non-existent list
const response = await request
.post('/api/users/shopping-lists/999999/items')
.set('Authorization', `Bearer ${token}`)
.send({ customItemName: 'Invalid List Item', quantity: 1 });
expect(response.status).toBe(404);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should enforce FK constraints at database level', async () => {
// Try to insert directly into DB with invalid FK
try {
await getPool().query(
`INSERT INTO public.shopping_list_items (shopping_list_id, custom_item_name, quantity)
VALUES (999999999, 'Direct Insert Test', 1)`,
);
// If we get here, the constraint didn't fire
expect.fail('Expected FK constraint violation');
} catch (error) {
// Expected - FK constraint should prevent this
expect(error).toBeDefined();
expect((error as Error).message).toContain('violates foreign key constraint');
}
});
});
describe('Unique Constraints', () => {
it('should return CONFLICT for duplicate email registration', async () => {
const email = `unique-test-${Date.now()}@example.com`;
// Register first user
const firstResponse = await request
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'First User' });
expect(firstResponse.status).toBe(201);
// Try to register second user with same email
const secondResponse = await request
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Second User' });
expect(secondResponse.status).toBe(409); // CONFLICT
expect(secondResponse.body.success).toBe(false);
expect(secondResponse.body.error.code).toBe('CONFLICT');
// Clean up first user
const userId = firstResponse.body.data.userprofile.user.user_id;
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]);
});
});
describe('CHECK Constraints', () => {
it('should reject budget with invalid period via API', async () => {
const { user, token } = await createAndLoginUser({
email: `check-test-${Date.now()}@example.com`,
fullName: 'Check Constraint User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Invalid Period Budget',
amount_cents: 10000,
period: 'yearly', // Invalid - only weekly/monthly allowed
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should reject budget with negative amount via API', async () => {
const { user, token } = await createAndLoginUser({
email: `amount-check-${Date.now()}@example.com`,
fullName: 'Amount Check User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
name: 'Negative Amount Budget',
amount_cents: -100, // Invalid - must be positive
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
it('should enforce CHECK constraints at database level', async () => {
// Try to insert directly with invalid period
const { user, token: _ } = await createAndLoginUser({
email: `db-check-${Date.now()}@example.com`,
fullName: 'DB Check User',
request,
});
try {
await getPool().query(
`INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date)
VALUES ($1, 'Direct Insert', 10000, 'yearly', '2025-01-01')`,
[user.user.user_id],
);
// If we get here, the constraint didn't fire
expect.fail('Expected CHECK constraint violation');
} catch (error) {
// Expected - CHECK constraint should prevent this
expect(error).toBeDefined();
expect((error as Error).message).toContain('violates check constraint');
}
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('NOT NULL Constraints', () => {
it('should require budget name via API', async () => {
const { user, token } = await createAndLoginUser({
email: `notnull-test-${Date.now()}@example.com`,
fullName: 'NotNull Test User',
request,
});
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${token}`)
.send({
// name is missing - required field
amount_cents: 10000,
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
// Clean up
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user.user_id]);
});
});
describe('Transaction Rollback', () => {
it('should rollback partial inserts on constraint violation', async () => {
const pool = getPool();
const client = await pool.connect();
try {
await client.query('BEGIN');
// First insert should work
const { user } = await createAndLoginUser({
email: `transaction-test-${Date.now()}@example.com`,
fullName: 'Transaction Test User',
request,
});
await client.query(
`INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, 'Transaction Test List') RETURNING shopping_list_id`,
[user.user.user_id],
);
// This should fail due to FK constraint
await client.query(
`INSERT INTO public.shopping_list_items (shopping_list_id, custom_item_name, quantity)
VALUES (999999999, 'Should Fail', 1)`,
);
await client.query('COMMIT');
expect.fail('Expected transaction to fail');
} catch {
await client.query('ROLLBACK');
// Expected - transaction should have rolled back
} finally {
client.release();
}
});
});
});

View File

@@ -0,0 +1,93 @@
// src/tests/integration/deals.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
/**
* @vitest-environment node
*
* Integration tests for the Deals API routes.
* These routes were previously unmounted and are now available at /api/deals.
*/
describe('Deals API Routes Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
const createdUserIds: string[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create a user for the tests
const { user, token } = await createAndLoginUser({
email: `deals-user-${Date.now()}@example.com`,
fullName: 'Deals Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
});
afterAll(async () => {
vi.unstubAllEnvs();
await cleanupDb({
userIds: createdUserIds,
});
});
describe('GET /api/deals/best-watched-prices', () => {
it('should require authentication', async () => {
const response = await request.get('/api/deals/best-watched-prices');
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});
it('should return watched item deals for authenticated user', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should return empty array when user has no watched items', async () => {
// New test user with no watched items
const { token: newUserToken, user: newUser } = await createAndLoginUser({
email: `deals-no-watch-${Date.now()}@example.com`,
fullName: 'No Watch User',
request,
});
createdUserIds.push(newUser.user.user_id);
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${newUserToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
it('should reject invalid JWT token', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', 'Bearer invalid.token.here');
expect(response.status).toBe(401);
});
it('should reject missing Bearer prefix', async () => {
const response = await request
.get('/api/deals/best-watched-prices')
.set('Authorization', authToken);
expect(response.status).toBe(401);
});
});
});

View File

@@ -0,0 +1,360 @@
// src/tests/integration/edge-cases.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
/**
* @vitest-environment node
*
* Integration tests for edge cases discovered during manual frontend testing.
* These tests cover file upload validation, input sanitization, and authorization boundaries.
*/
describe('Edge Cases Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
let otherUserToken: string;
const createdUserIds: string[] = [];
const createdShoppingListIds: number[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create primary test user
const { user, token } = await createAndLoginUser({
email: `edge-case-user-${Date.now()}@example.com`,
fullName: 'Edge Case Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
// Create secondary test user for cross-user tests
const { user: user2, token: token2 } = await createAndLoginUser({
email: `edge-case-other-${Date.now()}@example.com`,
fullName: 'Other Test User',
request,
});
otherUserToken = token2;
createdUserIds.push(user2.user.user_id);
});
afterAll(async () => {
vi.unstubAllEnvs();
await cleanupDb({
userIds: createdUserIds,
shoppingListIds: createdShoppingListIds,
});
});
describe('File Upload Validation', () => {
describe('Checksum Validation', () => {
it('should reject missing checksum', async () => {
// Create a small valid PNG
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
// Skip if test asset doesn't exist
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toContain('checksum');
});
it('should reject invalid checksum format (non-hex)', async () => {
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath)
.field('checksum', 'not-a-valid-hex-checksum-at-all!!!!');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should reject short checksum (not 64 characters)', async () => {
const testImagePath = path.join(__dirname, '../assets/flyer-test.png');
if (!fs.existsSync(testImagePath)) {
return;
}
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyerFile', testImagePath)
.field('checksum', 'abc123'); // Too short
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('File Type Validation', () => {
it('should require flyerFile field', async () => {
const checksum = crypto.randomBytes(32).toString('hex');
const response = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${authToken}`)
.field('checksum', checksum);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.message).toContain('file');
});
});
});
describe('Input Sanitization', () => {
describe('Shopping List Names', () => {
it('should accept unicode characters and emojis', async () => {
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Grocery List 🛒 日本語 émoji' });
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Grocery List 🛒 日本語 émoji');
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
it('should store XSS payloads as-is (frontend must escape)', async () => {
const xssPayload = '<script>alert("xss")</script>';
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: xssPayload });
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
// The payload is stored as-is - frontend is responsible for escaping
expect(response.body.data.name).toBe(xssPayload);
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
it('should reject null bytes in JSON', async () => {
// Null bytes in JSON should be rejected by the JSON parser
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'application/json')
.send('{"name":"test\u0000value"}');
// JSON parser may reject this or sanitize it
expect([400, 201]).toContain(response.status);
});
});
});
describe('Authorization Boundaries', () => {
describe('Cross-User Resource Access', () => {
it("should return 404 (not 403) for accessing another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Private List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to access it as the other user
const accessResponse = await request
.get(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`);
// Should return 404 to hide resource existence
expect(accessResponse.status).toBe(404);
expect(accessResponse.body.success).toBe(false);
expect(accessResponse.body.error.code).toBe('NOT_FOUND');
});
it("should return 404 when trying to update another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Another Private List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to update it as the other user
const updateResponse = await request
.put(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`)
.send({ name: 'Hacked List' });
// Should return 404 to hide resource existence
expect(updateResponse.status).toBe(404);
});
it("should return 404 when trying to delete another user's shopping list", async () => {
// Create a shopping list as the primary user
const createResponse = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Delete Test List' });
expect(createResponse.status).toBe(201);
const listId = createResponse.body.data.shopping_list_id;
createdShoppingListIds.push(listId);
// Try to delete it as the other user
const deleteResponse = await request
.delete(`/api/users/shopping-lists/${listId}`)
.set('Authorization', `Bearer ${otherUserToken}`);
// Should return 404 to hide resource existence
expect(deleteResponse.status).toBe(404);
});
});
describe('SQL Injection Prevention', () => {
it('should safely handle SQL injection in query params', async () => {
// Attempt SQL injection in limit param
const response = await request
.get('/api/personalization/master-items')
.query({ limit: '10; DROP TABLE users; --' });
// Should either return normal data or a validation error, not crash
expect([200, 400]).toContain(response.status);
expect(response.body).toBeDefined();
});
it('should safely handle SQL injection in search params', async () => {
// Attempt SQL injection in flyer search
const response = await request.get('/api/flyers').query({
search: "'; DROP TABLE flyers; --",
});
// Should handle safely
expect([200, 400]).toContain(response.status);
});
});
});
describe('API Error Handling', () => {
it('should return 404 for non-existent resources with clear message', async () => {
const response = await request
.get('/api/flyers/99999999')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('NOT_FOUND');
});
it('should return validation error for malformed JSON body', async () => {
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'application/json')
.send('{ invalid json }');
expect(response.status).toBe(400);
});
it('should return validation error for missing required fields', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({}); // Empty body
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('should return validation error for invalid data types', async () => {
const response = await request
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Test Budget',
amount_cents: 'not-a-number', // Should be number
period: 'weekly',
start_date: '2025-01-01',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('Concurrent Operations', () => {
it('should handle concurrent writes without data loss', async () => {
// Create 5 shopping lists concurrently
const promises = Array.from({ length: 5 }, (_, i) =>
request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: `Concurrent List ${i + 1}` }),
);
const results = await Promise.all(promises);
// All should succeed
results.forEach((response) => {
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
if (response.body.data.shopping_list_id) {
createdShoppingListIds.push(response.body.data.shopping_list_id);
}
});
// Verify all lists were created
const listResponse = await request
.get('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const lists = listResponse.body.data;
const concurrentLists = lists.filter((l: { name: string }) =>
l.name.startsWith('Concurrent List'),
);
expect(concurrentLists.length).toBe(5);
});
it('should handle concurrent reads without errors', async () => {
// Make 10 concurrent read requests
const promises = Array.from({ length: 10 }, () =>
request.get('/api/personalization/master-items'),
);
const results = await Promise.all(promises);
// All should succeed
results.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
});
});
});

View File

@@ -145,4 +145,87 @@ describe('Notification API Routes Integration Tests', () => {
expect(Number(finalUnreadCountRes.rows[0].count)).toBe(0);
});
});
describe('Job Status Polling', () => {
describe('GET /api/ai/jobs/:id/status', () => {
it('should return 404 for non-existent job', async () => {
const response = await request.get('/api/ai/jobs/nonexistent-job-id/status');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('NOT_FOUND');
});
it('should be accessible without authentication (public endpoint)', async () => {
// This verifies that job status can be polled without auth
// This is important for UX where users may poll status from frontend
const response = await request.get('/api/ai/jobs/test-job-123/status');
// Should return 404 (job not found) rather than 401 (unauthorized)
expect(response.status).toBe(404);
expect(response.body.error.code).toBe('NOT_FOUND');
});
});
});
describe('DELETE /api/users/notifications/:notificationId', () => {
it('should delete a specific notification', async () => {
// First create a notification to delete
const createResult = await getPool().query(
`INSERT INTO public.notifications (user_id, content, is_read, link_url)
VALUES ($1, 'Notification to delete', false, '/test')
RETURNING notification_id`,
[testUser.user.user_id],
);
const notificationId = createResult.rows[0].notification_id;
const response = await request
.delete(`/api/users/notifications/${notificationId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(204);
// Verify it was deleted
const verifyResult = await getPool().query(
`SELECT * FROM public.notifications WHERE notification_id = $1`,
[notificationId],
);
expect(verifyResult.rows.length).toBe(0);
});
it('should return 404 for non-existent notification', async () => {
const response = await request
.delete('/api/users/notifications/999999')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(404);
});
it("should prevent deleting another user's notification", async () => {
// Create another user
const { user: otherUser, token: otherToken } = await createAndLoginUser({
email: `notification-other-${Date.now()}@example.com`,
fullName: 'Other Notification User',
request,
});
createdUserIds.push(otherUser.user.user_id);
// Create a notification for the original user
const createResult = await getPool().query(
`INSERT INTO public.notifications (user_id, content, is_read, link_url)
VALUES ($1, 'Private notification', false, '/test')
RETURNING notification_id`,
[testUser.user.user_id],
);
const notificationId = createResult.rows[0].notification_id;
// Try to delete it as the other user
const response = await request
.delete(`/api/users/notifications/${notificationId}`)
.set('Authorization', `Bearer ${otherToken}`);
// Should return 404 (not 403) to hide existence
expect(response.status).toBe(404);
});
});
});

View File

@@ -0,0 +1,243 @@
// src/tests/integration/reactions.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import supertest from 'supertest';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { getPool } from '../../services/db/connection.db';
/**
* @vitest-environment node
*
* Integration tests for the Reactions API routes.
* These routes were previously unmounted and are now available at /api/reactions.
*/
describe('Reactions API Routes Integration Tests', () => {
let request: ReturnType<typeof supertest>;
let authToken: string;
let testRecipeId: number;
const createdUserIds: string[] = [];
const createdReactionIds: number[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default;
request = supertest(app);
// Create a user for the tests
const { user, token } = await createAndLoginUser({
email: `reactions-user-${Date.now()}@example.com`,
fullName: 'Reactions Test User',
request,
});
authToken = token;
createdUserIds.push(user.user.user_id);
// Get an existing recipe ID from the seed data to use for reactions
const recipeResult = await getPool().query(`SELECT recipe_id FROM public.recipes LIMIT 1`);
if (recipeResult.rows.length > 0) {
testRecipeId = recipeResult.rows[0].recipe_id;
} else {
// Create a minimal recipe if none exist
const newRecipe = await getPool().query(
`INSERT INTO public.recipes (title, description, instructions, prep_time_minutes, cook_time_minutes, servings)
VALUES ('Test Recipe for Reactions', 'A test recipe', 'Test instructions', 10, 20, 4)
RETURNING recipe_id`,
);
testRecipeId = newRecipe.rows[0].recipe_id;
}
});
afterAll(async () => {
vi.unstubAllEnvs();
// Clean up reactions created during tests
if (createdReactionIds.length > 0) {
await getPool().query('DELETE FROM public.reactions WHERE reaction_id = ANY($1::int[])', [
createdReactionIds,
]);
}
await cleanupDb({
userIds: createdUserIds,
});
});
describe('GET /api/reactions', () => {
it('should return reactions (public endpoint)', async () => {
const response = await request.get('/api/reactions');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should filter reactions by entityType', async () => {
const response = await request.get('/api/reactions').query({ entityType: 'recipe' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
it('should filter reactions by entityId', async () => {
const response = await request
.get('/api/reactions')
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
});
});
describe('GET /api/reactions/summary', () => {
it('should return reaction summary for an entity', async () => {
const response = await request
.get('/api/reactions/summary')
.query({ entityType: 'recipe', entityId: String(testRecipeId) });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
// Summary should have reaction counts
expect(response.body.data).toBeDefined();
});
it('should return 400 when entityType is missing', async () => {
const response = await request
.get('/api/reactions/summary')
.query({ entityId: String(testRecipeId) });
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 when entityId is missing', async () => {
const response = await request.get('/api/reactions/summary').query({ entityType: 'recipe' });
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('POST /api/reactions/toggle', () => {
it('should require authentication', async () => {
const response = await request.post('/api/reactions/toggle').send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(401);
});
it('should add a reaction when none exists', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.message).toBe('Reaction added.');
expect(response.body.data.reaction).toBeDefined();
// Track for cleanup
if (response.body.data.reaction?.reaction_id) {
createdReactionIds.push(response.body.data.reaction.reaction_id);
}
});
it('should remove the reaction when toggled again', async () => {
// First add the reaction
const addResponse = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'love', // Use different type to not conflict
});
expect(addResponse.status).toBe(201);
if (addResponse.body.data.reaction?.reaction_id) {
createdReactionIds.push(addResponse.body.data.reaction.reaction_id);
}
// Then toggle it off
const removeResponse = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'love',
});
expect(removeResponse.status).toBe(200);
expect(removeResponse.body.success).toBe(true);
expect(removeResponse.body.data.message).toBe('Reaction removed.');
});
it('should return 400 for missing entity_type', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_id: String(testRecipeId),
reaction_type: 'like',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 for missing entity_id', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
reaction_type: 'like',
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should return 400 for missing reaction_type', async () => {
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
it('should accept entity_id as string (required format)', async () => {
// entity_id must be a string per the Zod schema
const response = await request
.post('/api/reactions/toggle')
.set('Authorization', `Bearer ${authToken}`)
.send({
entity_type: 'recipe',
entity_id: String(testRecipeId),
reaction_type: 'helpful',
});
// Should succeed (201 for add, 200 for remove)
expect([200, 201]).toContain(response.status);
expect(response.body.success).toBe(true);
if (response.body.data.reaction?.reaction_id) {
createdReactionIds.push(response.body.data.reaction.reaction_id);
}
});
});
});

View File

@@ -232,6 +232,88 @@ describe('Recipe API Routes Integration Tests', () => {
createdRecipeIds.push(forkedRecipe.recipe_id);
});
it('should allow forking seed recipes (null user_id)', async () => {
// First, find or create a seed recipe (one with null user_id)
let seedRecipeId: number;
const seedRecipeResult = await getPool().query(
`SELECT recipe_id FROM public.recipes WHERE user_id IS NULL LIMIT 1`,
);
if (seedRecipeResult.rows.length > 0) {
seedRecipeId = seedRecipeResult.rows[0].recipe_id;
} else {
// Create a seed recipe if none exist
const createSeedResult = await getPool().query(
`INSERT INTO public.recipes (name, instructions, user_id, status, description)
VALUES ('Seed Recipe for Fork Test', 'Seed recipe instructions.', NULL, 'public', 'A seed recipe.')
RETURNING recipe_id`,
);
seedRecipeId = createSeedResult.rows[0].recipe_id;
createdRecipeIds.push(seedRecipeId);
}
// Fork the seed recipe - this should succeed
const response = await request
.post(`/api/recipes/${seedRecipeId}/fork`)
.set('Authorization', `Bearer ${authToken}`);
// Forking should work - seed recipes should be forkable
expect(response.status).toBe(201);
const forkedRecipe: Recipe = response.body.data;
expect(forkedRecipe.original_recipe_id).toBe(seedRecipeId);
expect(forkedRecipe.user_id).toBe(testUser.user.user_id);
// Track for cleanup
createdRecipeIds.push(forkedRecipe.recipe_id);
});
describe('GET /api/recipes/:recipeId/comments', () => {
it('should return comments for a recipe', async () => {
// First add a comment
await request
.post(`/api/recipes/${testRecipe.recipe_id}/comments`)
.set('Authorization', `Bearer ${authToken}`)
.send({ content: 'Test comment for GET request' });
// Now fetch comments
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toBeInstanceOf(Array);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify comment structure
const comment = response.body.data[0];
expect(comment).toHaveProperty('recipe_comment_id');
expect(comment).toHaveProperty('content');
expect(comment).toHaveProperty('user_id');
expect(comment).toHaveProperty('recipe_id');
});
it('should return empty array for recipe with no comments', async () => {
// Create a recipe specifically with no comments
const createRes = await request
.post('/api/users/recipes')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Recipe With No Comments',
instructions: 'No comments here.',
description: 'Testing empty comments.',
});
const noCommentsRecipe: Recipe = createRes.body.data;
createdRecipeIds.push(noCommentsRecipe.recipe_id);
// Fetch comments for this recipe
const response = await request.get(`/api/recipes/${noCommentsRecipe.recipe_id}/comments`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
});
describe('POST /api/recipes/suggest', () => {
it('should return a recipe suggestion based on ingredients', async () => {
const ingredients = ['chicken', 'rice', 'broccoli'];

View File

@@ -4,15 +4,16 @@ import { z } from 'zod';
/**
* A Zod schema for a required, non-empty string.
* @param message The error message to display if the string is empty or missing.
* @param maxLength Optional maximum length (defaults to 255).
* @returns A Zod string schema.
*/
export const requiredString = (message: string) =>
export const requiredString = (message: string, maxLength = 255) =>
z.preprocess(
// If the value is null or undefined, preprocess it to an empty string.
// This ensures that the subsequent `.min(1)` check will catch missing required fields.
(val) => val ?? '',
// Now, validate that the (potentially preprocessed) value is a string that, after trimming, has at least 1 character.
z.string().trim().min(1, message),
z.string().trim().min(1, message).max(maxLength, `Must be ${maxLength} characters or less.`),
);
/**
@@ -76,7 +77,7 @@ export const optionalNumeric = (
// the .optional() and .default() logic for null inputs. We want null to be
// treated as "not provided", just like undefined.
const schema = z.preprocess((val) => (val === null ? undefined : val), optionalNumberSchema);
if (options.default !== undefined) return schema.default(options.default);
return schema;
@@ -89,7 +90,6 @@ export const optionalNumeric = (
*/
export const optionalDate = (message?: string) => z.string().date(message).optional();
/**
* Creates a Zod schema for an optional boolean query parameter that is coerced from a string.
* Handles 'true', '1' as true and 'false', '0' as false.