@ferjssilva/fast-crud-api
Version:
A complete and fast crud API generator
580 lines (490 loc) • 17.8 kB
JavaScript
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();
});
});
});