From 7d40e0ececeb12165e38a20ddf78784224c95053 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 00:57:51 +0100 Subject: [PATCH] test(backend): improve coverage for boards.service and templates.service --- .../src/modules/boards/boards.service.spec.ts | 259 ++++++++++++++++++ .../templates/templates.service.spec.ts | 58 ++++ 2 files changed, 317 insertions(+) diff --git a/backend/src/modules/boards/boards.service.spec.ts b/backend/src/modules/boards/boards.service.spec.ts index a8f7550..bad2891 100644 --- a/backend/src/modules/boards/boards.service.spec.ts +++ b/backend/src/modules/boards/boards.service.spec.ts @@ -10,6 +10,16 @@ describe('BoardsService', () => { let service: BoardsService; let prismaService: PrismaService; + let mockTx: { + board: { create: jest.Mock }; + label: { create: jest.Mock }; + list: { create: jest.Mock }; + card: { create: jest.Mock }; + cardLabel: { create: jest.Mock }; + checklist: { create: jest.Mock }; + checklistItem: { create: jest.Mock }; + }; + const mockPrismaService = { board: { create: jest.fn(), @@ -32,6 +42,7 @@ describe('BoardsService', () => { workspaceMember: { findUnique: jest.fn(), }, + $transaction: jest.fn((fn: (tx: unknown) => Promise) => fn(mockTx)), }; const mockNotificationsService = { @@ -71,6 +82,18 @@ describe('BoardsService', () => { }; beforeEach(async () => { + mockTx = { + board: { create: jest.fn() }, + label: { create: jest.fn() }, + list: { create: jest.fn() }, + card: { create: jest.fn() }, + cardLabel: { create: jest.fn() }, + checklist: { create: jest.fn() }, + checklistItem: { create: jest.fn() }, + }; + (mockPrismaService.$transaction as jest.Mock).mockImplementation( + (fn: (tx: typeof mockTx) => Promise) => fn(mockTx), + ); const module: TestingModule = await Test.createTestingModule({ providers: [ BoardsService, @@ -253,6 +276,242 @@ describe('BoardsService', () => { }), ); }); + + it('should create a board with system templateId (kanban)', async () => { + const input = { + title: 'Kanban Board', + templateId: 'kanban', + }; + + mockPrismaService.board.create.mockResolvedValue(mockBoard); + + const result = await service.create(input, mockUser.id); + + expect(result).toEqual(mockBoard); + expect(prismaService.board.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: input.title, + lists: expect.objectContaining({ + create: expect.arrayContaining([ + expect.objectContaining({ title: 'To Do', position: 0 }), + expect.objectContaining({ title: 'In Progress', position: 1 }), + expect.objectContaining({ title: 'Done', position: 2 }), + ]), + }), + }), + }), + ); + }); + + it('should create a board with custom templateId when getTemplateForBoard returns lists', async () => { + const input = { + title: 'From Custom Template', + templateId: 'custom-tpl-uuid', + }; + + const customLists = [ + { title: 'Backlog', position: 0 }, + { title: 'Done', position: 1 }, + ]; + mockTemplatesService.getTemplateForBoard.mockResolvedValue({ lists: customLists }); + mockPrismaService.board.create.mockResolvedValue(mockBoard); + + const result = await service.create(input, mockUser.id); + + expect(result).toEqual(mockBoard); + expect(mockTemplatesService.getTemplateForBoard).toHaveBeenCalledWith( + input.templateId, + mockUser.id, + ); + expect(prismaService.board.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lists: { + create: [ + { title: 'Backlog', position: 0 }, + { title: 'Done', position: 1 }, + ], + }, + }), + }), + ); + }); + + it('should create a board with custom templateId when getTemplateForBoard returns null (fallback to system template)', async () => { + const input = { + title: 'Fallback Template Board', + templateId: 'custom-missing', + }; + + mockTemplatesService.getTemplateForBoard.mockResolvedValue(null); + mockPrismaService.board.create.mockResolvedValue(mockBoard); + + await service.create(input, mockUser.id); + + expect(mockTemplatesService.getTemplateForBoard).toHaveBeenCalledWith( + input.templateId, + mockUser.id, + ); + expect(prismaService.board.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lists: expect.objectContaining({ create: expect.any(Array) }), + }), + }), + ); + }); + }); + + describe('copy', () => { + const sourceBoard = { + id: 'source-1', + title: 'Source Board', + description: 'Source desc', + workspaceId: 'workspace-1', + visibility: Visibility.PRIVATE, + background: null, + isArchived: false, + creatorId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + members: [{ id: 'm1', boardId: 'source-1', userId: 'user-1', role: Role.ADMIN, joinedAt: new Date() }], + workspace: { id: 'workspace-1', memberships: [{ userId: 'user-1' }] }, + labels: [ + { id: 'label-1', boardId: 'source-1', name: 'Bug', color: '#red' }, + ], + lists: [ + { + id: 'list-1', + boardId: 'source-1', + title: 'To Do', + position: 0, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + cards: [ + { + id: 'card-1', + listId: 'list-1', + title: 'Card 1', + description: null, + background: null, + startDate: null, + dueDate: null, + position: 0, + completed: false, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + labels: [{ labelId: 'label-1' }], + checklists: [ + { + id: 'cl-1', + cardId: 'card-1', + title: 'Check', + items: [ + { id: 'item-1', checklistId: 'cl-1', content: 'Item', checked: false, position: 0 }, + ], + }, + ], + }, + ], + }, + ], + }; + + it('should copy a board with lists, cards, labels and checklists', async () => { + mockPrismaService.board.findUnique.mockResolvedValue(sourceBoard); + const newBoard = { id: 'new-board-1', title: 'Copied', workspaceId: 'workspace-1' }; + mockTx.board.create.mockResolvedValue(newBoard); + mockTx.label.create.mockResolvedValue({ id: 'new-label-1' }); + mockTx.list.create.mockResolvedValue({ id: 'new-list-1' }); + mockTx.card.create.mockResolvedValue({ id: 'new-card-1' }); + mockTx.cardLabel.create.mockResolvedValue({}); + mockTx.checklist.create.mockResolvedValue({ id: 'new-cl-1' }); + mockTx.checklistItem.create.mockResolvedValue({}); + + const result = await service.copy( + { sourceBoardId: 'source-1', title: 'Copied' }, + mockUser.id, + ); + + expect(result).toEqual(newBoard); + expect(mockPrismaService.$transaction).toHaveBeenCalled(); + expect(mockTx.board.create).toHaveBeenCalled(); + expect(mockTx.label.create).toHaveBeenCalled(); + expect(mockTx.list.create).toHaveBeenCalled(); + expect(mockTx.card.create).toHaveBeenCalled(); + expect(mockTx.checklist.create).toHaveBeenCalled(); + expect(mockTx.checklistItem.create).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if source board not found', async () => { + mockPrismaService.board.findUnique.mockResolvedValue(null); + + await expect( + service.copy({ sourceBoardId: 'invalid', title: 'Copy' }, mockUser.id), + ).rejects.toThrow(NotFoundException); + await expect( + service.copy({ sourceBoardId: 'invalid', title: 'Copy' }, mockUser.id), + ).rejects.toThrow('Board not found'); + }); + + it('should throw ForbiddenException if user has no access to source board', async () => { + mockPrismaService.board.findUnique.mockResolvedValue({ + ...sourceBoard, + visibility: Visibility.PRIVATE, + members: [], + workspace: null, + }); + + await expect( + service.copy({ sourceBoardId: 'source-1', title: 'Copy' }, 'other-user'), + ).rejects.toThrow(ForbiddenException); + await expect( + service.copy({ sourceBoardId: 'source-1', title: 'Copy' }, 'other-user'), + ).rejects.toThrow('You do not have access to this board'); + }); + + it('should throw ForbiddenException if copying to workspace where user is not member', async () => { + mockPrismaService.board.findUnique.mockResolvedValue(sourceBoard); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null); + + await expect( + service.copy( + { sourceBoardId: 'source-1', title: 'Copy', workspaceId: 'other-ws' }, + mockUser.id, + ), + ).rejects.toThrow(ForbiddenException); + await expect( + service.copy( + { sourceBoardId: 'source-1', title: 'Copy', workspaceId: 'other-ws' }, + mockUser.id, + ), + ).rejects.toThrow('You are not a member of this workspace'); + }); + + it('should throw ForbiddenException if copying to workspace where user is OBSERVER', async () => { + mockPrismaService.board.findUnique.mockResolvedValue(sourceBoard); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ + userId: mockUser.id, + workspaceId: 'other-ws', + role: Role.OBSERVER, + }); + + await expect( + service.copy( + { sourceBoardId: 'source-1', title: 'Copy', workspaceId: 'other-ws' }, + mockUser.id, + ), + ).rejects.toThrow(ForbiddenException); + await expect( + service.copy( + { sourceBoardId: 'source-1', title: 'Copy', workspaceId: 'other-ws' }, + mockUser.id, + ), + ).rejects.toThrow('Observers cannot create boards'); + }); }); describe('findOne', () => { diff --git a/backend/src/modules/templates/templates.service.spec.ts b/backend/src/modules/templates/templates.service.spec.ts index dc07dd5..24ddb26 100644 --- a/backend/src/modules/templates/templates.service.spec.ts +++ b/backend/src/modules/templates/templates.service.spec.ts @@ -196,6 +196,29 @@ describe('TemplatesService', () => { service.createFromBoard('board-1', mockUser.id), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('should throw when board is WORKSPACE visibility and user is not workspace member', async () => { + mockPrismaService.board.findUnique.mockResolvedValue({ + id: 'board-1', + title: 'Workspace Board', + description: '', + visibility: 'WORKSPACE', + workspaceId: 'ws-1', + members: [], + workspace: { + id: 'ws-1', + memberships: [{ userId: 'other-user' }], + }, + lists: [{ id: 'l1', title: 'List', position: 0, cards: [] }], + }); + + await expect( + service.createFromBoard('board-1', mockUser.id), + ).rejects.toBeInstanceOf(ForbiddenException); + await expect( + service.createFromBoard('board-1', mockUser.id), + ).rejects.toThrow('You do not have access to this board'); + }); }); describe('findOne', () => { @@ -253,6 +276,19 @@ describe('TemplatesService', () => { ForbiddenException, ); }); + + it('should throw when visibility WORKSPACE and user is not workspace member', async () => { + const row = { ...mockTemplateRow, visibility: 'WORKSPACE' as const, workspaceId: 'ws-1', creatorId: 'other-user' }; + mockPrismaService.boardTemplate.findUnique.mockResolvedValue(row); + mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null); + + await expect(service.findOne('tpl-1', mockUser.id)).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect(service.findOne('tpl-1', mockUser.id)).rejects.toThrow( + 'You do not have access to this template', + ); + }); }); describe('findAll', () => { @@ -334,6 +370,17 @@ describe('TemplatesService', () => { ], }); }); + + it('should return empty lists when row.lists is not an array', async () => { + mockPrismaService.boardTemplate.findUnique.mockResolvedValue({ + ...mockTemplateRow, + lists: null, + }); + + const result = await service.getTemplateForBoard('tpl-1', mockUser.id); + + expect(result).toEqual({ lists: [] }); + }); }); describe('update', () => { @@ -408,6 +455,17 @@ describe('TemplatesService', () => { ); }); + it('should throw BadRequestException when update sets lists to empty array', async () => { + mockPrismaService.boardTemplate.findUnique.mockResolvedValue(mockTemplateRow); + + await expect( + service.update({ id: 'tpl-1', lists: [] }, mockUser.id), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update({ id: 'tpl-1', lists: [] }, mockUser.id), + ).rejects.toThrow('At least one list is required'); + }); + it('should throw when template not found', async () => { mockPrismaService.boardTemplate.findUnique.mockResolvedValue(null);