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
2 changes: 2 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import errorHandler from './middlewares/error.middleware.js';
/* Routers */
import accountsRouter from './routers/accounts.router.js';
import authRouter from './routers/auth.router.js';
import weatherRouter from './routers/weather.router.js';

/* Imported Middlewares */
app.use(express.json());
Expand All @@ -20,6 +21,7 @@ app.use(logger);
/* Implementing Routes */
app.use('/api/accounts', accountsRouter);
app.use('/api/auth', authRouter);
app.use('/api/weather', weatherRouter);

/* errorMiddleware MUST BE AT THE BOTTOM LIKE SO */
app.use(errorHandler);
Expand Down
126 changes: 126 additions & 0 deletions backend/controllers/__tests__/weather.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import request from 'supertest';

// mock weather service so no real HTTP or Redis calls
const mockGetCurrentWeather = jest.fn();
const mockGetHistoricalWeather = jest.fn();

jest.unstable_mockModule('../../services/weather.service.js', () => ({
getCurrentWeather: mockGetCurrentWeather,
getHistoricalWeather: mockGetHistoricalWeather,
}));

// also mock ioredis so app import doesn't try to connect to Redis
jest.unstable_mockModule('ioredis', () => ({
default: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
on: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
})),
}));

const { default: app } = await import('../../app.js');

