UNPKG

@ferjssilva/fast-crud-api

Version:

A complete and fast crud API generator

580 lines (490 loc) 17.8 kB
const { setupCrudRoutes } = require('../../src/routes/crud'); const { isMethodAllowed } = require('../../src/validators/method'); const { transformDocument } = require('../../src/utils/document'); const { buildQuery } = require('../../src/utils/query'); // Mock external modules jest.mock('../../src/validators/method'); jest.mock('../../src/utils/document'); jest.mock('../../src/utils/query'); describe('CRUD Routes - User Scoped', () => { let fastifyMock; let modelMock; let options; let listRouteHandler; let getSingleRouteHandler; let postRouteHandler; let putRouteHandler; let deleteRouteHandler; let listRoutePreHandler; let getSingleRoutePreHandler; let postRoutePreHandler; let putRoutePreHandler; let deleteRoutePreHandler; beforeEach(() => { // Reset all mocks jest.clearAllMocks(); // Default mock for isMethodAllowed (allow everything by default) isMethodAllowed.mockReturnValue(true); // Mock for transformDocument transformDocument.mockImplementation(doc => ({ id: doc._id, ...doc })); // Mock for buildQuery buildQuery.mockReturnValue({ exec: jest.fn().mockResolvedValue([{ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }]) }); // Mock Fastify fastifyMock = { get: jest.fn((route, routeOptions) => { if (route === '/api/user-habits') { listRouteHandler = routeOptions.handler; listRoutePreHandler = routeOptions.preHandler; } else if (route.includes(':id')) { getSingleRouteHandler = routeOptions.handler; getSingleRoutePreHandler = routeOptions.preHandler; } }), post: jest.fn((route, routeOptions) => { postRouteHandler = routeOptions.handler; postRoutePreHandler = routeOptions.preHandler; }), put: jest.fn((route, routeOptions) => { putRouteHandler = routeOptions.handler; putRoutePreHandler = routeOptions.preHandler; }), delete: jest.fn((route, routeOptions) => { deleteRouteHandler = routeOptions.handler; deleteRoutePreHandler = routeOptions.preHandler; }) }; // Mock saved document instance const mockDocInstance = { _id: 'new-id', userId: 'auth0|123', habitId: 'habit1', save: jest.fn().mockResolvedValue({ _id: 'new-id', userId: 'auth0|123', habitId: 'habit1' }) }; // Mock Mongoose model as constructor function modelMock = jest.fn().mockImplementation(() => mockDocInstance); // Add properties to the model modelMock.collection = { name: 'user-habits' }; modelMock.schema = { paths: { userId: { instance: 'String' }, habitId: { instance: 'String' } } }; modelMock.findById = jest.fn().mockReturnValue({ populate: jest.fn().mockReturnThis(), exec: jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }) }); modelMock.findOne = jest.fn().mockReturnValue({ populate: jest.fn().mockReturnThis(), exec: jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }) }); modelMock.findByIdAndUpdate = jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }); modelMock.findOneAndUpdate = jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }); modelMock.findByIdAndDelete = jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123' }); modelMock.findOneAndDelete = jest.fn().mockResolvedValue({ _id: 'habit1', userId: 'auth0|123' }); modelMock.countDocuments = jest.fn().mockResolvedValue(5); // Default options with user-scoped resources options = { methods: { 'user-habits': ['GET', 'POST', 'PUT', 'DELETE'] }, userScoped: ['user-habits'] }; // Execute the setupCrudRoutes function setupCrudRoutes(fastifyMock, modelMock, '/api/user-habits', options); }); describe('GET list - User Scoped', () => { test('should have preHandler attached', () => { expect(listRoutePreHandler).toBeDefined(); expect(typeof listRoutePreHandler).toBe('function'); }); test('should return 401 when request.userId is not set', async () => { const request = { method: 'GET', query: {} }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await listRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(401); expect(reply.send).toHaveBeenCalledWith({ error: 'Unauthorized', message: 'Authentication required' }); }); test('should return 403 when user tries to access other users\' data', async () => { const request = { method: 'GET', userId: 'auth0|123', query: { userId: 'auth0|456' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await listRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(403); expect(reply.send).toHaveBeenCalledWith({ error: 'Forbidden', message: 'Cannot access other users\' data' }); }); test('should inject userId and proceed when authenticated', async () => { const request = { method: 'GET', userId: 'auth0|123', query: {} }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await listRoutePreHandler(request, reply); expect(request.query.userId).toBe('auth0|123'); expect(reply.code).not.toHaveBeenCalled(); }); test('should execute handler successfully with injected userId', async () => { const request = { method: 'GET', userId: 'auth0|123', query: {} }; await listRoutePreHandler(request, { code: jest.fn(), send: jest.fn() }); const result = await listRouteHandler(request); expect(buildQuery).toHaveBeenCalledWith( modelMock, { userId: 'auth0|123' }, expect.any(Object) ); expect(result).toHaveProperty('data'); expect(result).toHaveProperty('pagination'); }); }); describe('GET single - User Scoped', () => { test('should have preHandler attached', () => { expect(getSingleRoutePreHandler).toBeDefined(); expect(typeof getSingleRoutePreHandler).toBe('function'); }); test('should return 401 when request.userId is not set', async () => { const request = { method: 'GET', params: { id: 'habit1' }, query: {} }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await getSingleRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(401); }); test('should inject userId and proceed when authenticated', async () => { const request = { method: 'GET', userId: 'auth0|123', params: { id: 'habit1' }, query: {} }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await getSingleRoutePreHandler(request, reply); expect(request.query.userId).toBe('auth0|123'); expect(reply.code).not.toHaveBeenCalled(); }); }); describe('POST - User Scoped', () => { test('should have preHandler attached', () => { expect(postRoutePreHandler).toBeDefined(); expect(typeof postRoutePreHandler).toBe('function'); }); test('should return 401 when request.userId is not set', async () => { const request = { method: 'POST', body: { habitId: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await postRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(401); }); test('should return 403 when user tries to set different userId', async () => { const request = { method: 'POST', userId: 'auth0|123', body: { userId: 'auth0|456', habitId: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await postRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(403); expect(reply.send).toHaveBeenCalledWith({ error: 'Forbidden', message: 'Cannot modify other users\' data' }); }); test('should inject userId and proceed when authenticated', async () => { const request = { method: 'POST', userId: 'auth0|123', body: { habitId: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await postRoutePreHandler(request, reply); expect(request.body.userId).toBe('auth0|123'); expect(reply.code).not.toHaveBeenCalled(); }); test('should create resource with injected userId', async () => { const request = { method: 'POST', userId: 'auth0|123', body: { habitId: 'habit1' } }; await postRoutePreHandler(request, { code: jest.fn(), send: jest.fn() }); const result = await postRouteHandler(request); expect(modelMock).toHaveBeenCalledWith({ habitId: 'habit1', userId: 'auth0|123' }); expect(result).toHaveProperty('id'); }); }); describe('PUT - User Scoped', () => { test('should have preHandler attached', () => { expect(putRoutePreHandler).toBeDefined(); expect(typeof putRoutePreHandler).toBe('function'); }); test('should return 401 when request.userId is not set', async () => { const request = { method: 'PUT', params: { id: 'habit1' }, body: { habitId: 'habit2' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await putRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(401); }); test('should return 403 when user tries to set different userId', async () => { const request = { method: 'PUT', userId: 'auth0|123', params: { id: 'habit1' }, body: { userId: 'auth0|456', habitId: 'habit2' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await putRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(403); }); test('should inject userId and proceed when authenticated', async () => { const request = { method: 'PUT', userId: 'auth0|123', params: { id: 'habit1' }, body: { habitId: 'habit2' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await putRoutePreHandler(request, reply); expect(request.body.userId).toBe('auth0|123'); expect(reply.code).not.toHaveBeenCalled(); }); test('should atomically verify ownership and update', async () => { modelMock.findOneAndUpdate.mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit2' }); const request = { method: 'PUT', userId: 'auth0|123', params: { id: 'habit1' }, body: { habitId: 'habit2' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await putRoutePreHandler(request, reply); const result = await putRouteHandler(request, reply); // Should use atomic findOneAndUpdate with both _id and userId expect(modelMock.findOneAndUpdate).toHaveBeenCalledWith( { _id: 'habit1', userId: 'auth0|123' }, { habitId: 'habit2', userId: 'auth0|123' }, { new: true, runValidators: true } ); expect(result).toHaveProperty('id'); }); test('should return 404 when trying to update other users\' resource (atomic check)', async () => { // When trying to update someone else's resource, findOneAndUpdate returns null // because the query { _id, userId } won't match modelMock.findOneAndUpdate.mockResolvedValue(null); const request = { method: 'PUT', userId: 'auth0|123', params: { id: 'habit1' }, body: { habitId: 'habit2' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await putRoutePreHandler(request, reply); await putRouteHandler(request, reply); // Returns 404 instead of 403 to not leak resource existence expect(reply.code).toHaveBeenCalledWith(404); expect(reply.send).toHaveBeenCalledWith({ error: 'NotFound', message: 'Resource not found' }); }); }); describe('DELETE - User Scoped', () => { test('should have preHandler attached', () => { expect(deleteRoutePreHandler).toBeDefined(); expect(typeof deleteRoutePreHandler).toBe('function'); }); test('should return 401 when request.userId is not set', async () => { const request = { method: 'DELETE', params: { id: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await deleteRoutePreHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(401); }); test('should pass through preHandler when authenticated', async () => { const request = { method: 'DELETE', userId: 'auth0|123', params: { id: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await deleteRoutePreHandler(request, reply); // PreHandler should not call reply - ownership is checked atomically in the route handler expect(reply.code).not.toHaveBeenCalled(); }); test('should return 404 when resource not found (atomic check in handler)', async () => { modelMock.findOneAndDelete.mockResolvedValue(null); const request = { method: 'DELETE', userId: 'auth0|123', params: { id: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await deleteRoutePreHandler(request, reply); await deleteRouteHandler(request, reply); expect(reply.code).toHaveBeenCalledWith(404); expect(reply.send).toHaveBeenCalledWith({ error: 'NotFound', message: 'Resource not found' }); }); test('should return 404 when trying to delete other users\' resource (atomic check)', async () => { // When trying to delete someone else's resource, findOneAndDelete returns null // because the query { _id, userId } won't match modelMock.findOneAndDelete.mockResolvedValue(null); const request = { method: 'DELETE', userId: 'auth0|123', params: { id: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await deleteRoutePreHandler(request, reply); await deleteRouteHandler(request, reply); // Returns 404 instead of 403 to not leak resource existence expect(reply.code).toHaveBeenCalledWith(404); expect(reply.send).toHaveBeenCalledWith({ error: 'NotFound', message: 'Resource not found' }); }); test('should atomically verify ownership and delete when user owns the resource', async () => { modelMock.findOneAndDelete.mockResolvedValue({ _id: 'habit1', userId: 'auth0|123', habitId: 'habit1' }); const request = { method: 'DELETE', userId: 'auth0|123', params: { id: 'habit1' } }; const reply = { code: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis() }; await deleteRoutePreHandler(request, reply); const result = await deleteRouteHandler(request, reply); // Should use atomic findOneAndDelete with both _id and userId expect(modelMock.findOneAndDelete).toHaveBeenCalledWith({ _id: 'habit1', userId: 'auth0|123' }); expect(result).toEqual({ success: true }); }); }); describe('Non-user-scoped resources', () => { beforeEach(() => { jest.clearAllMocks(); isMethodAllowed.mockReturnValue(true); modelMock.collection = { name: 'categories' }; const nonScopedOptions = { methods: { categories: ['GET', 'POST', 'PUT', 'DELETE'] }, userScoped: ['user-habits'] // categories NOT in this list }; fastifyMock = { get: jest.fn((route, routeOptions) => { if (route === '/api/categories') { listRouteHandler = routeOptions.handler; listRoutePreHandler = routeOptions.preHandler; } }), post: jest.fn((route, routeOptions) => { postRouteHandler = routeOptions.handler; postRoutePreHandler = routeOptions.preHandler; }), put: jest.fn(), delete: jest.fn() }; setupCrudRoutes(fastifyMock, modelMock, '/api/categories', nonScopedOptions); }); test('should not have preHandler for non-user-scoped resources', () => { expect(listRoutePreHandler).toBeUndefined(); expect(postRoutePreHandler).toBeUndefined(); }); }); });