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
21 changes: 21 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh

if git diff --cached --name-only | grep -q '^frontend/'; then
echo "Frontend files staged — running unit tests..."
npm test --prefix frontend --silent
if [ $? -ne 0 ]; then
echo ""
echo "Frontend unit tests failed. Fix the failures above before committing."
exit 1
fi
fi

if git diff --cached --name-only | grep -q '^backend/'; then
echo "Backend files staged — running unit tests..."
npm test --prefix backend --silent
if [ $? -ne 0 ]; then
echo ""
echo "Backend unit tests failed. Fix the failures above before committing."
exit 1
fi
fi
85 changes: 85 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: CI

on:
push:
branches-ignore:
- main

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build Docker image
run: docker build ./backend

#- name: Start backend
# run: nohup npm start --prefix backend > /dev/null 2>&1 &

changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend:
- 'frontend/**'
backend:
- 'backend/**'

test-backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json

- name: Install backend dependencies
run: npm ci --prefix backend

- name: Run backend unit tests
run: npm test --prefix backend
env:
CI: true

test-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies
run: npm ci --prefix frontend

- name: Run frontend unit tests
run: npm test --prefix frontend
env:
CI: true
EXPO_PUBLIC_API_URL: http://localhost:3000
EXPO_PUBLIC_FIREBASE_API_KEY: test-key
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN: test.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID: test-project
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET: test.appspot.com
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '123456'
EXPO_PUBLIC_FIREBASE_APP_ID: test-app-id
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Allergy Detect

## Pre-Commit Hooks
* run:
```bash
git config core.hooksPath .githooks
```
* This will make it so that unit test run on your local commit. They also run in CI but these are much slower so it's good to know if it's going to pass before you push.
* You can skip this with the `--no-verify` argument.

