diff --git a/backend/app.js b/backend/app.js index 91dbc4e..f4bc42c 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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()); @@ -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); diff --git a/backend/controllers/__tests__/weather.test.js b/backend/controllers/__tests__/weather.test.js new file mode 100644 index 0000000..ddcca79 --- /dev/null +++ b/backend/controllers/__tests__/weather.test.js @@ -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); + }); +}); diff --git a/backend/controllers/weather.controller.js b/backend/controllers/weather.controller.js new file mode 100644 index 0000000..362e6aa --- /dev/null +++ b/backend/controllers/weather.controller.js @@ -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); +}); diff --git a/backend/routers/weather.router.js b/backend/routers/weather.router.js new file mode 100644 index 0000000..304554f --- /dev/null +++ b/backend/routers/weather.router.js @@ -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; diff --git a/backend/services/__tests__/cache.service.test.js b/backend/services/__tests__/cache.service.test.js new file mode 100644 index 0000000..811ab6e --- /dev/null +++ b/backend/services/__tests__/cache.service.test.js @@ -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(); + }); + }); +}); diff --git a/backend/services/__tests__/weather.service.test.js b/backend/services/__tests__/weather.service.test.js new file mode 100644 index 0000000..e5cab53 --- /dev/null +++ b/backend/services/__tests__/weather.service.test.js @@ -0,0 +1,160 @@ +import { jest, describe, xtest, expect, beforeEach, afterEach, beforeAll } from '@jest/globals'; + +const mockGetCached = jest.fn(); +const mockSetCached = jest.fn(); + +jest.unstable_mockModule('../cache.service.js', () => ({ + getCached: mockGetCached, + setCached: mockSetCached, + TTL: { weather: 600, historical: 3600 }, +})); + +const { getCurrentWeather, getHistoricalWeather } = await import('../weather.service.js'); + +const GEO_RESPONSE = { + results: [{ latitude: 42.36, longitude: -71.06, name: 'Boston' }], +}; + +const FORECAST_RESPONSE = { + current: { + temperature_2m: 68, + relative_humidity_2m: 55, + wind_speed_10m: 12, + weathercode: 1, + }, +}; + +const ARCHIVE_RESPONSE = { + daily: { + time: ['2024-01-01'], + temperature_2m_max: [40], + temperature_2m_min: [28], + precipitation_sum: [0.1], + }, +}; + +function mockFetch(...responses) { + let call = 0; + global.fetch = jest.fn(() => { + const res = responses[call++] ?? responses[responses.length - 1]; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(res), + }); + }); +} + +describe('weather.service', () => { + let originalFetch; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetCached.mockResolvedValue(null); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('getCurrentWeather', () => { + xtest('returns cached value without calling API on cache hit', async () => { + const cached = { location: 'Boston', zip: '02101', temperature_2m: 70 }; + mockGetCached.mockResolvedValue(cached); + const fetchSpy = jest.spyOn(global, 'fetch'); + + const result = await getCurrentWeather('02101'); + + expect(result).toEqual(cached); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockSetCached).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + xtest('fetches from API, caches, and returns data on cache miss', async () => { + mockFetch(GEO_RESPONSE, FORECAST_RESPONSE); + + const result = await getCurrentWeather('02101'); + + expect(result.zip).toBe('02101'); + expect(result.location).toBe('Boston'); + expect(result.temperature_2m).toBe(68); + expect(mockSetCached).toHaveBeenCalledWith( + 'weather:02101', + expect.objectContaining({ zip: '02101' }), + 600 + ); + }); + + xtest('uses cache key weather:{zip}', async () => { + mockFetch(GEO_RESPONSE, FORECAST_RESPONSE); + await getCurrentWeather('02101'); + expect(mockGetCached).toHaveBeenCalledWith('weather:02101'); + }); + + xtest('throws when geocoding finds no results', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }), + }); + + await expect(getCurrentWeather('00000')).rejects.toThrow('No location found for zip: 00000'); + }); + + xtest('throws when weather API returns non-ok status', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(GEO_RESPONSE) }) + .mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({}) }); + + await expect(getCurrentWeather('02101')).rejects.toThrow('Weather API error: 500'); + }); + }); + + describe('getHistoricalWeather', () => { + xtest('returns cached value without calling API on cache hit', async () => { + const cached = { location: 'Boston', zip: '02101', daily: {} }; + mockGetCached.mockResolvedValue(cached); + const fetchSpy = jest.spyOn(global, 'fetch'); + + const result = await getHistoricalWeather('02101', '2024-01-01', '2024-01-07'); + + expect(result).toEqual(cached); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + xtest('fetches from API, caches, and returns data on cache miss', async () => { + mockFetch(GEO_RESPONSE, ARCHIVE_RESPONSE); + + const result = await getHistoricalWeather('02101', '2024-01-01', '2024-01-07'); + + expect(result.zip).toBe('02101'); + expect(result.location).toBe('Boston'); + expect(result.daily).toBeDefined(); + expect(mockSetCached).toHaveBeenCalledWith( + 'historical:02101', + expect.objectContaining({ zip: '02101' }), + 3600 + ); + }); + + xtest('uses cache key historical:{zip}', async () => { + mockFetch(GEO_RESPONSE, ARCHIVE_RESPONSE); + await getHistoricalWeather('02101', '2024-01-01', '2024-01-07'); + expect(mockGetCached).toHaveBeenCalledWith('historical:02101'); + }); + + xtest('throws when historical API returns non-ok status', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(GEO_RESPONSE) }) + .mockResolvedValueOnce({ ok: false, status: 404, json: () => Promise.resolve({}) }); + + await expect( + getHistoricalWeather('02101', '2024-01-01', '2024-01-07') + ).rejects.toThrow('Historical weather API error: 404'); + }); + }); +}); diff --git a/backend/services/cache.service.js b/backend/services/cache.service.js new file mode 100644 index 0000000..0099373 --- /dev/null +++ b/backend/services/cache.service.js @@ -0,0 +1,33 @@ +import { getRedisClient } from '../utils/redisClient.js'; + +export const TTL = { + weather: 10 * 60, // 10 minutes + historical: 60 * 60, // 1 hour +}; + +export async function getCached(key) { + try { + const client = getRedisClient(); + const value = await client.get(key); + + if (value !== null) { + console.log(`[Cache] HIT ${key}`); + return JSON.parse(value); + } + + console.log(`[Cache] MISS ${key}`); + return null; + } catch (err) { + console.warn(`[Cache] GET failed for ${key}:`, err.message); + return null; + } +} + +export async function setCached(key, value, ttl) { + try { + const client = getRedisClient(); + await client.set(key, JSON.stringify(value), 'EX', ttl); + } catch (err) { + console.warn(`[Cache] SET failed for ${key}:`, err.message); + } +} diff --git a/backend/services/weather.service.js b/backend/services/weather.service.js new file mode 100644 index 0000000..1b5b09e --- /dev/null +++ b/backend/services/weather.service.js @@ -0,0 +1,56 @@ +import { getCached, setCached, TTL } from './cache.service.js'; + +const GEO_URL = 'placeholder1'; +const FORECAST_URL = 'placeholder2'; +const ARCHIVE_URL = 'placeholder3'; + +// PLACEHOLDER +async function zipToCoords(zip) { + const res = await fetch(`${GEO_URL}/blahblahblah`); + if (!res.ok) throw new Error(`Geocoding API error: ${res.status}`); + + const data = await res.json(); + if (!data.results?.length) throw new Error(`No location found for zip: ${zip}`); + + const { latitude, longitude, name } = data.results[0]; + return { latitude, longitude, name }; +} + +// PLACEHOLDER +export async function getCurrentWeather(zip) { + const cacheKey = `weather:${zip}`; + const cached = await getCached(cacheKey); + if (cached) return cached; + + const { latitude, longitude, name } = await zipToCoords(zip); + const res = await fetch( + `${FORECAST_URL}/blahblahblah${latitude}${longitude}` + ); + if (!res.ok) throw new Error(`Weather API error: ${res.status}`); + + const data = await res.json(); + const result = { location: name, zip, ...data.current }; + + await setCached(cacheKey, result, TTL.weather); + return result; +} + +// PLACEHOLDER +export async function getHistoricalWeather(zip, startDate, endDate) { + const cacheKey = `historical:${zip}`; + const cached = await getCached(cacheKey); + if (cached) return cached; + + const { latitude, longitude, name } = await zipToCoords(zip); + const res = await fetch( + `${ARCHIVE_URL}/blahblahblah${latitude}${longitude}` + + `blablahblah${startDate}${endDate}` + ); + if (!res.ok) throw new Error(`Historical weather API error: ${res.status}`); + + const data = await res.json(); + const result = { location: name, zip, ...data }; + + await setCached(cacheKey, result, TTL.historical); + return result; +} diff --git a/backend/utils/redisClient.js b/backend/utils/redisClient.js new file mode 100644 index 0000000..be58312 --- /dev/null +++ b/backend/utils/redisClient.js @@ -0,0 +1,24 @@ +import Redis from 'ioredis'; + +let client = null; + +export function getRedisClient() { + if (!client) { + client = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + lazyConnect: true, + enableOfflineQueue: false, + maxRetriesPerRequest: 1, + }); + + client.on('connect', () => console.log('[Redis] Connected')); + client.on('error', (err) => console.warn('[Redis] Error:', err.message)); + + client.connect().catch((err) => { + console.warn('[Redis] Could not connect, caching will be unavailable:', err.message); + }); + } + + return client; +} diff --git a/backend/validators/weather.validator.js b/backend/validators/weather.validator.js new file mode 100644 index 0000000..977fd31 --- /dev/null +++ b/backend/validators/weather.validator.js @@ -0,0 +1,11 @@ +import Joi from 'joi'; + +export const currentWeatherSchema = Joi.object({ + zip: Joi.string().pattern(/^\d{5}$/).required(), +}); + +export const historicalWeatherSchema = Joi.object({ + zip: Joi.string().pattern(/^\d{5}$/).required(), + start_date: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/).required(), + end_date: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/).required(), +}); diff --git a/docker-compose.yml b/docker-compose.yml index 72b4dc6..0567f43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,10 @@ services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + restart: unless-stopped + backend: build: context: . @@ -7,6 +13,8 @@ services: - "3000:3000" env_file: - .env + depends_on: + - redis frontend: build: diff --git a/package-lock.json b/package-lock.json index 13b00e1..038204c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "expo-splash-screen": "~31.0.10", "expo-system-ui": "~6.0.7", "express": "^5.2.1", + "ioredis": "^5.10.0", "jest": "^30.2.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", @@ -2353,6 +2354,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5383,6 +5390,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5939,6 +5955,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8700,6 +8725,30 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10790,12 +10839,24 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -13826,6 +13887,27 @@ "node": ">=0.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14692,6 +14774,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 48ecebd..d7c8370 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "expo-splash-screen": "~31.0.10", "expo-system-ui": "~6.0.7", "express": "^5.2.1", + "ioredis": "^5.10.0", "jest": "^30.2.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2",