From 91f0071d3a4f53ad69ebe10d45140b57c09ba3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andres=20Patin=CC=83o?= <97bermudez.andres@gmail.com> Date: Thu, 14 May 2026 21:39:06 -0500 Subject: [PATCH] test: add unit tests for application components --- .../core/guards/unsaved-changes-guard.spec.ts | 29 ++ .../api-response-interceptor.spec.ts | 37 ++ .../interceptors/error-interceptor.spec.ts | 96 +++++ tests/app/core/services/notification.spec.ts | 86 +++++ .../models/task-state.model.spec.ts | 24 ++ .../data-access/models/task.model.spec.ts | 46 +++ .../resolvers/task-resolver.spec.ts | 66 ++++ .../tasks/data-access/services/task.spec.ts | 148 ++++++++ .../data-access/store/task-store.spec.ts | 345 ++++++++++++++++++ .../task-create-page/task-create-page.spec.ts | 83 +++++ .../task-detail-page/task-detail-page.spec.ts | 65 ++++ .../task-list-page/task-list-page.spec.ts | 154 ++++++++ .../task-board-column.spec.ts | 113 ++++++ .../tasks/ui/task-card/task-card.spec.ts | 90 +++++ .../task-create-sidebar.spec.ts | 97 +++++ .../task-detail-sidebar.spec.ts | 135 +++++++ .../tasks/ui/task-form/task-form.spec.ts | 80 ++++ .../ui/task-note-list/task-note-list.spec.ts | 96 +++++ .../task-state-badge/task-state-badge.spec.ts | 77 ++++ tests/app/shared/models/result.model.spec.ts | 62 ++++ tests/app/shared/pipes/safe-date-pipe.spec.ts | 38 ++ tests/app/shared/ui/button/button.spec.ts | 118 ++++++ tests/app/shared/ui/card/card.spec.ts | 71 ++++ .../shared/ui/form-field/form-field.spec.ts | 103 ++++++ tests/app/shared/ui/modal/modal.spec.ts | 28 ++ .../app/shared/ui/not-found/not-found.spec.ts | 56 +++ .../shared/ui/pagination/pagination.spec.ts | 121 ++++++ tests/app/shared/ui/spinner/spinner.spec.ts | 65 ++++ tests/app/shared/ui/toast/toast.spec.ts | 98 +++++ tests/app/shared/utils/date.util.spec.ts | 114 ++++++ .../shared/utils/form-validators.util.spec.ts | 118 ++++++ tests/builders/task.builder.ts | 59 +++ 32 files changed, 2918 insertions(+) create mode 100644 tests/app/core/guards/unsaved-changes-guard.spec.ts create mode 100644 tests/app/core/interceptors/api-response-interceptor.spec.ts create mode 100644 tests/app/core/interceptors/error-interceptor.spec.ts create mode 100644 tests/app/core/services/notification.spec.ts create mode 100644 tests/app/features/tasks/data-access/models/task-state.model.spec.ts create mode 100644 tests/app/features/tasks/data-access/models/task.model.spec.ts create mode 100644 tests/app/features/tasks/data-access/resolvers/task-resolver.spec.ts create mode 100644 tests/app/features/tasks/data-access/services/task.spec.ts create mode 100644 tests/app/features/tasks/data-access/store/task-store.spec.ts create mode 100644 tests/app/features/tasks/feature/task-create-page/task-create-page.spec.ts create mode 100644 tests/app/features/tasks/feature/task-detail-page/task-detail-page.spec.ts create mode 100644 tests/app/features/tasks/feature/task-list-page/task-list-page.spec.ts create mode 100644 tests/app/features/tasks/ui/task-board-column/task-board-column.spec.ts create mode 100644 tests/app/features/tasks/ui/task-card/task-card.spec.ts create mode 100644 tests/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.spec.ts create mode 100644 tests/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.spec.ts create mode 100644 tests/app/features/tasks/ui/task-form/task-form.spec.ts create mode 100644 tests/app/features/tasks/ui/task-note-list/task-note-list.spec.ts create mode 100644 tests/app/features/tasks/ui/task-state-badge/task-state-badge.spec.ts create mode 100644 tests/app/shared/models/result.model.spec.ts create mode 100644 tests/app/shared/pipes/safe-date-pipe.spec.ts create mode 100644 tests/app/shared/ui/button/button.spec.ts create mode 100644 tests/app/shared/ui/card/card.spec.ts create mode 100644 tests/app/shared/ui/form-field/form-field.spec.ts create mode 100644 tests/app/shared/ui/modal/modal.spec.ts create mode 100644 tests/app/shared/ui/not-found/not-found.spec.ts create mode 100644 tests/app/shared/ui/pagination/pagination.spec.ts create mode 100644 tests/app/shared/ui/spinner/spinner.spec.ts create mode 100644 tests/app/shared/ui/toast/toast.spec.ts create mode 100644 tests/app/shared/utils/date.util.spec.ts create mode 100644 tests/app/shared/utils/form-validators.util.spec.ts create mode 100644 tests/builders/task.builder.ts diff --git a/tests/app/core/guards/unsaved-changes-guard.spec.ts b/tests/app/core/guards/unsaved-changes-guard.spec.ts new file mode 100644 index 0000000..9f5f0c0 --- /dev/null +++ b/tests/app/core/guards/unsaved-changes-guard.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { unsavedChangesGuard, CanComponentDeactivate } from '@app/core/guards/unsaved-changes-guard'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +describe('unsavedChangesGuard', () => { + it('should return true when component has no canDeactivate method', () => { + const component = {} as CanComponentDeactivate; + + const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot); + + expect(result).toBe(true); + }); + + it('should return result of component.canDeactivate when method exists', () => { + const component = { canDeactivate: () => false } as CanComponentDeactivate; + + const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot); + + expect(result).toBe(false); + }); + + it('should return true when canDeactivate returns true', () => { + const component = { canDeactivate: () => true } as CanComponentDeactivate; + + const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot); + + expect(result).toBe(true); + }); +}); diff --git a/tests/app/core/interceptors/api-response-interceptor.spec.ts b/tests/app/core/interceptors/api-response-interceptor.spec.ts new file mode 100644 index 0000000..56272b0 --- /dev/null +++ b/tests/app/core/interceptors/api-response-interceptor.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { apiResponseInterceptor } from '@app/core/interceptors/api-response-interceptor'; + +describe('apiResponseInterceptor', () => { + it('should extract data from ApiResponse wrapper', async () => { + const req = {} as never; + const apiResponse = { success: true, data: { id: 1, name: 'test' } }; + const response = new HttpResponse({ body: apiResponse }); + const next = () => of(response); + + const result = await apiResponseInterceptor(req, next).toPromise(); + + expect((result as HttpResponse).body).toEqual({ id: 1, name: 'test' }); + }); + + it('should pass through when body does not have ApiResponse structure', async () => { + const req = {} as never; + const body = { id: 1, name: 'test' }; + const response = new HttpResponse({ body }); + const next = () => of(response); + + const result = await apiResponseInterceptor(req, next).toPromise(); + + expect((result as HttpResponse).body).toEqual(body); + }); + + it('should pass through when event is not HttpResponse', async () => { + const req = {} as never; + const next = () => of({ type: 0 }); + + const result = await apiResponseInterceptor(req, next).toPromise(); + + expect(result).toEqual({ type: 0 }); + }); +}); diff --git a/tests/app/core/interceptors/error-interceptor.spec.ts b/tests/app/core/interceptors/error-interceptor.spec.ts new file mode 100644 index 0000000..18d3e53 --- /dev/null +++ b/tests/app/core/interceptors/error-interceptor.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { errorInterceptor } from '@app/core/interceptors/error-interceptor'; +import { Notification } from '@app/core/services/notification'; + +describe('errorInterceptor', () => { + let notification: Notification; + + beforeEach(() => { + TestBed.configureTestingModule({}); + notification = TestBed.inject(Notification); + vi.spyOn(notification, 'error'); + }); + + it('should pass through successful requests', async () => { + const req = {} as never; + const response = new HttpResponse({ body: { data: 'test' } }); + const next = () => of(response); + + const result = await TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ); + + expect(result).toEqual(response); + }); + + it('should call notification.error on HTTP error', async () => { + const req = {} as never; + const error = new HttpErrorResponse({ status: 500, error: { message: 'Server error' } }); + const next = () => throwError(() => error); + + try { + await TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ); + } catch {} + + expect(notification.error).toHaveBeenCalledWith('A server error occurred. Please try again later.'); + }); + + it('should handle network errors', async () => { + const req = {} as never; + const error = new HttpErrorResponse({ status: 0 }); + const next = () => throwError(() => error); + + try { + await TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ); + } catch {} + + expect(notification.error).toHaveBeenCalledWith('Unable to connect to the server. Please check your connection.'); + }); + + it('should handle 404 errors', async () => { + const req = {} as never; + const error = new HttpErrorResponse({ status: 404 }); + const next = () => throwError(() => error); + + try { + await TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ); + } catch {} + + expect(notification.error).toHaveBeenCalledWith('The requested resource was not found.'); + }); + + it('should handle 400 errors', async () => { + const req = {} as never; + const error = new HttpErrorResponse({ status: 400, error: { message: 'Bad request' } }); + const next = () => throwError(() => error); + + try { + await TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ); + } catch {} + + expect(notification.error).toHaveBeenCalledWith('Bad request'); + }); + + it('should rethrow the error', async () => { + const req = {} as never; + const error = new HttpErrorResponse({ status: 500 }); + const next = () => throwError(() => error); + + await expect( + TestBed.runInInjectionContext(() => + errorInterceptor(req, next).toPromise() + ) + ).rejects.toThrow(); + }); +}); diff --git a/tests/app/core/services/notification.spec.ts b/tests/app/core/services/notification.spec.ts new file mode 100644 index 0000000..8b8bf41 --- /dev/null +++ b/tests/app/core/services/notification.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Notification } from '@app/core/services/notification'; + +describe('Notification', () => { + let service: Notification; + + beforeEach(() => { + service = new Notification(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + it('should start with empty notifications', () => { + expect(service.notifications()).toEqual([]); + }); + + it('should add success notification', () => { + service.success('Operation successful'); + + expect(service.notifications()).toHaveLength(1); + expect(service.notifications()[0].type).toBe('success'); + expect(service.notifications()[0].message).toBe('Operation successful'); + }); + + it('should add error notification', () => { + service.error('Something failed'); + + expect(service.notifications()).toHaveLength(1); + expect(service.notifications()[0].type).toBe('error'); + expect(service.notifications()[0].message).toBe('Something failed'); + }); + + it('should add warning notification', () => { + service.warning('Be careful'); + + expect(service.notifications()).toHaveLength(1); + expect(service.notifications()[0].type).toBe('warning'); + }); + + it('should add info notification', () => { + service.info('FYI'); + + expect(service.notifications()).toHaveLength(1); + expect(service.notifications()[0].type).toBe('info'); + }); + + it('should dismiss notification by id', () => { + service.success('Test 1'); + service.error('Test 2'); + + expect(service.notifications()).toHaveLength(2); + + service.dismiss(service.notifications()[0].id); + + expect(service.notifications()).toHaveLength(1); + expect(service.notifications()[0].message).toBe('Test 2'); + }); + + it('should auto-dismiss after 5 seconds', () => { + service.success('Auto dismiss'); + + expect(service.notifications()).toHaveLength(1); + + vi.advanceTimersByTime(5000); + + expect(service.notifications()).toHaveLength(0); + }); + + it('should assign unique ids', () => { + service.success('First'); + service.error('Second'); + service.warning('Third'); + + const ids = service.notifications().map(n => n.id); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).toBe(3); + }); +}); diff --git a/tests/app/features/tasks/data-access/models/task-state.model.spec.ts b/tests/app/features/tasks/data-access/models/task-state.model.spec.ts new file mode 100644 index 0000000..3e1bde5 --- /dev/null +++ b/tests/app/features/tasks/data-access/models/task-state.model.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { defaultFilter, defaultSort } from '@app/features/tasks/data-access/models/task-state.model'; + +describe('task-state.model', () => { + describe('defaultFilter', () => { + it('should have state as all', () => { + expect(defaultFilter.state).toBe('all'); + }); + + it('should have empty search string', () => { + expect(defaultFilter.search).toBe(''); + }); + }); + + describe('defaultSort', () => { + it('should have field as dueDate', () => { + expect(defaultSort.field).toBe('dueDate'); + }); + + it('should have direction as asc', () => { + expect(defaultSort.direction).toBe('asc'); + }); + }); +}); diff --git a/tests/app/features/tasks/data-access/models/task.model.spec.ts b/tests/app/features/tasks/data-access/models/task.model.spec.ts new file mode 100644 index 0000000..81b8792 --- /dev/null +++ b/tests/app/features/tasks/data-access/models/task.model.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createTask } from '@app/features/tasks/data-access/models/task.model'; + +describe('task.model', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('createTask', () => { + it('should create task with all provided values', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-13')); + const dto = { title: 'My Task', description: 'My description', dueDate: '2026-12-01' }; + + const task = createTask(dto); + + expect(task.title).toBe('My Task'); + expect(task.description).toBe('My description'); + expect(task.dueDate).toBe('2026-12-01'); + expect(task.stateHistory).toEqual([{ state: 'new', date: '2026-05-13' }]); + expect(task.notes).toEqual([]); + expect(task.id).toBeDefined(); + }); + + it('should create task with default values when optional fields are missing', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-13')); + const dto = { title: 'Minimal Task' }; + + const task = createTask(dto); + + expect(task.title).toBe('Minimal Task'); + expect(task.description).toBe(''); + expect(task.dueDate).toBe('2026-05-13'); + }); + + it('should generate unique ids for different tasks', () => { + const dto = { title: 'Task' }; + + const task1 = createTask(dto); + const task2 = createTask(dto); + + expect(task1.id).not.toBe(task2.id); + }); + }); +}); diff --git a/tests/app/features/tasks/data-access/resolvers/task-resolver.spec.ts b/tests/app/features/tasks/data-access/resolvers/task-resolver.spec.ts new file mode 100644 index 0000000..fd4a584 --- /dev/null +++ b/tests/app/features/tasks/data-access/resolvers/task-resolver.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { taskResolver } from '@app/features/tasks/data-access/resolvers/task-resolver'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { TaskStore } from '@app/features/tasks/data-access/store/task-store'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('taskResolver', () => { + let taskService: TaskService; + let taskStore: TaskStore; + + beforeEach(() => { + taskService = { + getById: vi.fn(), + } as unknown as TaskService; + + taskStore = { + setSelectedTask: vi.fn(), + } as unknown as TaskStore; + + TestBed.configureTestingModule({ + providers: [ + { provide: TaskService, useValue: taskService }, + { provide: TaskStore, useValue: taskStore }, + ], + }); + }); + + function createRoute(id: string | null) { + return { + paramMap: { + get: vi.fn().mockReturnValue(id), + }, + } as unknown as ActivatedRouteSnapshot; + } + + it('should return null when no id param', async () => { + const route = createRoute(null); + + const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot)); + + expect(result).toBeNull(); + }); + + it('should return task and set it in store when id exists', async () => { + const task = new TaskBuilder().withId('task-1').build(); + vi.mocked(taskService.getById).mockReturnValue(of(task)); + const route = createRoute('task-1'); + + const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot)); + + expect(result).toEqual(task); + expect(taskStore.setSelectedTask).toHaveBeenCalledWith(task); + }); + + it('should return null when service throws error', async () => { + vi.mocked(taskService.getById).mockReturnValue(throwError(() => new Error('Not found'))); + const route = createRoute('task-1'); + + const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot)); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/app/features/tasks/data-access/services/task.spec.ts b/tests/app/features/tasks/data-access/services/task.spec.ts new file mode 100644 index 0000000..5536a61 --- /dev/null +++ b/tests/app/features/tasks/data-access/services/task.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { TaskDataSource } from '@app/features/tasks/data-access/data-sources/task-data-source.interface'; +import { TASK_DATA_SOURCE } from '@app/features/tasks/data-access/data-sources/task-data-source.token'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskService', () => { + let service: TaskService; + let dataSource: TaskDataSource; + + beforeEach(() => { + dataSource = { + getAll: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transition: vi.fn(), + addNote: vi.fn(), + deleteNote: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + TaskService, + { provide: TASK_DATA_SOURCE, useValue: dataSource }, + ], + }); + + service = TestBed.inject(TaskService); + }); + + describe('getAll', () => { + it('should return paginated tasks from datasource', () => { + const tasks = TaskBuilder.buildMany(3); + const response = { data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } }; + vi.mocked(dataSource.getAll).mockReturnValue(of(response)); + + service.getAll().subscribe(result => { + expect(result).toEqual(response); + }); + + expect(dataSource.getAll).toHaveBeenCalledOnce(); + }); + + it('should pass pagination params to datasource', () => { + const params = { page: 2, pageSize: 5 }; + const response = { data: [], pagination: { page: 2, pageSize: 5, total: 0, totalPages: 0 } }; + vi.mocked(dataSource.getAll).mockReturnValue(of(response)); + + service.getAll(params).subscribe(); + + expect(dataSource.getAll).toHaveBeenCalledWith(params); + }); + }); + + describe('getById', () => { + it('should return task by id from datasource', () => { + const task = new TaskBuilder().withId('task-1').build(); + vi.mocked(dataSource.getById).mockReturnValue(of(task)); + + service.getById('task-1').subscribe(result => { + expect(result).toEqual(task); + }); + + expect(dataSource.getById).toHaveBeenCalledWith('task-1'); + }); + }); + + describe('create', () => { + it('should create task through datasource', () => { + const dto = { title: 'New Task', description: 'Description' }; + const created = new TaskBuilder().withTitle('New Task').build(); + vi.mocked(dataSource.create).mockReturnValue(of(created)); + + service.create(dto).subscribe(result => { + expect(result).toEqual(created); + }); + + expect(dataSource.create).toHaveBeenCalledWith(dto); + }); + }); + + describe('update', () => { + it('should update task through datasource', () => { + const dto = { title: 'Updated' }; + const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build(); + vi.mocked(dataSource.update).mockReturnValue(of(updated)); + + service.update('task-1', dto).subscribe(result => { + expect(result).toEqual(updated); + }); + + expect(dataSource.update).toHaveBeenCalledWith('task-1', dto); + }); + }); + + describe('delete', () => { + it('should delete task through datasource', () => { + vi.mocked(dataSource.delete).mockReturnValue(of(undefined)); + + service.delete('task-1').subscribe(); + + expect(dataSource.delete).toHaveBeenCalledWith('task-1'); + }); + }); + + describe('transition', () => { + it('should transition task state through datasource', () => { + const updated = new TaskBuilder().withId('task-1').withState('active').build(); + vi.mocked(dataSource.transition).mockReturnValue(of(updated)); + + service.transition('task-1', 'active').subscribe(result => { + expect(result).toEqual(updated); + }); + + expect(dataSource.transition).toHaveBeenCalledWith('task-1', 'active'); + }); + }); + + describe('addNote', () => { + it('should add note through datasource', () => { + const updated = new TaskBuilder().withId('task-1').withNotes(['New note']).build(); + vi.mocked(dataSource.addNote).mockReturnValue(of(updated)); + + service.addNote('task-1', 'New note').subscribe(result => { + expect(result).toEqual(updated); + }); + + expect(dataSource.addNote).toHaveBeenCalledWith('task-1', 'New note'); + }); + }); + + describe('deleteNote', () => { + it('should delete note through datasource', () => { + const updated = new TaskBuilder().withId('task-1').withNotes([]).build(); + vi.mocked(dataSource.deleteNote).mockReturnValue(of(updated)); + + service.deleteNote('task-1', 0).subscribe(result => { + expect(result).toEqual(updated); + }); + + expect(dataSource.deleteNote).toHaveBeenCalledWith('task-1', 0); + }); + }); +}); diff --git a/tests/app/features/tasks/data-access/store/task-store.spec.ts b/tests/app/features/tasks/data-access/store/task-store.spec.ts new file mode 100644 index 0000000..863ea01 --- /dev/null +++ b/tests/app/features/tasks/data-access/store/task-store.spec.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { TaskStore } from '@app/features/tasks/data-access/store/task-store'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskStore', () => { + let store: TaskStore; + let taskService: TaskService; + + beforeEach(() => { + taskService = { + getAll: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transition: vi.fn(), + addNote: vi.fn(), + deleteNote: vi.fn(), + } as unknown as TaskService; + + TestBed.configureTestingModule({ + providers: [ + TaskStore, + { provide: TaskService, useValue: taskService }, + ], + }); + + store = TestBed.inject(TaskStore); + }); + + describe('loadTasks', () => { + it('should load tasks and update signals', () => { + const tasks = TaskBuilder.buildMany(3); + const pagination = { page: 1, pageSize: 10, total: 3, totalPages: 1 }; + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination })); + + store.loadTasks(); + + expect(store.tasks()).toEqual(tasks); + expect(store.pagination()).toEqual(pagination); + expect(store.loading()).toBe(false); + expect(store.error()).toBeNull(); + }); + + it('should set loading true while fetching', () => { + vi.mocked(taskService.getAll).mockReturnValue(of({ data: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } })); + + store.loadTasks(); + + expect(store.loading()).toBe(false); + }); + + it('should set error when request fails', () => { + vi.mocked(taskService.getAll).mockReturnValue(throwError(() => new Error('Network error'))); + + store.loadTasks(); + + expect(store.error()).toBe('Network error'); + expect(store.loading()).toBe(false); + }); + }); + + describe('loadTask', () => { + it('should load single task and set selected task', () => { + const task = new TaskBuilder().withId('task-1').build(); + vi.mocked(taskService.getById).mockReturnValue(of(task)); + + store.loadTask('task-1'); + + expect(store.selectedTask()).toEqual(task); + expect(store.loading()).toBe(false); + }); + + it('should set error when loading single task fails', () => { + vi.mocked(taskService.getById).mockReturnValue(throwError(() => new Error('Not found'))); + + store.loadTask('task-1'); + + expect(store.error()).toBe('Not found'); + expect(store.loading()).toBe(false); + }); + }); + + describe('setSelectedTask', () => { + it('should set the selected task', () => { + const task = new TaskBuilder().withId('task-1').build(); + + store.setSelectedTask(task); + + expect(store.selectedTask()).toEqual(task); + }); + }); + + describe('createTask', () => { + it('should add new task to tasks list', () => { + const existing = TaskBuilder.buildMany(2); + const newTask = new TaskBuilder().withId('task-3').withTitle('New').build(); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: existing, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } })); + vi.mocked(taskService.create).mockReturnValue(of(newTask)); + + store.loadTasks(); + store.createTask({ title: 'New' }); + + expect(store.tasks()).toHaveLength(3); + expect(store.tasks()).toContainEqual(newTask); + expect(store.loading()).toBe(false); + }); + + it('should set error when creation fails', () => { + vi.mocked(taskService.create).mockReturnValue(throwError(() => new Error('Create failed'))); + + store.createTask({ title: 'Fail' }); + + expect(store.error()).toBe('Create failed'); + expect(store.loading()).toBe(false); + }); + }); + + describe('updateTask', () => { + it('should update existing task in list', () => { + const original = TaskBuilder.buildMany(2); + const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build(); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: original, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } })); + vi.mocked(taskService.update).mockReturnValue(of(updated)); + + store.loadTasks(); + store.updateTask('task-1', { title: 'Updated' }); + + expect(store.tasks().find(t => t.id === 'task-1')?.title).toBe('Updated'); + expect(store.loading()).toBe(false); + }); + + it('should update selected task if it matches', () => { + const task = new TaskBuilder().withId('task-1').build(); + const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build(); + vi.mocked(taskService.getById).mockReturnValue(of(task)); + vi.mocked(taskService.update).mockReturnValue(of(updated)); + + store.loadTask('task-1'); + store.updateTask('task-1', { title: 'Updated' }); + + expect(store.selectedTask()?.title).toBe('Updated'); + }); + + it('should set error when update fails', () => { + vi.mocked(taskService.update).mockReturnValue(throwError(() => new Error('Update failed'))); + + store.updateTask('task-1', { title: 'Fail' }); + + expect(store.error()).toBe('Update failed'); + expect(store.loading()).toBe(false); + }); + }); + + describe('deleteTask', () => { + it('should remove task from list', () => { + const tasks = TaskBuilder.buildMany(3); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } })); + vi.mocked(taskService.delete).mockReturnValue(of(undefined)); + + store.loadTasks(); + store.deleteTask('task-1'); + + expect(store.tasks()).toHaveLength(2); + expect(store.tasks().find(t => t.id === 'task-1')).toBeUndefined(); + }); + + it('should clear selected task if deleted task was selected', () => { + const task = new TaskBuilder().withId('task-1').build(); + vi.mocked(taskService.getById).mockReturnValue(of(task)); + vi.mocked(taskService.delete).mockReturnValue(of(undefined)); + + store.loadTask('task-1'); + store.deleteTask('task-1'); + + expect(store.selectedTask()).toBeNull(); + }); + + it('should set error when deletion fails', () => { + vi.mocked(taskService.delete).mockReturnValue(throwError(() => new Error('Delete failed'))); + + store.deleteTask('task-1'); + + expect(store.error()).toBe('Delete failed'); + expect(store.loading()).toBe(false); + }); + }); + + describe('transitionTask', () => { + it('should update task state in list', () => { + const tasks = TaskBuilder.buildMany(2, (builder, i) => { + if (i === 0) builder.withState('new'); + }); + const transitioned = new TaskBuilder().withId('task-1').withState('active').build(); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } })); + vi.mocked(taskService.transition).mockReturnValue(of(transitioned)); + + store.loadTasks(); + store.transitionTask('task-1', 'active'); + + expect(store.tasks().find(t => t.id === 'task-1')?.stateHistory[0].state).toBe('active'); + }); + + it('should rollback task when transition fails', () => { + const tasks = TaskBuilder.buildMany(1, (builder) => { + builder.withId('task-1').withState('new'); + }); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } })); + vi.mocked(taskService.transition).mockReturnValue(throwError(() => new Error('Transition failed'))); + + store.loadTasks(); + store.transitionTask('task-1', 'active'); + + expect(store.tasks().find(t => t.id === 'task-1')?.stateHistory.at(-1)?.state).toBe('new'); + expect(store.transitioningState()).toBeNull(); + }); + }); + + describe('addNote', () => { + it('should update task with new note', () => { + const tasks = [new TaskBuilder().withId('task-1').withNotes([]).build()]; + const updated = new TaskBuilder().withId('task-1').withNotes(['New note']).build(); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } })); + vi.mocked(taskService.addNote).mockReturnValue(of(updated)); + + store.loadTasks(); + store.addNote('task-1', 'New note'); + + expect(store.tasks().find(t => t.id === 'task-1')?.notes).toContain('New note'); + }); + + it('should set error when adding note fails', () => { + vi.mocked(taskService.addNote).mockReturnValue(throwError(() => new Error('Note failed'))); + + store.addNote('task-1', 'Note'); + + expect(store.error()).toBe('Note failed'); + }); + }); + + describe('deleteNote', () => { + it('should remove note from task', () => { + const tasks = [new TaskBuilder().withId('task-1').withNotes(['Note 1', 'Note 2']).build()]; + const updated = new TaskBuilder().withId('task-1').withNotes(['Note 2']).build(); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } })); + vi.mocked(taskService.deleteNote).mockReturnValue(of(updated)); + + store.loadTasks(); + store.deleteNote('task-1', 0); + + expect(store.tasks().find(t => t.id === 'task-1')?.notes).toEqual(['Note 2']); + }); + }); + + describe('filteredTasks', () => { + it('should filter tasks by state', () => { + const tasks = [ + new TaskBuilder().withId('task-1').withState('active').build(), + new TaskBuilder().withId('task-2').withState('new').build(), + new TaskBuilder().withId('task-3').withState('active').build(), + ]; + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } })); + + store.loadTasks(); + store.updateFilter({ state: 'active' }); + + expect(store.filteredTasks()).toHaveLength(2); + }); + + it('should filter tasks by search term', () => { + const tasks = [ + new TaskBuilder().withId('task-1').withTitle('Angular task').build(), + new TaskBuilder().withId('task-2').withTitle('React task').build(), + new TaskBuilder().withId('task-3').withTitle('Angular component').build(), + ]; + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } })); + + store.loadTasks(); + store.updateFilter({ search: 'angular' }); + + expect(store.filteredTasks()).toHaveLength(2); + }); + + it('should return all tasks when filter is all', () => { + const tasks = TaskBuilder.buildMany(5); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 5, totalPages: 1 } })); + + store.loadTasks(); + + expect(store.filteredTasks()).toHaveLength(5); + }); + }); + + describe('updateFilter', () => { + it('should merge partial filter with existing filter', () => { + store.updateFilter({ state: 'active' }); + + expect(store.filter().state).toBe('active'); + expect(store.filter().search).toBe(''); + }); + }); + + describe('updateSort', () => { + it('should update sort criteria', () => { + store.updateSort({ field: 'title', direction: 'desc' }); + + expect(store.sort().field).toBe('title'); + expect(store.sort().direction).toBe('desc'); + }); + }); + + describe('clearError', () => { + it('should set error to null', () => { + vi.mocked(taskService.getAll).mockReturnValue(throwError(() => new Error('Error'))); + store.loadTasks(); + expect(store.error()).toBe('Error'); + + store.clearError(); + + expect(store.error()).toBeNull(); + }); + }); + + describe('reset', () => { + it('should reset all signals to default values', () => { + const tasks = TaskBuilder.buildMany(3); + vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } })); + store.loadTasks(); + store.updateFilter({ state: 'active', search: 'test' }); + + store.reset(); + + expect(store.tasks()).toEqual([]); + expect(store.selectedTask()).toBeNull(); + expect(store.filter()).toEqual({ state: 'all', search: '' }); + expect(store.sort()).toEqual({ field: 'dueDate', direction: 'asc' }); + expect(store.loading()).toBe(false); + expect(store.error()).toBeNull(); + expect(store.pagination()).toBeNull(); + }); + }); +}); diff --git a/tests/app/features/tasks/feature/task-create-page/task-create-page.spec.ts b/tests/app/features/tasks/feature/task-create-page/task-create-page.spec.ts new file mode 100644 index 0000000..93adc4f --- /dev/null +++ b/tests/app/features/tasks/feature/task-create-page/task-create-page.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TaskCreatePage } from '@app/features/tasks/feature/task-create-page/task-create-page'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; + +describe('TaskCreatePage', () => { + let component: TaskCreatePage; + let fixture: ComponentFixture; + let taskService: TaskService; + + beforeEach(async () => { + taskService = { + create: vi.fn(), + } as unknown as TaskService; + + await TestBed.configureTestingModule({ + imports: [TaskCreatePage], + providers: [ + provideRouter([]), + { provide: TaskService, useValue: taskService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskCreatePage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a form with title, description, dueDate and initialNote', () => { + expect(component.form.get('title')).toBeTruthy(); + expect(component.form.get('description')).toBeTruthy(); + expect(component.form.get('dueDate')).toBeTruthy(); + expect(component.form.get('initialNote')).toBeTruthy(); + }); + + it('should have title as required', () => { + const title = component.form.get('title'); + + title?.setValue(''); + + expect(title?.valid).toBe(false); + }); + + it('should have initialNote as required', () => { + const initialNote = component.form.get('initialNote'); + + initialNote?.setValue(''); + + expect(initialNote?.valid).toBe(false); + }); + + it('should validate title max length', () => { + const title = component.form.get('title'); + const longTitle = 'a'.repeat(101); + + title?.setValue(longTitle); + + expect(title?.valid).toBe(false); + }); + + it('should accept valid title', () => { + const title = component.form.get('title'); + + title?.setValue('Valid Task Title'); + + expect(title?.valid).toBe(true); + }); + + it('should return true from canDeactivate when form is pristine', () => { + expect(component.canDeactivate()).toBe(true); + }); + + it('should return false from canDeactivate when form is dirty', () => { + component.form.markAsDirty(); + + expect(component.canDeactivate()).toBe(false); + }); +}); diff --git a/tests/app/features/tasks/feature/task-detail-page/task-detail-page.spec.ts b/tests/app/features/tasks/feature/task-detail-page/task-detail-page.spec.ts new file mode 100644 index 0000000..cad66ce --- /dev/null +++ b/tests/app/features/tasks/feature/task-detail-page/task-detail-page.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter, ActivatedRoute } from '@angular/router'; +import { TaskDetailPage } from '@app/features/tasks/feature/task-detail-page/task-detail-page'; +import { TaskStore } from '@app/features/tasks/data-access/store/task-store'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { of } from 'rxjs'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskDetailPage', () => { + let component: TaskDetailPage; + let fixture: ComponentFixture; + let taskService: TaskService; + + beforeEach(async () => { + taskService = { + getById: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + update: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + transition: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + delete: vi.fn().mockReturnValue(of(undefined)), + addNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + deleteNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + } as unknown as TaskService; + + await TestBed.configureTestingModule({ + imports: [TaskDetailPage], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: { + get: vi.fn().mockReturnValue('task-1'), + }, + }, + }, + }, + { provide: TaskService, useValue: taskService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskDetailPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return current state from task history', () => { + const store = TestBed.inject(TaskStore); + store.setSelectedTask(new TaskBuilder().withState('active').build()); + + expect(component.currentState).toBe('active'); + }); + + it('should return null for currentState when task has empty history', () => { + const store = TestBed.inject(TaskStore); + store.setSelectedTask(new TaskBuilder().withStateHistory([]).build()); + + expect(component.currentState).toBeNull(); + }); +}); diff --git a/tests/app/features/tasks/feature/task-list-page/task-list-page.spec.ts b/tests/app/features/tasks/feature/task-list-page/task-list-page.spec.ts new file mode 100644 index 0000000..ebe9b6b --- /dev/null +++ b/tests/app/features/tasks/feature/task-list-page/task-list-page.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TaskListPage } from '@app/features/tasks/feature/task-list-page/task-list-page'; +import { TaskStore } from '@app/features/tasks/data-access/store/task-store'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { of } from 'rxjs'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskListPage', () => { + let component: TaskListPage; + let fixture: ComponentFixture; + let store: TaskStore; + let taskService: TaskService; + + beforeEach(async () => { + taskService = { + getAll: vi.fn().mockReturnValue(of({ data: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } })), + delete: vi.fn().mockReturnValue(of(undefined)), + update: vi.fn(), + transition: vi.fn(), + addNote: vi.fn(), + deleteNote: vi.fn(), + } as unknown as TaskService; + + await TestBed.configureTestingModule({ + imports: [TaskListPage], + providers: [ + provideRouter([]), + { provide: TaskService, useValue: taskService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskListPage); + component = fixture.componentInstance; + store = TestBed.inject(TaskStore); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default view as table', () => { + expect(component.activeView()).toBe('table'); + }); + + it('should have PAGE_SIZE of 5', () => { + expect(component.PAGE_SIZE).toBe(5); + }); + + it('should have default currentPage as 1', () => { + expect(component.currentPage()).toBe(1); + }); + + it('should change view mode', () => { + component.onViewChange('board'); + + expect(component.activeView()).toBe('board'); + }); + + it('should change page', () => { + component.onPageChange(3); + + expect(component.currentPage()).toBe(3); + }); + + it('should return current state from task history', () => { + const task = new TaskBuilder().withState('active').build(); + + expect(component.getState(task)).toBe('active'); + }); + + it('should return new when history is empty', () => { + const task = new TaskBuilder().withStateHistory([]).build(); + + expect(component.getState(task)).toBe('new'); + }); + + it('should return correct state label', () => { + const task = new TaskBuilder().withState('resolved').build(); + + expect(component.getStateLabel(task)).toBe('Resolved'); + }); + + it('should return correct state progress', () => { + const task = new TaskBuilder().withState('active').build(); + + expect(component.getStateProgress(task)).toBe(50); + }); + + it('should return true for finalized tasks', () => { + const resolved = new TaskBuilder().withState('resolved').build(); + const closed = new TaskBuilder().withState('closed').build(); + + expect(component.isFinalized(resolved)).toBe(true); + expect(component.isFinalized(closed)).toBe(true); + }); + + it('should return false for non-finalized tasks', () => { + const active = new TaskBuilder().withState('active').build(); + const newTask = new TaskBuilder().withState('new').build(); + + expect(component.isFinalized(active)).toBe(false); + expect(component.isFinalized(newTask)).toBe(false); + }); + + it('should set selected task on view', () => { + const task = new TaskBuilder().build(); + + component.onViewTask(task); + + expect(component.selectedTask()).toEqual(task); + }); + + it('should clear selected task on close', () => { + store.setSelectedTask(new TaskBuilder().build()); + + component.onCloseDetailSidebar(); + + expect(component.selectedTask()).toBeNull(); + }); + + it('should open create sidebar', () => { + component.onOpenCreateSidebar(); + + expect(component.showCreateSidebar()).toBe(true); + }); + + it('should close create sidebar', () => { + component.showCreateSidebar.set(true); + + component.onCloseCreateSidebar(); + + expect(component.showCreateSidebar()).toBe(false); + }); + + it('should close create sidebar on task created', () => { + component.showCreateSidebar.set(true); + + component.onTaskCreated(); + + expect(component.showCreateSidebar()).toBe(false); + }); + + it('should close create sidebar when viewing task', () => { + component.showCreateSidebar.set(true); + const task = new TaskBuilder().build(); + + component.onViewTask(task); + + expect(component.showCreateSidebar()).toBe(false); + }); +}); diff --git a/tests/app/features/tasks/ui/task-board-column/task-board-column.spec.ts b/tests/app/features/tasks/ui/task-board-column/task-board-column.spec.ts new file mode 100644 index 0000000..01a907a --- /dev/null +++ b/tests/app/features/tasks/ui/task-board-column/task-board-column.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskBoardColumn } from '@app/features/tasks/ui/task-board-column/task-board-column'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskBoardColumn', () => { + let component: TaskBoardColumn; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskBoardColumn], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskBoardColumn); + component = fixture.componentInstance; + fixture.componentRef.setInput('state', 'new'); + fixture.componentRef.setInput('title', 'New Tasks'); + fixture.componentRef.setInput('tasks', []); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render column title', () => { + const title = fixture.nativeElement.querySelector('.board-column__title'); + + expect(title?.textContent).toContain('New Tasks'); + }); + + it('should render task count', () => { + fixture.componentRef.setInput('tasks', TaskBuilder.buildMany(3)); + fixture.detectChanges(); + + const count = fixture.nativeElement.querySelector('.board-column__count'); + + expect(count?.textContent).toContain('3'); + }); + + it('should render empty state when no tasks', () => { + const empty = fixture.nativeElement.querySelector('.board-column__empty'); + + expect(empty).toBeTruthy(); + expect(empty.textContent).toContain('No tasks'); + }); + + it('should render task cards', () => { + const tasks = TaskBuilder.buildMany(2); + fixture.componentRef.setInput('tasks', tasks); + fixture.detectChanges(); + + const cards = fixture.nativeElement.querySelectorAll('.board-column__card'); + + expect(cards.length).toBe(2); + }); + + it('should render task title in card', () => { + const tasks = [new TaskBuilder().withTitle('My Task').build()]; + fixture.componentRef.setInput('tasks', tasks); + fixture.detectChanges(); + + const cardTitle = fixture.nativeElement.querySelector('.board-column__card-title'); + + expect(cardTitle?.textContent).toContain('My Task'); + }); + + it('should return state class', () => { + fixture.componentRef.setInput('state', 'active'); + + expect(component.stateClass).toBe('board-column--active'); + }); + + it('should emit view event', () => { + const task = new TaskBuilder().build(); + const spy = vi.fn(); + component.view.subscribe(spy); + + component.onView(task); + + expect(spy).toHaveBeenCalledWith(task); + }); + + it('should emit edit event', () => { + const task = new TaskBuilder().build(); + const spy = vi.fn(); + component.edit.subscribe(spy); + + component.onEdit(task); + + expect(spy).toHaveBeenCalledWith(task); + }); + + it('should emit delete event', () => { + const task = new TaskBuilder().build(); + const spy = vi.fn(); + component.delete.subscribe(spy); + + component.onDelete(task); + + expect(spy).toHaveBeenCalledWith(task); + }); + + it('should emit add event', () => { + const spy = vi.fn(); + component.add.subscribe(spy); + + component.onAdd(); + + expect(spy).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/app/features/tasks/ui/task-card/task-card.spec.ts b/tests/app/features/tasks/ui/task-card/task-card.spec.ts new file mode 100644 index 0000000..9396bf9 --- /dev/null +++ b/tests/app/features/tasks/ui/task-card/task-card.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskCard } from '@app/features/tasks/ui/task-card/task-card'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskCard', () => { + let component: TaskCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskCard], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskCard); + component = fixture.componentInstance; + fixture.componentRef.setInput('task', new TaskBuilder().build()); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render task title', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withTitle('My Task').build()); + fixture.detectChanges(); + + const title = fixture.nativeElement.querySelector('h3'); + + expect(title?.textContent).toContain('My Task'); + }); + + it('should render task description', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withDescription('My Description').build()); + fixture.detectChanges(); + + const desc = fixture.nativeElement.querySelector('.task-card__description'); + + expect(desc?.textContent).toContain('My Description'); + }); + + it('should render due date', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withDueDate('2026-12-25').build()); + fixture.detectChanges(); + + const date = fixture.nativeElement.querySelector('.task-card__date'); + + expect(date?.textContent).toContain('2026-12-25'); + }); + + it('should return last state from stateHistory', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build()); + + expect(component.currentState).toBe('active'); + }); + + it('should return new when stateHistory is empty', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withStateHistory([]).build()); + + expect(component.currentState).toBe('new'); + }); + + it('should emit view event', () => { + const spy = vi.fn(); + component.view.subscribe(spy); + + component.onView(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should emit edit event', () => { + const spy = vi.fn(); + component.edit.subscribe(spy); + + component.onEdit(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should emit delete event', () => { + const spy = vi.fn(); + component.delete.subscribe(spy); + + component.onDelete(); + + expect(spy).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.spec.ts b/tests/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.spec.ts new file mode 100644 index 0000000..e8ee5a1 --- /dev/null +++ b/tests/app/features/tasks/ui/task-create-sidebar/task-create-sidebar.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskCreateSidebar } from '@app/features/tasks/ui/task-create-sidebar/task-create-sidebar'; +import { TaskService } from '@app/features/tasks/data-access/services/task'; +import { of } from 'rxjs'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskCreateSidebar', () => { + let component: TaskCreateSidebar; + let fixture: ComponentFixture; + let taskService: TaskService; + + beforeEach(async () => { + taskService = { + create: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + addNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())), + } as unknown as TaskService; + + await TestBed.configureTestingModule({ + imports: [TaskCreateSidebar], + providers: [ + { provide: TaskService, useValue: taskService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskCreateSidebar); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render sidebar title', () => { + const title = fixture.nativeElement.querySelector('.sidebar__title'); + + expect(title?.textContent).toContain('New Task'); + }); + + it('should have a form with title, description, dueDate and initialNote fields', () => { + expect(component.form.get('title')).toBeTruthy(); + expect(component.form.get('description')).toBeTruthy(); + expect(component.form.get('dueDate')).toBeTruthy(); + expect(component.form.get('initialNote')).toBeTruthy(); + }); + + it('should have initialNote as required', () => { + const initialNote = component.form.get('initialNote'); + + initialNote?.setValue(''); + + expect(initialNote?.valid).toBe(false); + }); + + it('should accept valid initialNote', () => { + const initialNote = component.form.get('initialNote'); + + initialNote?.setValue('This is a valid note'); + + expect(initialNote?.valid).toBe(true); + }); + + it('should emit close event', () => { + const spy = vi.fn(); + component.close.subscribe(spy); + + component.onClose(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should not emit created when form is invalid', () => { + const spy = vi.fn(); + component.created.subscribe(spy); + + component.onSubmit(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should emit created when form is valid', () => { + const spy = vi.fn(); + component.created.subscribe(spy); + component.form.patchValue({ title: 'New Task', initialNote: 'First note' }); + + component.onSubmit(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should render initialNote field in template', () => { + const field = fixture.nativeElement.querySelector('#create-initialNote'); + + expect(field).toBeTruthy(); + }); +}); diff --git a/tests/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.spec.ts b/tests/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.spec.ts new file mode 100644 index 0000000..2ad6fae --- /dev/null +++ b/tests/app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskDetailSidebar } from '@app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar'; +import { TaskBuilder } from '@tests/builders/task.builder'; + +describe('TaskDetailSidebar', () => { + let component: TaskDetailSidebar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskDetailSidebar], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskDetailSidebar); + component = fixture.componentInstance; + fixture.componentRef.setInput('task', new TaskBuilder().build()); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render task title', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withTitle('My Task').build()); + fixture.detectChanges(); + + const title = fixture.nativeElement.querySelector('.task-sidebar__title'); + + expect(title?.textContent).toContain('My Task'); + }); + + it('should render description', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withDescription('My Description').build()); + fixture.detectChanges(); + + const desc = fixture.nativeElement.querySelector('.task-sidebar__text'); + + expect(desc?.textContent).toContain('My Description'); + }); + + it('should show fallback when no description', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withDescription('').build()); + fixture.detectChanges(); + + const desc = fixture.nativeElement.querySelector('.task-sidebar__text'); + + expect(desc?.textContent).toContain('No description provided'); + }); + + it('should render due date', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withDueDate('2026-12-25').build()); + fixture.detectChanges(); + + const meta = fixture.nativeElement.querySelector('.task-sidebar__meta'); + + expect(meta?.textContent).toContain('2026-12-25'); + }); + + it('should return current state from history', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build()); + + expect(component.currentState()).toBe('active'); + }); + + it('should return new when history is empty', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withStateHistory([]).build()); + + expect(component.currentState()).toBe('new'); + }); + + it('should return 0 progress for new state', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withState('new').build()); + + expect(component.getStateProgress()).toBe(0); + }); + + it('should return 50 progress for active state', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build()); + + expect(component.getStateProgress()).toBe(50); + }); + + it('should return 100 progress for resolved state', () => { + fixture.componentRef.setInput('task', new TaskBuilder().withState('resolved').build()); + + expect(component.getStateProgress()).toBe(100); + }); + + it('should emit close event', () => { + const spy = vi.fn(); + component.close.subscribe(spy); + + component.onClose(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should emit changeState event', () => { + const spy = vi.fn(); + component.changeState.subscribe(spy); + + component.onChangeState('active'); + + expect(spy).toHaveBeenCalledWith('active'); + }); + + it('should emit deleteTask event', () => { + const spy = vi.fn(); + component.deleteTask.subscribe(spy); + + component.onDeleteTask(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('should emit addNote event', () => { + const spy = vi.fn(); + component.addNote.subscribe(spy); + + component.onAddNote('New note'); + + expect(spy).toHaveBeenCalledWith('New note'); + }); + + it('should emit deleteNote event', () => { + const spy = vi.fn(); + component.deleteNote.subscribe(spy); + + component.onDeleteNote(0); + + expect(spy).toHaveBeenCalledWith(0); + }); +}); diff --git a/tests/app/features/tasks/ui/task-form/task-form.spec.ts b/tests/app/features/tasks/ui/task-form/task-form.spec.ts new file mode 100644 index 0000000..47f5b1f --- /dev/null +++ b/tests/app/features/tasks/ui/task-form/task-form.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TaskForm } from '@app/features/tasks/ui/task-form/task-form'; + +describe('TaskForm', () => { + let component: TaskForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskForm, ReactiveFormsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskForm); + component = fixture.componentInstance; + + const form = new FormGroup({ + title: new FormControl('Test Task', [Validators.required]), + description: new FormControl('Test Description'), + dueDate: new FormControl('2026-12-01'), + }); + + fixture.componentRef.setInput('form', form); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default submit label', () => { + expect(component.submitLabel()).toBe('Save'); + }); + + it('should have isEditing false by default', () => { + expect(component.isEditing()).toBe(false); + }); + + it('should have loading false by default', () => { + expect(component.loading()).toBe(false); + }); + + it('should emit submitted with form values when form is valid', () => { + const spy = vi.fn(); + component.submitted.subscribe(spy); + + component.onSubmit(); + + expect(spy).toHaveBeenCalledWith({ + title: 'Test Task', + description: 'Test Description', + dueDate: '2026-12-01', + }); + }); + + it('should not emit submitted when form is invalid', () => { + const spy = vi.fn(); + component.submitted.subscribe(spy); + + const form = new FormGroup({ + title: new FormControl('', [Validators.required]), + }); + fixture.componentRef.setInput('form', form); + fixture.detectChanges(); + + component.onSubmit(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should emit cancelled on cancel', () => { + const spy = vi.fn(); + component.cancelled.subscribe(spy); + + component.onCancel(); + + expect(spy).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/app/features/tasks/ui/task-note-list/task-note-list.spec.ts b/tests/app/features/tasks/ui/task-note-list/task-note-list.spec.ts new file mode 100644 index 0000000..0413ade --- /dev/null +++ b/tests/app/features/tasks/ui/task-note-list/task-note-list.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskNoteList } from '@app/features/tasks/ui/task-note-list/task-note-list'; + +describe('TaskNoteList', () => { + let component: TaskNoteList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskNoteList], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskNoteList); + component = fixture.componentInstance; + fixture.componentRef.setInput('notes', []); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render empty state when no notes', () => { + const empty = fixture.nativeElement.querySelector('.note-list__empty'); + + expect(empty).toBeTruthy(); + expect(empty.textContent).toContain('No notes yet'); + }); + + it('should render notes list', () => { + fixture.componentRef.setInput('notes', ['Note 1', 'Note 2', 'Note 3']); + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('.note-list__item'); + + expect(items.length).toBe(3); + }); + + it('should render note content', () => { + fixture.componentRef.setInput('notes', ['My first note']); + fixture.detectChanges(); + + const content = fixture.nativeElement.querySelector('.note-list__content'); + + expect(content?.textContent).toContain('My first note'); + }); + + it('should have list role for accessibility', () => { + const list = fixture.nativeElement.querySelector('.note-list'); + + expect(list.getAttribute('role')).toBe('list'); + }); + + it('should have aria-label on list', () => { + const list = fixture.nativeElement.querySelector('.note-list'); + + expect(list.getAttribute('aria-label')).toBe('Task notes'); + }); + + it('should emit addNote with content', () => { + const spy = vi.fn(); + component.addNote.subscribe(spy); + + component.onAddNote('New note'); + + expect(spy).toHaveBeenCalledWith('New note'); + }); + + it('should not emit addNote when content is empty', () => { + const spy = vi.fn(); + component.addNote.subscribe(spy); + + component.onAddNote(' '); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should emit deleteNote with index', () => { + const spy = vi.fn(); + component.deleteNote.subscribe(spy); + + component.onDeleteNote(2); + + expect(spy).toHaveBeenCalledWith(2); + }); + + it('should have delete button with aria-label', () => { + fixture.componentRef.setInput('notes', ['Test note']); + fixture.detectChanges(); + + const deleteBtn = fixture.nativeElement.querySelector('.note-list__delete'); + + expect(deleteBtn.getAttribute('aria-label')).toBe('Delete note: Test note'); + }); +}); diff --git a/tests/app/features/tasks/ui/task-state-badge/task-state-badge.spec.ts b/tests/app/features/tasks/ui/task-state-badge/task-state-badge.spec.ts new file mode 100644 index 0000000..c3b66dd --- /dev/null +++ b/tests/app/features/tasks/ui/task-state-badge/task-state-badge.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TaskStateBadge } from '@app/features/tasks/ui/task-state-badge/task-state-badge'; + +describe('TaskStateBadge', () => { + let component: TaskStateBadge; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskStateBadge], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskStateBadge); + component = fixture.componentInstance; + fixture.componentRef.setInput('state', 'new'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render span with role status', () => { + const span = fixture.nativeElement.querySelector('.state-badge'); + + expect(span).toBeTruthy(); + expect(span.getAttribute('role')).toBe('status'); + }); + + it('should show New label for new state', () => { + fixture.componentRef.setInput('state', 'new'); + fixture.detectChanges(); + + expect(component.stateLabel).toBe('New'); + expect(fixture.nativeElement.textContent).toContain('New'); + }); + + it('should show Active label for active state', () => { + fixture.componentRef.setInput('state', 'active'); + fixture.detectChanges(); + + expect(component.stateLabel).toBe('Active'); + }); + + it('should show Resolved label for resolved state', () => { + fixture.componentRef.setInput('state', 'resolved'); + fixture.detectChanges(); + + expect(component.stateLabel).toBe('Resolved'); + }); + + it('should show Closed label for closed state', () => { + fixture.componentRef.setInput('state', 'closed'); + fixture.detectChanges(); + + expect(component.stateLabel).toBe('Closed'); + }); + + it('should apply state-specific class', () => { + fixture.componentRef.setInput('state', 'active'); + fixture.detectChanges(); + + const span = fixture.nativeElement.querySelector('.state-badge'); + + expect(span.classList.contains('state-badge--active')).toBe(true); + }); + + it('should have aria-label with state', () => { + fixture.componentRef.setInput('state', 'resolved'); + fixture.detectChanges(); + + const span = fixture.nativeElement.querySelector('.state-badge'); + + expect(span.getAttribute('aria-label')).toBe('Task state: Resolved'); + }); +}); diff --git a/tests/app/shared/models/result.model.spec.ts b/tests/app/shared/models/result.model.spec.ts new file mode 100644 index 0000000..fbaf19e --- /dev/null +++ b/tests/app/shared/models/result.model.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { ok, err, isOk, isErr } from '@app/shared/models/result.model'; + +describe('Result model', () => { + describe('ok', () => { + it('should create a success result with data', () => { + const data = { name: 'test' }; + + const result = ok(data); + + expect(result.success).toBe(true); + expect((result as { success: true; data: typeof data }).data).toEqual(data); + }); + }); + + describe('err', () => { + it('should create an error result with error', () => { + const error = new Error('fail'); + + const result = err(error); + + expect(result.success).toBe(false); + expect((result as { success: false; error: Error }).error).toEqual(error); + }); + }); + + describe('isOk', () => { + it('should return true for success result', () => { + const result = ok('value'); + + const check = isOk(result); + + expect(check).toBe(true); + }); + + it('should return false for error result', () => { + const result = err(new Error('fail')); + + const check = isOk(result); + + expect(check).toBe(false); + }); + }); + + describe('isErr', () => { + it('should return true for error result', () => { + const result = err(new Error('fail')); + + const check = isErr(result); + + expect(check).toBe(true); + }); + + it('should return false for success result', () => { + const result = ok('value'); + + const check = isErr(result); + + expect(check).toBe(false); + }); + }); +}); diff --git a/tests/app/shared/pipes/safe-date-pipe.spec.ts b/tests/app/shared/pipes/safe-date-pipe.spec.ts new file mode 100644 index 0000000..0d94194 --- /dev/null +++ b/tests/app/shared/pipes/safe-date-pipe.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SafeDatePipe } from '@app/shared/pipes/safe-date-pipe'; + +describe('SafeDatePipe', () => { + let pipe: SafeDatePipe; + + afterEach(() => { + vi.useRealTimers(); + }); + + beforeEach(() => { + pipe = new SafeDatePipe(); + }); + + it('should transform a date string to relative format', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-13T12:00:00')); + + const result = pipe.transform('2026-05-13T10:00:00'); + + expect(result).toBe('Today'); + }); + + it('should transform a Date object to relative format', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-13T12:00:00')); + + const result = pipe.transform(new Date('2026-05-12T10:00:00')); + + expect(result).toBe('Yesterday'); + }); + + it('should return empty string for null value', () => { + const result = pipe.transform(null); + + expect(result).toBe(''); + }); +}); diff --git a/tests/app/shared/ui/button/button.spec.ts b/tests/app/shared/ui/button/button.spec.ts new file mode 100644 index 0000000..8190665 --- /dev/null +++ b/tests/app/shared/ui/button/button.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Button } from '@app/shared/ui/button/button'; + +describe('Button', () => { + let component: Button; + let fixture: ComponentFixture