diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d39850 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI Pipeline + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test-backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 20 + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install Dependencies + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi + + - name: Run Unit Tests + run: npm run test + + test-frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js 20 + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install Dependencies + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi + + - name: Check Build + run: npm run build diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index e6c9d30..275f225 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -3,7 +3,8 @@ import { AuthService } from './auth.service'; import { JwtService } from '@nestjs/jwt'; import { getRepositoryToken } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; -import { UnauthorizedException, ConflictException } from '@nestjs/common'; +import { Donation } from '../donations/entities/donation.entity'; +import { UnauthorizedException, ConflictException, NotFoundException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; // 1. Mock the entire bcrypt library here @@ -20,6 +21,10 @@ const mockJwtService = { sign: jest.fn().mockReturnValue('fake_jwt_token'), }; +const mockDonationRepo = { + find: jest.fn().mockResolvedValue([]), +}; + describe('AuthService', () => { let service: AuthService; @@ -28,6 +33,7 @@ describe('AuthService', () => { providers: [ AuthService, { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(Donation), useValue: mockDonationRepo }, { provide: JwtService, useValue: mockJwtService }, ], }).compile(); @@ -132,7 +138,7 @@ describe('AuthService', () => { it('should throw error if user not found', async () => { mockUserRepo.findOne.mockResolvedValue(null); - await expect(service.getProfile('99')).rejects.toThrow(UnauthorizedException); + await expect(service.getProfile('99')).rejects.toThrow(NotFoundException); }); }); @@ -147,7 +153,7 @@ describe('AuthService', () => { const result = await service.updateProfile('1', updateDto); expect(mockUserRepo.update).toHaveBeenCalledWith('1', updateDto); - expect(result.name).toBe('Updated Name'); + expect(result.data.name).toBe('Updated Name'); }); }); }); \ No newline at end of file diff --git a/backend/src/donations/donations.service.spec.ts b/backend/src/donations/donations.service.spec.ts index 6bf36ef..2da302f 100644 --- a/backend/src/donations/donations.service.spec.ts +++ b/backend/src/donations/donations.service.spec.ts @@ -4,8 +4,9 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Donation, DonationStatus } from './entities/donation.entity'; import { User, UserRole } from '../auth/entities/user.entity'; import { BadRequestException } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; -// 1. 👇 Create a SHARED mock for QueryBuilder +// 1. Create a SHARED mock for QueryBuilder const mockQueryBuilder = { addSelect: jest.fn().mockReturnThis(), having: jest.fn().mockReturnThis(), @@ -26,7 +27,7 @@ const mockDonationRepo = { manager: { transaction: jest.fn((cb) => cb(mockEntityManager)), }, - // 2. 👇 Return the SAME object every time + // 2. Return the SAME object every time createQueryBuilder: jest.fn(() => mockQueryBuilder), }; @@ -35,6 +36,13 @@ const mockUserRepo = { save: jest.fn(), }; +const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + reset: jest.fn(), +}; + describe('DonationsService Unit Tests', () => { let service: DonationsService; @@ -44,6 +52,7 @@ describe('DonationsService Unit Tests', () => { DonationsService, { provide: getRepositoryToken(Donation), useValue: mockDonationRepo }, { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, ], }).compile(); diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index ca8f9cd..18dab38 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render } from '@testing-library/react' import App from '../App' vi.mock('react-router-dom', () => ({ diff --git a/frontend/src/__tests__/pages/Register.test.tsx b/frontend/src/__tests__/pages/Register.test.tsx index 66d49f3..4441a7f 100644 --- a/frontend/src/__tests__/pages/Register.test.tsx +++ b/frontend/src/__tests__/pages/Register.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render } from '@testing-library/react' import Register from '../../pages/Register' vi.mock('react-router-dom', () => ({ diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 6c10eae..a5f1473 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useState, type ChangeEvent, type FormEvent } from 'react' import { useNavigate, Link } from 'react-router-dom' import { registerUser, type UserRole } from '../services/api' -import { Utensils, Store, Building2, Car, ChevronRight, AlertCircle } from 'lucide-react' +import { Store, Building2, Car, ChevronRight, AlertCircle } from 'lucide-react' export default function Register() { const [formData, setFormData] = useState({ @@ -9,7 +9,7 @@ export default function Register() { email: '', password: '', phone: '', - role: 'donor' as UserRole, + role: 'DONOR' as UserRole, organizationName: '', organizationType: '', address: '', @@ -18,11 +18,11 @@ export default function Register() { const [error, setError] = useState(null) const navigate = useNavigate() - const handleChange = (e: React.ChangeEvent) => { + const handleChange = (e: ChangeEvent) => { setFormData({ ...formData, [e.target.name]: e.target.value }) } - const onSubmit = async (e: React.FormEvent) => { + const onSubmit = async (e: FormEvent) => { e.preventDefault() setLoading(true) setError(null) @@ -44,17 +44,12 @@ export default function Register() { } const roles = [ - { id: 'donor', label: 'Donor', desc: 'Share surplus food', icon: Store }, - { id: 'ngo', label: 'NGO', desc: 'Collect and distribute', icon: Building2 }, - { id: 'volunteer', label: 'Volunteer', desc: 'Help with transport', icon: Car }, + { id: 'DONOR', label: 'Donor', desc: 'Share surplus food', icon: Store }, + { id: 'NGO', label: 'NGO', desc: 'Collect and distribute', icon: Building2 }, + { id: 'VOLUNTEER', label: 'Volunteer', desc: 'Help with transport', icon: Car }, ] - const organizationTypes = { - donor: ['restaurant', 'hotel', 'canteen', 'event', 'catering', 'grocery', 'bakery'], - ngo: ['charity', 'shelter', 'community_kitchen', 'food_bank'], - } - - const showOrganizationFields = formData.role === 'donor' || formData.role === 'ngo' + const showOrganizationFields = formData.role === 'DONOR' || formData.role === 'NGO' return (
@@ -120,7 +115,7 @@ export default function Register() { {showOrganizationFields && ( <>
- +
diff --git a/frontend/src/pages/dashboard/AddFood.tsx b/frontend/src/pages/dashboard/AddFood.tsx index b8fd164..70a87cc 100644 --- a/frontend/src/pages/dashboard/AddFood.tsx +++ b/frontend/src/pages/dashboard/AddFood.tsx @@ -124,6 +124,14 @@ export default function AddFood() { return } + const donorId = String(user?.id ?? '').trim() + const donorName = String(user?.organizationName ?? user?.name ?? '').trim() + + if (!donorId || !donorName) { + setError('Missing donor information. Please sign in again.') + return + } + setIsSubmitting(true) try { @@ -138,8 +146,8 @@ export default function AddFood() { quantity: formData.quantity, unit: formData.unit, description: formData.description, - donorId: user.id, - donorName: user.organizationName || user.name, + donorId, + donorName, donorTrustScore: user.trustScore || 0, location, hygiene: { diff --git a/frontend/src/pages/dashboard/DonorHome.tsx b/frontend/src/pages/dashboard/DonorHome.tsx index 77b5d6d..710963c 100644 --- a/frontend/src/pages/dashboard/DonorHome.tsx +++ b/frontend/src/pages/dashboard/DonorHome.tsx @@ -45,45 +45,6 @@ export default function DonorHome() { return getTimeRemaining(d.expiryTime).urgent }) - const handleClaim = async (donationId: string) => { - setClaiming(donationId) - try { - await claimDonation(donationId) - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to claim donation') - } finally { - setClaiming(null) - } - } - - const handleConfirmPickup = async (id: string) => { - setProcessingId(id) - try { - await updateDonationStatus(id, 'PICKED_UP') - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to confirm pickup') - } finally { - setProcessingId(null) - } - } - - const handleConfirmDelivery = async (id: string) => { - setProcessingId(id) - try { - await updateDonationStatus(id, 'DELIVERED') - await load() - setSelectedDonation(null) - } catch (error: any) { - alert(error.message || 'Failed to confirm delivery') - } finally { - setProcessingId(null) - } - } - const getStatusStyle = (status: string) => { const styles: Record = { AVAILABLE: 'bg-emerald-500/10 text-emerald-400', diff --git a/frontend/src/pages/dashboard/History.tsx b/frontend/src/pages/dashboard/History.tsx index 03a95d7..bb10fb1 100644 --- a/frontend/src/pages/dashboard/History.tsx +++ b/frontend/src/pages/dashboard/History.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { getDonations } from '../../services/api' import type { Donation } from '../../services/api' diff --git a/frontend/src/pages/dashboard/NGODashboard.tsx b/frontend/src/pages/dashboard/NGODashboard.tsx index c300a28..1a74740 100644 --- a/frontend/src/pages/dashboard/NGODashboard.tsx +++ b/frontend/src/pages/dashboard/NGODashboard.tsx @@ -405,7 +405,7 @@ export default function NGODashboard() { {/* Location */}

Pickup Location

-

📍 {selectedDonation.address || 'Location not specified'}

+

📍 {selectedDonation.location?.address || 'Location not specified'}

{/* Time */} diff --git a/frontend/src/pages/dashboard/Notifications.tsx b/frontend/src/pages/dashboard/Notifications.tsx index 893f35d..b97f072 100644 --- a/frontend/src/pages/dashboard/Notifications.tsx +++ b/frontend/src/pages/dashboard/Notifications.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactElement } from 'react' import { getNotifications, markNotificationRead, type Notification } from '../../services/api' import { Bell, CheckCircle, AlertTriangle, MapPin, Package } from 'lucide-react' @@ -43,7 +43,7 @@ export default function Notifications() { const unreadCount = notifications.filter(n => !n.read).length const getIcon = (type: Notification['type']) => { - const icons = { + const icons: Record = { food_claimed: , pickup_assigned: , delivery_confirmed: , diff --git a/frontend/src/pages/dashboard/VolunteerDashboard.tsx b/frontend/src/pages/dashboard/VolunteerDashboard.tsx index e2bbd9f..475c12d 100644 --- a/frontend/src/pages/dashboard/VolunteerDashboard.tsx +++ b/frontend/src/pages/dashboard/VolunteerDashboard.tsx @@ -14,8 +14,7 @@ export default function VolunteerDashboard() { const load = async () => { setLoading(true) try { - const data = await getDonations({ status: ['CLAIMED', 'PICKED_UP'], role: 'volunteer', userId: user.id }) - setDonations(data) + setDonations(await getDonations({ status: ['CLAIMED', 'PICKED_UP'], role: 'volunteer', userId: user.id })) } finally { setLoading(false) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e4f4041..e79eafa 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -79,6 +79,7 @@ export type Notification = { id: string; title: string; message: string; + type: 'food_claimed' | 'pickup_assigned' | 'delivery_confirmed' | 'near_expiry' | 'new_food_nearby'; read: boolean; createdAt: Date; }; @@ -197,8 +198,12 @@ export const createDonation = async (data: any, images: File[] = []) => { formData.append('preparationTime', toISO(data.preparationTime)); formData.append('expiryTime', toISO(data.expiryTime)); - if (data.donorId) formData.append('donorId', data.donorId); - if (data.donorName) formData.append('donorName', data.donorName); + if (data.donorId !== undefined && data.donorId !== null) { + formData.append('donorId', String(data.donorId)); + } + if (data.donorName !== undefined && data.donorName !== null) { + formData.append('donorName', String(data.donorName)); + } if (data.donorTrustScore) { formData.append('donorTrustScore', data.donorTrustScore.toString()); } @@ -241,6 +246,7 @@ export const getNotifications = async (_userId: string): Promise id: '1', title: 'Welcome!', message: 'Welcome to SurplusSync.', + type: 'new_food_nearby', read: false, createdAt: new Date(), },