diff --git a/package-lock.json b/package-lock.json index 17e5c4d6..ce61cf7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.2", "lucide-react": "^0.555.0", "multer": "^2.0.2", + "node-cron": "^4.2.1", "nodemailer": "^7.0.10", "passport": "^0.7.0", "passport-github2": "^0.1.12", @@ -11227,6 +11228,15 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", diff --git a/package.json b/package.json index 6246ef4a..14dfbfad 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "jsonwebtoken": "^9.0.2", "lucide-react": "^0.555.0", "multer": "^2.0.2", + "node-cron": "^4.2.1", "nodemailer": "^7.0.10", "passport": "^0.7.0", "passport-github2": "^0.1.12", diff --git a/src/routes/gamification.test.ts b/src/routes/gamification.test.ts index 4544a989..2daa9b35 100644 --- a/src/routes/gamification.test.ts +++ b/src/routes/gamification.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import supertest from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; import gamificationRouter from './gamification'; -import * as db from '../services/db'; +import * as db from '../services/db/index'; import { UserProfile, Achievement, UserAchievement } from '../types'; // Mock the entire db service @@ -25,10 +25,14 @@ vi.mock('./passport', () => ({ default: { authenticate: vi.fn(), }, + // Mock the named export 'isAdmin' + isAdmin: vi.fn(), })); import passport from './passport'; +import { isAdmin } from './passport'; const mockedAuthenticate = vi.mocked(passport.authenticate); +const mockedIsAdmin = vi.mocked(isAdmin); // Create a minimal Express app to host our router const app = express(); @@ -42,6 +46,12 @@ describe('Gamification Routes (/api/achievements)', () => { role: 'user', points: 100, }; + const mockAdminProfile: UserProfile = { + user_id: 'admin-456', + user: { user_id: 'admin-456', email: 'admin@test.com' }, + role: 'admin', + points: 999, + }; beforeEach(() => { vi.clearAllMocks(); @@ -49,6 +59,9 @@ describe('Gamification Routes (/api/achievements)', () => { mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => { res.status(401).json({ message: 'Unauthorized' }); }); + mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => { + res.status(403).json({ message: 'Forbidden' }); + }); }); describe('GET /', () => { @@ -90,4 +103,42 @@ describe('Gamification Routes (/api/achievements)', () => { expect(mockedDb.getUserAchievements).toHaveBeenCalledWith('user-123'); }); }); -}); \ No newline at end of file + + describe('POST /award', () => { + const awardPayload = { userId: 'user-789', achievementName: 'Test Award' }; + + it('should return 401 Unauthorized if user is not authenticated', async () => { + const response = await supertest(app).post('/api/achievements/award').send(awardPayload); + expect(response.status).toBe(401); + }); + + it('should return 403 Forbidden if authenticated user is not an admin', async () => { + // Mock a regular authenticated user + mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => { + req.user = mockUserProfile; + next(); + }); + // Let the default isAdmin mock run, which denies access + + const response = await supertest(app).post('/api/achievements/award').send(awardPayload); + expect(response.status).toBe(403); + }); + + it('should successfully award an achievement when user is an admin', async () => { + // Mock an authenticated admin user + mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => { + req.user = mockAdminProfile; + next(); + }); + mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next()); + mockedDb.awardAchievement.mockResolvedValue(undefined); + + const response = await supertest(app).post('/api/achievements/award').send(awardPayload); + + expect(response.status).toBe(200); + expect(response.body.message).toContain('Successfully awarded'); + expect(mockedDb.awardAchievement).toHaveBeenCalledTimes(1); + expect(mockedDb.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName); + }); + }); +}); diff --git a/src/routes/gamification.ts b/src/routes/gamification.ts index c64e6ce8..5f641f6f 100644 --- a/src/routes/gamification.ts +++ b/src/routes/gamification.ts @@ -1,7 +1,7 @@ // src/routes/gamification.ts import express, { Request, Response } from 'express'; -import passport from 'passport'; -import { getAllAchievements, getUserAchievements } from '../services/db'; +import passport, { isAdmin } from './passport'; +import { getAllAchievements, getUserAchievements, awardAchievement } from '../services/db'; import { logger } from '../services/logger'; import { User } from '../types'; @@ -40,4 +40,29 @@ router.get( } ); +/** + * POST /api/achievements/award - Manually award an achievement to a user. + * This is an admin-only endpoint. + */ +router.post( + '/award', + passport.authenticate('jwt', { session: false }), + isAdmin, + async (req: Request, res: Response) => { + const { userId, achievementName } = req.body; + + if (!userId || !achievementName) { + return res.status(400).json({ message: 'Both userId and achievementName are required.' }); + } + + try { + await awardAchievement(userId, achievementName); + res.status(200).json({ message: `Successfully awarded '${achievementName}' to user ${userId}.` }); + } catch (error) { + logger.error('Error awarding achievement via admin endpoint:', { error, userId, achievementName }); + res.status(500).json({ message: 'Failed to award achievement.' }); + } + } +); + export default router; \ No newline at end of file diff --git a/src/routes/passport.test.ts b/src/routes/passport.test.ts index b454f5fc..12004c9f 100644 --- a/src/routes/passport.test.ts +++ b/src/routes/passport.test.ts @@ -1,8 +1,8 @@ // src/routes/passport.test.ts import { describe, it, expect, vi, beforeEach, type Mocked, type Mock } from 'vitest'; -import type { Strategy as LocalStrategy } from 'passport-local'; import { Strategy as JwtStrategy } from 'passport-jwt'; import { Request, Response, NextFunction } from 'express'; +import * as bcrypt from 'bcrypt'; import * as db from '../services/db'; // Mock dependencies before importing the passport configuration @@ -30,7 +30,7 @@ describe('Passport Configuration', () => { const mockUser: Awaited> = { user_id: 'user-1', email: 'test@test.com', - password_hash: await require('bcrypt').hash('password123', 10), + password_hash: await bcrypt.hash('password123', 10), failed_login_attempts: 0, last_failed_login: null, }; diff --git a/src/services/aiApiClient.test.ts b/src/services/aiApiClient.test.ts index ec481682..4b8fed37 100644 --- a/src/services/aiApiClient.test.ts +++ b/src/services/aiApiClient.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import * as apiClient from './apiClient'; import * as aiApiClient from './aiApiClient'; -import { MasterGroceryItem } from '../types'; +import { MasterGroceryItem, FlyerItem } from '../types'; // Mock the underlying apiClient to isolate our tests to the aiApiClient logic. vi.mock('./apiClient'); @@ -14,6 +14,38 @@ describe('AI API Client', () => { vi.clearAllMocks(); }); + describe('isImageAFlyer', () => { + it('should construct FormData and call apiFetchWithAuth correctly', async () => { + const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' }); + await aiApiClient.isImageAFlyer(file, 'test-token'); + + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); + const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0]; + + expect(url).toBe('/ai/check-flyer'); + expect(options.method).toBe('POST'); + expect(token).toBe('test-token'); + expect(options.body).toBeInstanceOf(FormData); + expect((options.body as FormData).get('image')).toBe(file); + }); + }); + + describe('extractAddressFromImage', () => { + it('should construct FormData and call apiFetchWithAuth correctly', async () => { + const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' }); + await aiApiClient.extractAddressFromImage(file, 'test-token'); + + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); + const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0]; + + expect(url).toBe('/ai/extract-address'); + expect(options.method).toBe('POST'); + expect(token).toBe('test-token'); + expect(options.body).toBeInstanceOf(FormData); + expect((options.body as FormData).get('image')).toBe(file); + }); + }); + describe('extractCoreDataFromImage', () => { it('should construct FormData and call apiFetchWithAuth correctly', async () => { const files = [new File([''], 'flyer1.jpg', { type: 'image/jpeg' })]; @@ -35,72 +67,99 @@ describe('AI API Client', () => { }); }); - describe('planTripWithMaps', () => { - it('should call apiFetchWithAuth with the correct JSON body', async () => { - const mockLocation: GeolocationCoordinates = { - latitude: 48.4284, - longitude: -123.3656, - accuracy: 0, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null, - toJSON: () => ({}), // Add the missing toJSON method - }; - - await aiApiClient.planTripWithMaps([], undefined, mockLocation, 'test-token'); - - expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); - expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith( - '/ai/plan-trip', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items: [], store: undefined, userLocation: mockLocation }), - }, - 'test-token' - ); - }); - }); - - describe('rescanImageArea', () => { - it('should construct FormData and call apiFetchWithAuth for rescanning', async () => { - const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' }); - const cropArea = { x: 10, y: 10, width: 100, height: 50 }; - const extractionType = 'store_name'; - - await aiApiClient.rescanImageArea(file, cropArea, extractionType, 'test-token'); + describe('extractLogoFromImage', () => { + it('should construct FormData and call apiFetchWithAuth correctly', async () => { + const files = [new File([''], 'logo.jpg', { type: 'image/jpeg' })]; + await aiApiClient.extractLogoFromImage(files, 'test-token'); expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0]; - expect(url).toBe('/ai/rescan-area'); + expect(url).toBe('/ai/extract-logo'); expect(options.method).toBe('POST'); expect(token).toBe('test-token'); expect(options.body).toBeInstanceOf(FormData); - - // Verify FormData content - const formData = options.body as FormData; - expect(formData.get('image')).toBeInstanceOf(File); - expect(formData.get('cropArea')).toBe(JSON.stringify(cropArea)); - expect(formData.get('extractionType')).toBe(extractionType); + expect((options.body as FormData).get('images')).toBeInstanceOf(File); }); }); - describe('getQuickInsights', () => { + describe('getDeepDiveAnalysis', () => { it('should call apiFetchWithAuth with the items in the body', async () => { - await aiApiClient.getQuickInsights([], 'test-token'); + const items: FlyerItem[] = []; + await aiApiClient.getDeepDiveAnalysis(items, 'test-token'); expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith( - '/ai/quick-insights', + '/ai/deep-dive', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ items: [] }), + body: JSON.stringify({ items }), }, 'test-token' ); }); }); + + describe('searchWeb', () => { + it('should call apiFetchWithAuth with the items in the body', async () => { + const items: FlyerItem[] = []; + await aiApiClient.searchWeb(items, 'test-token'); + + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith( + '/ai/search-web', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items }), + }, + 'test-token' + ); + }); + }); + + describe('generateImageFromText', () => { + it('should call apiFetchWithAuth with the prompt in the body', async () => { + const prompt = 'A delicious meal'; + await aiApiClient.generateImageFromText(prompt, 'test-token'); + + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith( + '/ai/generate-image', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }, + 'test-token' + ); + }); + }); + + describe('generateSpeechFromText', () => { + it('should call apiFetchWithAuth with the text in the body', async () => { + const text = 'Hello world'; + await aiApiClient.generateSpeechFromText(text, 'test-token'); + + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1); + expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith( + '/ai/generate-speech', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }, + 'test-token' + ); + }); + }); + + describe('startVoiceSession', () => { + it('should throw an error as it is not implemented', () => { + expect(() => aiApiClient.startVoiceSession({ onmessage: vi.fn() })).toThrow( + 'Voice session feature is not fully implemented and requires a backend WebSocket proxy.' + ); + }); + }); }); \ No newline at end of file diff --git a/src/services/db/gamification.ts b/src/services/db/gamification.ts index 9eb6b81a..70f5a084 100644 --- a/src/services/db/gamification.ts +++ b/src/services/db/gamification.ts @@ -45,4 +45,21 @@ export async function getUserAchievements(userId: string): Promise<(UserAchievem logger.error('Database error in getUserAchievements:', { error, userId }); throw new Error('Failed to retrieve user achievements.'); } +} + +/** + * Manually awards a specific achievement to a user. + * This calls a database function that handles the logic of checking if the user + * already has the achievement and awarding points if they don't. + * @param userId The UUID of the user to award the achievement to. + * @param achievementName The name of the achievement to award. + * @returns A promise that resolves when the operation is complete. + */ +export async function awardAchievement(userId: string, achievementName: string): Promise { + try { + await getPool().query("SELECT public.award_achievement($1, $2)", [userId, achievementName]); + } catch (error) { + logger.error('Database error in awardAchievement:', { error, userId, achievementName }); + throw new Error('Failed to award achievement.'); + } } \ No newline at end of file