describe('GET /api/weather/current', () => {
beforeEach(() => jest.clearAllMocks());

test('returns 200 with weather data for valid zip', async () => {
const weatherData = { location: 'Boston', zip: '02101', temperature_2m: 68 };
mockGetCurrentWeather.mockResolvedValue(weatherData);

const res = await request(app)
.get('/api/weather/current')
.query({ zip: '02101' });

expect(res.status).toBe(200);
expect(res.body).toEqual(weatherData);
expect(mockGetCurrentWeather).toHaveBeenCalledWith('02101');
});

test('returns 400 for missing zip', async () => {
const res = await request(app)
.get('/api/weather/current');

expect(res.status).toBe(400);
expect(mockGetCurrentWeather).not.toHaveBeenCalled();
});

test('returns 400 for malformed zip (non-numeric)', async () => {
const res = await request(app)
.get('/api/weather/current')
.query({ zip: 'abcde' });

expect(res.status).toBe(400);
expect(mockGetCurrentWeather).not.toHaveBeenCalled();
});

test('returns 400 for zip with wrong length', async () => {
const res = await request(app)
.get('/api/weather/current')
.query({ zip: '1234' });

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

test('returns 500 when service throws', async () => {
mockGetCurrentWeather.mockRejectedValue(new Error('API down'));

const res = await request(app)
.get('/api/weather/current')
.query({ zip: '02101' });

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

describe('GET /api/weather/historical', () => {
beforeEach(() => jest.clearAllMocks());

test('returns 200 with historical data for valid params', async () => {
const historicalData = { location: 'Boston', zip: '02101', daily: {} };
mockGetHistoricalWeather.mockResolvedValue(historicalData);

const res = await request(app)
.get('/api/weather/historical')
.query({ zip: '02101', start_date: '2024-01-01', end_date: '2024-01-07' });

expect(res.status).toBe(200);
expect(res.body).toEqual(historicalData);
expect(mockGetHistoricalWeather).toHaveBeenCalledWith('02101', '2024-01-01', '2024-01-07');
});

test('returns 400 for missing start_date', async () => {
const res = await request(app)
.get('/api/weather/historical')
.query({ zip: '02101', end_date: '2024-01-07' });

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

test('returns 400 for missing end_date', async () => {
const res = await request(app)
.get('/api/weather/historical')
.query({ zip: '02101', start_date: '2024-01-01' });

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

test('returns 400 for malformed date format', async () => {
const res = await request(app)
.get('/api/weather/historical')
.query({ zip: '02101', start_date: '01-01-2024', end_date: '2024-01-07' });

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

test('returns 500 when service throws', async () => {
mockGetHistoricalWeather.mockRejectedValue(new Error('API down'));

const res = await request(app)
.get('/api/weather/historical')
.query({ zip: '02101', start_date: '2024-01-01', end_date: '2024-01-07' });

expect(res.status).toBe(500);
});
});
14 changes: 14 additions & 0 deletions backend/controllers/weather.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import catchAsync from '../utils/catchAsync.js';
import { getCurrentWeather, getHistoricalWeather } from '../services/weather.service.js';

export const getWeather = catchAsync(async (req, res) => {
const { zip } = req.query;
const data = await getCurrentWeather(zip);
res.json(data);
});

export const getHistorical = catchAsync(async (req, res) => {
const { zip, start_date, end_date } = req.query;
const data = await getHistoricalWeather(zip, start_date, end_date);
res.json(data);
});
11 changes: 11 additions & 0 deletions backend/routers/weather.router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Router } from 'express';
import validate from '../middlewares/validation.middleware.js';
import { currentWeatherSchema, historicalWeatherSchema } from '../validators/weather.validator.js';
import { getWeather, getHistorical } from '../controllers/weather.controller.js';

const router = Router();

router.get('/current', validate(currentWeatherSchema), getWeather);
router.get('/historical', validate(historicalWeatherSchema), getHistorical);

export default router;
100 changes: 100 additions & 0 deletions backend/services/__tests__/cache.service.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { jest, describe, test, expect, beforeEach } from '@jest/globals';

const mockGet = jest.fn();
const mockSet = jest.fn();
const mockOn = jest.fn();
const mockConnect = jest.fn().mockResolvedValue(undefined);

jest.unstable_mockModule('../../utils/redisClient.js', () => ({
getRedisClient: jest.fn(() => ({
get: mockGet,
set: mockSet,
on: mockOn,
connect: mockConnect,
})),
}));

const { getCached, setCached, TTL } = await import('../cache.service.js');

describe('cache.service', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('TTL constants', () => {
test('weather TTL is 10 minutes', () => {
expect(TTL.weather).toBe(600);
});

test('historical TTL is 1 hour', () => {
expect(TTL.historical).toBe(3600);
});
});

describe('getCached', () => {
test('returns parsed value and logs HIT on cache hit', async () => {
const payload = { temperature: 72, zip: '12345' };
mockGet.mockResolvedValue(JSON.stringify(payload));
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

const result = await getCached('weather:12345');

expect(result).toEqual(payload);
expect(consoleSpy).toHaveBeenCalledWith('[Cache] HIT weather:12345');
consoleSpy.mockRestore();
});

test('returns null and logs MISS on cache miss', async () => {
mockGet.mockResolvedValue(null);
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

const result = await getCached('weather:99999');

expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith('[Cache] MISS weather:99999');
consoleSpy.mockRestore();
});

test('returns null and logs warning when Redis throws', async () => {
mockGet.mockRejectedValue(new Error('ECONNREFUSED'));
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

const result = await getCached('weather:12345');

expect(result).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
'[Cache] GET failed for weather:12345:',
'ECONNREFUSED'
);
warnSpy.mockRestore();
});
});

describe('setCached', () => {
test('calls redis SET with correct args', async () => {
mockSet.mockResolvedValue('OK');
const payload = { temperature: 72 };

await setCached('weather:12345', payload, TTL.weather);

expect(mockSet).toHaveBeenCalledWith(
'weather:12345',
JSON.stringify(payload),
'EX',
TTL.weather
);
});

test('logs warning and does not throw when Redis throws', async () => {
mockSet.mockRejectedValue(new Error('ECONNREFUSED'));
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

await expect(setCached('weather:12345', {}, TTL.weather)).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
'[Cache] SET failed for weather:12345:',
'ECONNREFUSED'
);
warnSpy.mockRestore();
});
});
});
Loading
Loading