Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,10 @@ const mockJwtService = {
sign: jest.fn().mockReturnValue('fake_jwt_token'),
};

const mockDonationRepo = {
find: jest.fn().mockResolvedValue([]),
};

describe('AuthService', () => {
let service: AuthService;

Expand All @@ -28,6 +33,7 @@ describe('AuthService', () => {
providers: [
AuthService,
{ provide: getRepositoryToken(User), useValue: mockUserRepo },
{ provide: getRepositoryToken(Donation), useValue: mockDonationRepo },
{ provide: JwtService, useValue: mockJwtService },
],
}).compile();
Expand Down Expand Up @@ -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);
});
});

Expand All @@ -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');
});
});
});
13 changes: 11 additions & 2 deletions backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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),
};

Expand All @@ -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;

Expand All @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/pages/Register.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down
25 changes: 10 additions & 15 deletions frontend/src/pages/Register.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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({
name: '',
email: '',
password: '',
phone: '',
role: 'donor' as UserRole,
role: 'DONOR' as UserRole,
organizationName: '',
organizationType: '',
address: '',
Expand All @@ -18,11 +18,11 @@ export default function Register() {
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()

const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
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)
Expand All @@ -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 (
<div className="min-h-screen bg-slate-950 p-4 flex items-center justify-center">
Expand Down Expand Up @@ -120,7 +115,7 @@ export default function Register() {
{showOrganizationFields && (
<>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">{formData.role === 'donor' ? 'Business Name' : 'Organization Name'}</label>
<label className="block text-sm font-medium text-slate-300 mb-2">{formData.role === 'DONOR' ? 'Business Name' : 'Organization Name'}</label>
<input type="text" name="organizationName" value={formData.organizationName} onChange={handleChange} required className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none" />
</div>
<div>
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/pages/dashboard/AddFood.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand Down
39 changes: 0 additions & 39 deletions frontend/src/pages/dashboard/DonorHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
AVAILABLE: 'bg-emerald-500/10 text-emerald-400',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/dashboard/History.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/dashboard/NGODashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ export default function NGODashboard() {
{/* Location */}
<div>
<p className="text-xs text-slate-400 mb-1">Pickup Location</p>
<p className="text-sm text-slate-300">📍 {selectedDonation.address || 'Location not specified'}</p>
<p className="text-sm text-slate-300">📍 {selectedDonation.location?.address || 'Location not specified'}</p>
</div>

{/* Time */}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/dashboard/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<Notification['type'], ReactElement> = {
food_claimed: <Package className="w-5 h-5 text-emerald-400" />,
pickup_assigned: <MapPin className="w-5 h-5 text-blue-400" />,
delivery_confirmed: <CheckCircle className="w-5 h-5 text-green-400" />,
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/pages/dashboard/VolunteerDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -241,6 +246,7 @@ export const getNotifications = async (_userId: string): Promise<Notification[]>
id: '1',
title: 'Welcome!',
message: 'Welcome to SurplusSync.',
type: 'new_food_nearby',
read: false,
createdAt: new Date(),
},
Expand Down
Loading