diff --git a/backend/package.json b/backend/package.json index 408aa80..c88bf05 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,10 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "nodemon src/index.js" + "dev": "nodemon src/index.js", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { "@aws-sdk/client-s3": "^3.966.0", @@ -27,6 +30,19 @@ "stripe": "^20.1.0" }, "devDependencies": { - "nodemon": "^3.0.2" + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "nodemon": "^3.0.2", + "supertest": "^7.2.2" + }, + "jest": { + "testEnvironment": "node", + "coveragePathIgnorePatterns": [ + "/node_modules/" + ], + "testMatch": [ + "**/__tests__/**/*.js", + "**/?(*.)+(spec|test).js" + ] } } diff --git a/backend/src/routes/__tests__/deployments.test.js b/backend/src/routes/__tests__/deployments.test.js new file mode 100644 index 0000000..83d78d1 --- /dev/null +++ b/backend/src/routes/__tests__/deployments.test.js @@ -0,0 +1,300 @@ +/** + * Tests for deployments routes + * Focus: Missing deploymentId error handling + */ + +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const deploymentsRouter = require('../deployments'); +const { supabase } = require('../../config/supabase'); + +// Mock Supabase +jest.mock('../../config/supabase', () => ({ + supabase: { + from: jest.fn() + } +})); + +// Mock environment +process.env.JWT_SECRET = 'test-secret-key'; + +describe('Deployments Routes - Missing Deployment ID', () => { + let app; + let testToken; + + beforeAll(() => { + // Create test app + app = express(); + app.use(express.json()); + app.use('/api/me', deploymentsRouter); + + // Create test JWT token + testToken = jwt.sign( + { userId: 1, email: 'test@example.com' }, + process.env.JWT_SECRET + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/me/deployments/:id/devices', () => { + it('should return 400 error when legacy_deployment_id is null', async () => { + // Mock user access check - user has access + const mockAccessSelect = jest.fn().mockReturnThis(); + const mockAccessEq1 = jest.fn().mockReturnThis(); + const mockAccessEq2 = jest.fn().mockReturnThis(); + const mockAccessSingle = jest.fn().mockResolvedValue({ + data: { role: 'custodian' }, + error: null + }); + + // Mock deployment fetch - deployment exists but no legacy_deployment_id + const mockDeploymentSelect = jest.fn().mockReturnThis(); + const mockDeploymentEq = jest.fn().mockReturnThis(); + const mockDeploymentSingle = jest.fn().mockResolvedValue({ + data: { + beneficiary_id: 42, + legacy_deployment_id: null // This is the key condition + }, + error: null + }); + + supabase.from.mockImplementation((table) => { + if (table === 'user_access') { + return { + select: mockAccessSelect, + eq: mockAccessEq1 + }; + } + if (table === 'beneficiary_deployments') { + return { + select: mockDeploymentSelect, + eq: mockDeploymentEq + }; + } + }); + + mockAccessSelect.mockReturnValue({ + eq: mockAccessEq1 + }); + mockAccessEq1.mockReturnValue({ + eq: mockAccessEq2 + }); + mockAccessEq2.mockReturnValue({ + single: mockAccessSingle + }); + + mockDeploymentSelect.mockReturnValue({ + eq: mockDeploymentEq + }); + mockDeploymentEq.mockReturnValue({ + single: mockDeploymentSingle + }); + + const response = await request(app) + .get('/api/me/deployments/1/devices') + .set('Authorization', `Bearer ${testToken}`); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'No deployment configured for user'); + expect(response.body).toHaveProperty('code', 'MISSING_DEPLOYMENT_ID'); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toContain('does not have a deployment configured'); + }); + + it('should return 400 error when legacy_deployment_id is undefined', async () => { + // Mock similar setup but with undefined legacy_deployment_id + const mockAccessSelect = jest.fn().mockReturnThis(); + const mockAccessEq1 = jest.fn().mockReturnThis(); + const mockAccessEq2 = jest.fn().mockReturnThis(); + const mockAccessSingle = jest.fn().mockResolvedValue({ + data: { role: 'custodian' }, + error: null + }); + + const mockDeploymentSelect = jest.fn().mockReturnThis(); + const mockDeploymentEq = jest.fn().mockReturnThis(); + const mockDeploymentSingle = jest.fn().mockResolvedValue({ + data: { + beneficiary_id: 42 + // legacy_deployment_id is undefined + }, + error: null + }); + + supabase.from.mockImplementation((table) => { + if (table === 'user_access') { + return { + select: mockAccessSelect, + eq: mockAccessEq1 + }; + } + if (table === 'beneficiary_deployments') { + return { + select: mockDeploymentSelect, + eq: mockDeploymentEq + }; + } + }); + + mockAccessSelect.mockReturnValue({ + eq: mockAccessEq1 + }); + mockAccessEq1.mockReturnValue({ + eq: mockAccessEq2 + }); + mockAccessEq2.mockReturnValue({ + single: mockAccessSingle + }); + + mockDeploymentSelect.mockReturnValue({ + eq: mockDeploymentEq + }); + mockDeploymentEq.mockReturnValue({ + single: mockDeploymentSingle + }); + + const response = await request(app) + .get('/api/me/deployments/1/devices') + .set('Authorization', `Bearer ${testToken}`); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('code', 'MISSING_DEPLOYMENT_ID'); + }); + + it('should proceed normally when legacy_deployment_id exists', async () => { + // Mock user access check + const mockAccessSelect = jest.fn().mockReturnThis(); + const mockAccessEq1 = jest.fn().mockReturnThis(); + const mockAccessEq2 = jest.fn().mockReturnThis(); + const mockAccessSingle = jest.fn().mockResolvedValue({ + data: { role: 'custodian' }, + error: null + }); + + // Mock deployment with valid legacy_deployment_id + const mockDeploymentSelect = jest.fn().mockReturnThis(); + const mockDeploymentEq = jest.fn().mockReturnThis(); + const mockDeploymentSingle = jest.fn().mockResolvedValue({ + data: { + beneficiary_id: 42, + legacy_deployment_id: 123 // Valid deployment ID + }, + error: null + }); + + supabase.from.mockImplementation((table) => { + if (table === 'user_access') { + return { + select: mockAccessSelect, + eq: mockAccessEq1 + }; + } + if (table === 'beneficiary_deployments') { + return { + select: mockDeploymentSelect, + eq: mockDeploymentEq + }; + } + }); + + mockAccessSelect.mockReturnValue({ + eq: mockAccessEq1 + }); + mockAccessEq1.mockReturnValue({ + eq: mockAccessEq2 + }); + mockAccessEq2.mockReturnValue({ + single: mockAccessSingle + }); + + mockDeploymentSelect.mockReturnValue({ + eq: mockDeploymentEq + }); + mockDeploymentEq.mockReturnValue({ + single: mockDeploymentSingle + }); + + const response = await request(app) + .get('/api/me/deployments/1/devices') + .set('Authorization', `Bearer ${testToken}`); + + // Should NOT return error - should proceed to device fetch + // (currently returns empty array as TODO is not implemented) + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('devices'); + expect(Array.isArray(response.body.devices)).toBe(true); + }); + + it('should return 401 when no auth token provided', async () => { + const response = await request(app) + .get('/api/me/deployments/1/devices'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('error', 'No token provided'); + }); + + it('should return 403 when user has no access to beneficiary', async () => { + // Mock user access check - no access + const mockAccessSelect = jest.fn().mockReturnThis(); + const mockAccessEq1 = jest.fn().mockReturnThis(); + const mockAccessEq2 = jest.fn().mockReturnThis(); + const mockAccessSingle = jest.fn().mockResolvedValue({ + data: null, + error: { message: 'No access' } + }); + + const mockDeploymentSelect = jest.fn().mockReturnThis(); + const mockDeploymentEq = jest.fn().mockReturnThis(); + const mockDeploymentSingle = jest.fn().mockResolvedValue({ + data: { + beneficiary_id: 42, + legacy_deployment_id: 123 + }, + error: null + }); + + supabase.from.mockImplementation((table) => { + if (table === 'user_access') { + return { + select: mockAccessSelect, + eq: mockAccessEq1 + }; + } + if (table === 'beneficiary_deployments') { + return { + select: mockDeploymentSelect, + eq: mockDeploymentEq + }; + } + }); + + mockAccessSelect.mockReturnValue({ + eq: mockAccessEq1 + }); + mockAccessEq1.mockReturnValue({ + eq: mockAccessEq2 + }); + mockAccessEq2.mockReturnValue({ + single: mockAccessSingle + }); + + mockDeploymentSelect.mockReturnValue({ + eq: mockDeploymentEq + }); + mockDeploymentEq.mockReturnValue({ + single: mockDeploymentSingle + }); + + const response = await request(app) + .get('/api/me/deployments/1/devices') + .set('Authorization', `Bearer ${testToken}`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('error', 'Access denied'); + }); + }); +}); diff --git a/backend/src/routes/deployments.js b/backend/src/routes/deployments.js index bc78e2d..f0b970d 100644 --- a/backend/src/routes/deployments.js +++ b/backend/src/routes/deployments.js @@ -291,9 +291,13 @@ router.get('/deployments/:id/devices', async (req, res) => { return res.status(403).json({ error: 'Access denied' }); } - // If no legacy_deployment_id, return empty list + // If no legacy_deployment_id, throw error if (!deployment.legacy_deployment_id) { - return res.json({ devices: [] }); + return res.status(400).json({ + error: 'No deployment configured for user', + code: 'MISSING_DEPLOYMENT_ID', + message: 'This beneficiary does not have a deployment configured yet. Please set up the deployment first.' + }); } // TODO: Get devices from Legacy API using legacy_deployment_id