## Frontend setup
* Install Android SDK and emulator as per [these](https://reactnative.dev/docs/set-up-your-environment) instructions
* If using MacOS install Homebrew if not already installed as per [these](https://brew.sh/) instructions
Expand Down
87 changes: 87 additions & 0 deletions backend/__tests__/auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
jest.mock('firebase-admin', () => ({
auth: jest.fn(),
initializeApp: jest.fn(),
credential: { cert: jest.fn() },
apps: [],
}));
jest.mock('../firebase', () => jest.fn());

const admin = require('firebase-admin');
const getDb = require('../firebase');
const requireAuth = require('../middleware/auth');

describe('requireAuth middleware', () => {
let req, res, next;

beforeEach(() => {
jest.clearAllMocks();
req = { method: 'GET', path: '/test', headers: {} };
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
admin.auth.mockReturnValue({
verifyIdToken: jest.fn().mockResolvedValue({ uid: 'user-123' }),
});
getDb.mockResolvedValue({});
});

it('passes OPTIONS requests through without checking token', async () => {
req.method = 'OPTIONS';

await requireAuth(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('returns 401 for missing Authorization header', async () => {
await requireAuth(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Missing or invalid Authorization header' });
expect(next).not.toHaveBeenCalled();
});

it('returns 401 when Authorization header lacks Bearer prefix', async () => {
req.headers.authorization = 'Basic some-token';

await requireAuth(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Missing or invalid Authorization header' });
expect(next).not.toHaveBeenCalled();
});

it('returns 401 when token verification fails', async () => {
req.headers.authorization = 'Bearer bad-token';
admin.auth.mockReturnValue({
verifyIdToken: jest.fn().mockRejectedValue(new Error('Token expired')),
});

await requireAuth(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' });
expect(next).not.toHaveBeenCalled();
});

it('attaches decoded user to req and calls next on valid token', async () => {
req.headers.authorization = 'Bearer valid-token';

await requireAuth(req, res, next);

expect(req.user).toEqual({ uid: 'user-123' });
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('initializes db before verifying token', async () => {
req.headers.authorization = 'Bearer valid-token';

await requireAuth(req, res, next);

expect(getDb).toHaveBeenCalled();
});
});
171 changes: 171 additions & 0 deletions backend/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
jest.mock('firebase-admin', () => ({
auth: jest.fn(),
initializeApp: jest.fn(),
credential: { cert: jest.fn() },
apps: [],
}));
jest.mock('../firebase', () => jest.fn());

const admin = require('firebase-admin');
const getDb = require('../firebase');
const request = require('supertest');
const app = require('../app');

const AUTH_HEADER = 'Bearer test-token';

beforeEach(() => {
jest.clearAllMocks();
admin.auth.mockReturnValue({
verifyIdToken: jest.fn().mockResolvedValue({ uid: 'test-uid' }),
});
});

describe('GET /data', () => {
it('returns 404 when collection is empty', async () => {
const mockCollection = { get: jest.fn().mockResolvedValue({ empty: true, docs: [] }) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app).get('/data').set('Authorization', AUTH_HEADER);

expect(res.status).toBe(404);
expect(res.body).toEqual({ message: 'No documents found.' });
});

it('returns array of documents when found', async () => {
const mockDocs = [
{ id: 'doc1', data: () => ({ name: 'Item 1' }) },
{ id: 'doc2', data: () => ({ name: 'Item 2' }) },
];
const mockCollection = { get: jest.fn().mockResolvedValue({ empty: false, docs: mockDocs }) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app).get('/data').set('Authorization', AUTH_HEADER);

expect(res.status).toBe(200);
expect(res.body).toEqual([
{ id: 'doc1', name: 'Item 1' },
{ id: 'doc2', name: 'Item 2' },
]);
});

it('returns 500 on Firestore error', async () => {
const mockCollection = { get: jest.fn().mockRejectedValue(new Error('Firestore unavailable')) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app).get('/data').set('Authorization', AUTH_HEADER);

expect(res.status).toBe(500);
});
});

describe('GET /ingredients/:upc/:userId', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

it('returns 404 when UPC not found in external API', async () => {
global.fetch.mockResolvedValue({ json: jest.fn().mockResolvedValue({ items: [] }) });
getDb.mockResolvedValue({ collection: jest.fn() });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'No item found for this UPC' });
});

it('returns 404 when user not found in Firestore', async () => {
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue({ items: [{ description: 'sugar, milk, flour' }] }),
});
const mockRef = { get: jest.fn().mockResolvedValue({ exists: false }) };
const mockCollection = { doc: jest.fn().mockReturnValue(mockRef) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'User not found' });
});

it('returns safe result when no allergens match ingredients', async () => {
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue({ items: [{ description: 'sugar, water, corn syrup' }] }),
});
const userData = { allergens: ['peanut'], intolerances: ['gluten'] };
const mockRef = {
get: jest.fn().mockResolvedValue({ exists: true, data: () => userData }),
};
const mockCollection = { doc: jest.fn().mockReturnValue(mockRef) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(200);
expect(res.body.safe).toBe(true);
expect(res.body.containsAllergens).toEqual([]);
expect(res.body.containsIntolerances).toEqual([]);
expect(res.body.ingredients).toEqual(['sugar', 'water', 'corn syrup']);
});

it('returns unsafe result when allergens are found in ingredients', async () => {
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue({ items: [{ description: 'peanut oil, sugar, salt' }] }),
});
const userData = { allergens: ['peanut'], intolerances: ['gluten'] };
const mockRef = {
get: jest.fn().mockResolvedValue({ exists: true, data: () => userData }),
};
const mockCollection = { doc: jest.fn().mockReturnValue(mockRef) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(200);
expect(res.body.safe).toBe(false);
expect(res.body.containsAllergens).toEqual(['peanut oil']);
expect(res.body.containsIntolerances).toEqual([]);
});

it('returns unsafe result when both allergens and intolerances match', async () => {
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue({
items: [{ description: 'peanut butter, wheat flour, sugar' }],
}),
});
const userData = { allergens: ['peanut'], intolerances: ['wheat'] };
const mockRef = {
get: jest.fn().mockResolvedValue({ exists: true, data: () => userData }),
};
const mockCollection = { doc: jest.fn().mockReturnValue(mockRef) };
getDb.mockResolvedValue({ collection: jest.fn().mockReturnValue(mockCollection) });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(200);
expect(res.body.safe).toBe(false);
expect(res.body.containsAllergens).toEqual(['peanut butter']);
expect(res.body.containsIntolerances).toEqual(['wheat flour']);
});

it('returns 500 on external API fetch error', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
getDb.mockResolvedValue({ collection: jest.fn() });

const res = await request(app)
.get('/ingredients/012345678901/user-1')
.set('Authorization', AUTH_HEADER);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to fetch ingredients' });
});
});
Loading
Loading