From 7b532e61bd4dc82267c2254ce89bbe815708c8bf Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 22 Jun 2026 05:48:54 +0000 Subject: [PATCH] test: Add unit tests for project CRUD API endpoints --- backend/src/routes/__tests__/projects.test.ts | 551 ++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 backend/src/routes/__tests__/projects.test.ts diff --git a/backend/src/routes/__tests__/projects.test.ts b/backend/src/routes/__tests__/projects.test.ts new file mode 100644 index 0000000..b811a04 --- /dev/null +++ b/backend/src/routes/__tests__/projects.test.ts @@ -0,0 +1,551 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fastify from 'fastify'; +import projectsRoutes from '../projects'; +import { DatabaseInterface } from '../../database/DatabaseInterface'; + +// Mock database interface +const mockDb = { + getSessionByToken: vi.fn(), + getUserById: vi.fn(), + getProjectsByOwner: vi.fn(), + createProject: vi.fn(), + createProjectMember: vi.fn(), + getProjectById: vi.fn(), + updateProject: vi.fn(), + deleteProject: vi.fn(), + getProjectMembers: vi.fn(), + getUserByEmail: vi.fn(), + isProjectMember: vi.fn(), + getProjectMemberById: vi.fn(), + updateProjectMember: vi.fn(), + deleteProjectMember: vi.fn(), + getVersionsByProject: vi.fn(), + createVersion: vi.fn(), + getVersionById: vi.fn(), +}; + +// Mock fastify instance +const app = fastify(); +app.decorate('db', mockDb); + +// Register routes +app.register(projectsRoutes, { prefix: '/projects' }); + +// Test data +const mockUser = { + id: 'user-1', + email: 'test@example.com', + password_hash: 'hash', + display_name: 'Test User', + role: 'Planner', + is_active: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', +}; + +const mockProject = { + id: 'project-1', + name: 'Test Project', + description: 'Test Description', + owner_id: 'user-1', + units: 'm', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + thumbnail_path: 'path/to/thumbnail', +}; + +const mockVersion = { + id: 'version-1', + project_id: 'project-1', + label: 'v1.0', + description: 'Initial version', + snapshot_data: Buffer.from('{}'), + created_by: 'user-1', + created_at: '2023-01-01T00:00:00Z', +}; + +const mockMember = { + id: 'member-1', + project_id: 'project-1', + user_id: 'user-2', + role: 'Betrachter', + invited_at: '2023-01-01T00:00:00Z', + accepted_at: '2023-01-01T00:00:00Z', +}; + +const mockSession = { + id: 'session-1', + user_id: 'user-1', + token_hash: 'token-hash', + expires_at: '2026-01-01T00:00:00Z', + created_at: '2023-01-01T00:00:00Z', + ip_address: '127.0.0.1', + user_agent: 'test-agent', +}; + +// Tests +describe('Project Routes', () => { + beforeEach(() => { + // Clear all mocks + vi.clearAllMocks(); + + // Setup default mock responses + mockDb.getSessionByToken.mockResolvedValue(mockSession); + mockDb.getUserById.mockResolvedValue(mockUser); + mockDb.isProjectMember.mockResolvedValue(true); + }); + + describe('GET /projects', () => { + it('should return user projects when authenticated', async () => { + mockDb.getProjectsByOwner.mockResolvedValue([mockProject]); + + const response = await app.inject({ + method: 'GET', + url: '/projects', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual([mockProject]); + expect(mockDb.getProjectsByOwner).toHaveBeenCalledWith('user-1'); + }); + + it('should return 401 when not authenticated', async () => { + const response = await app.inject({ + method: 'GET', + url: '/projects', + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('POST /projects', () => { + it('should create a new project when authenticated', async () => { + mockDb.createProject.mockResolvedValue(mockProject); + mockDb.createProjectMember.mockResolvedValue({ + id: 'member-1', + project_id: 'project-1', + user_id: 'user-1', + role: 'Admin', + invited_at: '2023-01-01T00:00:00Z', + accepted_at: '2023-01-01T00:00:00Z', + }); + + const response = await app.inject({ + method: 'POST', + url: '/projects', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + name: 'Test Project', + description: 'Test Description', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(mockProject); + expect(mockDb.createProject).toHaveBeenCalled(); + expect(mockDb.createProjectMember).toHaveBeenCalled(); + }); + + it('should return 401 when not authenticated', async () => { + const response = await app.inject({ + method: 'POST', + url: '/projects', + body: { + name: 'Test Project', + }, + }); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('GET /projects/:id', () => { + it('should return project details when user is a member', async () => { + mockDb.getProjectById.mockResolvedValue(mockProject); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-1', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(mockProject); + }); + + it('should return 404 when project not found', async () => { + mockDb.getProjectById.mockResolvedValue(null); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-2', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('PUT /projects/:id', () => { + it('should update project when user is Planner or Admin and a member', async () => { + mockDb.getProjectById.mockResolvedValue(mockProject); + mockDb.updateProject.mockResolvedValue({ + ...mockProject, + name: 'Updated Project', + }); + + const response = await app.inject({ + method: 'PUT', + url: '/projects/project-1', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + name: 'Updated Project', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).name).toBe('Updated Project'); + }); + + it('should return 403 when user is not Planner or Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Betrachter', + }); + + const response = await app.inject({ + method: 'PUT', + url: '/projects/project-1', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + name: 'Updated Project', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('DELETE /projects/:id', () => { + it('should delete project when user is Admin and a member', async () => { + mockDb.getProjectById.mockResolvedValue(mockProject); + + const response = await app.inject({ + method: 'DELETE', + url: '/projects/project-1', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ message: 'Project deleted successfully' }); + expect(mockDb.deleteProject).toHaveBeenCalledWith('project-1'); + }); + + it('should return 403 when user is not Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Planner', + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/projects/project-1', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('GET /projects/:id/members', () => { + it('should return project members when user is a member', async () => { + mockDb.getProjectMembers.mockResolvedValue([mockMember]); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-1/members', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual([mockMember]); + }); + }); + + describe('POST /projects/:id/members', () => { + it('should invite member when user is Admin and a member', async () => { + mockDb.getUserByEmail.mockResolvedValue({ + id: 'user-2', + email: 'invitee@example.com', + password_hash: 'hash', + display_name: 'Invitee User', + role: 'Betrachter', + is_active: 1, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + }); + mockDb.isProjectMember.mockImplementation((projectId, userId) => + Promise.resolve(userId !== 'user-2') + ); + mockDb.createProjectMember.mockResolvedValue(mockMember); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/members', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + email: 'invitee@example.com', + role: 'Betrachter', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(mockMember); + }); + + it('should return 403 when user is not Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Planner', + }); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/members', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + email: 'invitee@example.com', + role: 'Betrachter', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('PUT /projects/:id/members/:userId', () => { + it('should update member role when user is Admin and a member', async () => { + mockDb.getProjectMemberById.mockResolvedValue(mockMember); + mockDb.updateProjectMember.mockResolvedValue({ + ...mockMember, + role: 'Planner', + }); + + const response = await app.inject({ + method: 'PUT', + url: '/projects/project-1/members/user-2', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + role: 'Planner', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).role).toBe('Planner'); + }); + + it('should return 403 when user is not Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Planner', + }); + + const response = await app.inject({ + method: 'PUT', + url: '/projects/project-1/members/user-2', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + role: 'Planner', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('DELETE /projects/:id/members/:userId', () => { + it('should remove member when user is Admin and a member', async () => { + mockDb.getProjectMemberById.mockResolvedValue(mockMember); + + const response = await app.inject({ + method: 'DELETE', + url: '/projects/project-1/members/user-2', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ message: 'Member removed successfully' }); + expect(mockDb.deleteProjectMember).toHaveBeenCalledWith('user-2'); + }); + + it('should return 403 when user is not Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Planner', + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/projects/project-1/members/user-2', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('GET /projects/:id/versions', () => { + it('should return project versions when user is a member', async () => { + mockDb.getVersionsByProject.mockResolvedValue([mockVersion]); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-1/versions', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual([mockVersion]); + }); + }); + + describe('POST /projects/:id/versions', () => { + it('should create version when user is Planner or Admin and a member', async () => { + mockDb.createVersion.mockResolvedValue(mockVersion); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/versions', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + label: 'v1.0', + description: 'Initial version', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(mockVersion); + }); + + it('should return 403 when user is not Planner or Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Betrachter', + }); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/versions', + headers: { + authorization: 'Bearer valid-token', + }, + body: { + label: 'v1.0', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('GET /projects/:id/versions/:vId', () => { + it('should return version details when user is a member', async () => { + mockDb.getVersionById.mockResolvedValue(mockVersion); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-1/versions/version-1', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual(mockVersion); + }); + + it('should return 404 when version not found', async () => { + mockDb.getVersionById.mockResolvedValue(null); + + const response = await app.inject({ + method: 'GET', + url: '/projects/project-1/versions/version-2', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('POST /projects/:id/versions/:vId/restore', () => { + it('should restore version when user is Planner or Admin and a member', async () => { + mockDb.getVersionById.mockResolvedValue(mockVersion); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/versions/version-1/restore', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toEqual({ message: 'Version restored successfully' }); + }); + + it('should return 403 when user is not Planner or Admin', async () => { + mockDb.getUserById.mockResolvedValue({ + ...mockUser, + role: 'Betrachter', + }); + + const response = await app.inject({ + method: 'POST', + url: '/projects/project-1/versions/version-1/restore', + headers: { + authorization: 'Bearer valid-token', + }, + }); + + expect(response.statusCode).toBe(403); + }); + }); +});