From 264d9f14028f3369311fba5a1456b57cbcaaa931 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 11:09:12 +0200 Subject: [PATCH] update test --- src/app/auth/auth.guard.spec.ts | 47 +++++ src/app/auth/auth.interceptor.spec.ts | 55 +++++ src/app/auth/keycloak.service.spec.ts | 137 ++++++++++++ .../issue-comments/issue-comments.spec.ts | 87 ++++++-- .../issues/issue-detail/issue-detail.spec.ts | 96 ++++++--- src/app/issues/issue-detail/issue-detail.ts | 3 +- src/app/issues/issues.spec.ts | 88 ++++++-- src/app/issues/issues.store.spec.ts | 195 ++++++++++-------- src/app/issues/issues.store.ts | 5 + 9 files changed, 575 insertions(+), 138 deletions(-) create mode 100644 src/app/auth/auth.guard.spec.ts create mode 100644 src/app/auth/auth.interceptor.spec.ts create mode 100644 src/app/auth/keycloak.service.spec.ts diff --git a/src/app/auth/auth.guard.spec.ts b/src/app/auth/auth.guard.spec.ts new file mode 100644 index 0000000..5e80996 --- /dev/null +++ b/src/app/auth/auth.guard.spec.ts @@ -0,0 +1,47 @@ +import { vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { authGuard } from './auth.guard'; +import { KeycloakService } from './keycloak.service'; + +describe('authGuard', () => { + let mockKeycloak: { isLoggedIn: ReturnType; login: ReturnType }; + + beforeEach(() => { + mockKeycloak = { + isLoggedIn: vi.fn().mockReturnValue(true), + login: vi.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [{ provide: KeycloakService, useValue: mockKeycloak }], + }); + }); + + const runGuard = () => + TestBed.runInInjectionContext(() => + authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + + it('returns true when the user is logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(true); + expect(runGuard()).toBe(true); + }); + + it('returns false when the user is not logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(false); + expect(runGuard()).toBe(false); + }); + + it('calls login() when the user is not logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(false); + runGuard(); + expect(mockKeycloak.login).toHaveBeenCalled(); + }); + + it('does not call login() when the user is already logged in', () => { + mockKeycloak.isLoggedIn.mockReturnValue(true); + runGuard(); + expect(mockKeycloak.login).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/auth/auth.interceptor.spec.ts b/src/app/auth/auth.interceptor.spec.ts new file mode 100644 index 0000000..a291c6e --- /dev/null +++ b/src/app/auth/auth.interceptor.spec.ts @@ -0,0 +1,55 @@ +import { vi } from 'vitest'; +import { HttpRequest } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of } from 'rxjs'; +import { authInterceptor } from './auth.interceptor'; +import { KeycloakService } from './keycloak.service'; +import { API_BASE_URL } from '../issues/issues-api.service'; + +describe('authInterceptor', () => { + let mockKeycloak: { getToken: ReturnType }; + + beforeEach(() => { + mockKeycloak = { getToken: vi.fn().mockResolvedValue('test-token') }; + + TestBed.configureTestingModule({ + providers: [{ provide: KeycloakService, useValue: mockKeycloak }], + }); + }); + + const intercept = (req: HttpRequest) => { + const captured: HttpRequest[] = []; + const next = vi.fn((r: HttpRequest) => { captured.push(r); return of(null as any); }); + const obs = TestBed.runInInjectionContext(() => authInterceptor(req, next as any)); + return { obs, next, captured }; + }; + + it('skips token logic for requests outside API_BASE_URL', () => { + const req = new HttpRequest('GET', 'http://other.example.com/data'); + const { next } = intercept(req); + expect(mockKeycloak.getToken).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(req); + }); + + it('calls getToken for requests to API_BASE_URL', () => { + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + intercept(req); + expect(mockKeycloak.getToken).toHaveBeenCalled(); + }); + + it('adds Authorization header when token is available', async () => { + mockKeycloak.getToken.mockResolvedValue('my-token'); + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + const { obs, captured } = intercept(req); + await firstValueFrom(obs); + expect(captured[0].headers.get('Authorization')).toBe('Bearer my-token'); + }); + + it('forwards the request without Authorization header when token is undefined', async () => { + mockKeycloak.getToken.mockResolvedValue(undefined); + const req = new HttpRequest('GET', `${API_BASE_URL}/issues`); + const { obs, captured } = intercept(req); + await firstValueFrom(obs); + expect(captured[0].headers.has('Authorization')).toBe(false); + }); +}); diff --git a/src/app/auth/keycloak.service.spec.ts b/src/app/auth/keycloak.service.spec.ts new file mode 100644 index 0000000..82b514e --- /dev/null +++ b/src/app/auth/keycloak.service.spec.ts @@ -0,0 +1,137 @@ +import { vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { KeycloakService } from './keycloak.service'; + +const mockKc = vi.hoisted(() => ({ + init: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + updateToken: vi.fn(), + token: 'mock-token' as string | undefined, + tokenParsed: { preferred_username: 'testuser' } as Record | undefined, + authenticated: true as boolean | undefined, + onTokenExpired: undefined as (() => void) | undefined, +})); + +vi.mock('keycloak-js', () => ({ default: vi.fn(function () { return mockKc; }) })); + +describe('KeycloakService', () => { + let service: KeycloakService; + + beforeEach(() => { + mockKc.init.mockResolvedValue(true); + mockKc.login.mockResolvedValue(undefined); + mockKc.logout.mockResolvedValue(undefined); + mockKc.updateToken.mockResolvedValue(true); + mockKc.token = 'mock-token'; + mockKc.tokenParsed = { preferred_username: 'testuser' }; + mockKc.authenticated = true; + mockKc.onTokenExpired = undefined; + + vi.clearAllMocks(); + mockKc.init.mockResolvedValue(true); + mockKc.login.mockResolvedValue(undefined); + mockKc.logout.mockResolvedValue(undefined); + mockKc.updateToken.mockResolvedValue(true); + + TestBed.configureTestingModule({}); + service = TestBed.inject(KeycloakService); + }); + + describe('init', () => { + it('sets isAuthenticated to true when authenticated', async () => { + await service.init(); + expect(service.isAuthenticated()).toBe(true); + }); + + it('sets username from tokenParsed when authenticated', async () => { + await service.init(); + expect(service.username()).toBe('testuser'); + }); + + it('registers an onTokenExpired handler when authenticated', async () => { + await service.init(); + expect(mockKc.onTokenExpired).toBeTypeOf('function'); + }); + + it('onTokenExpired calls logout when updateToken fails', async () => { + mockKc.updateToken.mockRejectedValue(new Error('expired')); + await service.init(); + await mockKc.onTokenExpired!(); + expect(mockKc.logout).toHaveBeenCalled(); + }); + + it('sets isAuthenticated to false when not authenticated', async () => { + mockKc.init.mockResolvedValue(false); + await service.init(); + expect(service.isAuthenticated()).toBe(false); + }); + + it('leaves username undefined when not authenticated', async () => { + mockKc.init.mockResolvedValue(false); + await service.init(); + expect(service.username()).toBeUndefined(); + }); + + it('handles init failure gracefully without throwing', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockKc.init.mockRejectedValue(new Error('connection refused')); + await expect(service.init()).resolves.toBeUndefined(); + spy.mockRestore(); + }); + + it('logs an error when init throws', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockKc.init.mockRejectedValue(new Error('fail')); + await service.init(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); + + describe('login', () => { + it('delegates to keycloak.login()', async () => { + await service.login(); + expect(mockKc.login).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('delegates to keycloak.logout() with window.location.origin as redirectUri', async () => { + await service.logout(); + expect(mockKc.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin }); + }); + }); + + describe('isLoggedIn', () => { + it('returns true when keycloak.authenticated is true', () => { + mockKc.authenticated = true; + expect(service.isLoggedIn()).toBe(true); + }); + + it('returns false when keycloak.authenticated is false', () => { + mockKc.authenticated = false; + expect(service.isLoggedIn()).toBe(false); + }); + + it('returns false when keycloak.authenticated is undefined', () => { + mockKc.authenticated = undefined; + expect(service.isLoggedIn()).toBe(false); + }); + }); + + describe('getToken', () => { + it('calls updateToken(30) and returns the token', async () => { + mockKc.token = 'fresh-token'; + const token = await service.getToken(); + expect(mockKc.updateToken).toHaveBeenCalledWith(30); + expect(token).toBe('fresh-token'); + }); + + it('returns undefined when updateToken fails', async () => { + mockKc.updateToken.mockRejectedValue(new Error('session expired')); + const token = await service.getToken(); + expect(token).toBeUndefined(); + }); + }); +}); diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 9ae7810..4bb3bea 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -1,29 +1,92 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { IssueComments } from './issue-comments'; -import { IssuesStore } from '../issues.store'; +import { IssueEntity, IssuesStore } from '../issues.store'; + +const makeIssue = (overrides: Partial = {}): IssueEntity => ({ + id: 1, + type: 'Story', + assignee: '', + epic: '', + name: 'Test Issue', + dueDate: '', + description: '', + estimatedTime: null, + dependsOnIds: [], + comments: [], + priority: 'Moyenne', + status: 'draft', + progress: 0, + ...overrides, +}); + +class FakeIssuesStore { + private _data = signal([makeIssue({ id: 1 })]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue; + const normalized: IssueEntity = { + ...makeIssue(), + ...rest, + dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [], + comments: Array.isArray(c) ? c : [], + estimatedTime: et ?? null, + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} describe('IssueComments', () => { let component: IssueComments; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueComments], + providers: [{ provide: IssuesStore, useValue: store }], }).compileComponents(); - store = TestBed.inject(IssuesStore); fixture = TestBed.createComponent(IssueComments); component = fixture.componentInstance; fixture.componentRef.setInput('issueId', 1); await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -68,9 +131,9 @@ describe('IssueComments', () => { expect(store.getById(1)?.comments[0].text).toBe('Test comment'); }); - it('clears newCommentText after adding', () => { + it('clears newCommentText after adding', async () => { (component as any).newCommentText = 'Some text'; - (component as any).addComment(); + await (component as any).addComment(); expect((component as any).newCommentText).toBe(''); }); @@ -147,9 +210,9 @@ describe('IssueComments', () => { expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull(); }); - it('resets editing state after saving', () => { + it('resets editing state after saving', async () => { (component as any).editingCommentText = 'Done'; - (component as any).saveEditComment(); + await (component as any).saveEditComment(); expect((component as any).editingCommentId).toBeNull(); expect((component as any).editingCommentText).toBe(''); }); diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 01d22a1..187c461 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { provideRouter } from '@angular/router'; @@ -23,6 +24,63 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ ...overrides, }); +class FakeIssuesStore { + private _data = signal([ + makeIssue({ id: 1, name: 'Issue 1' }), + makeIssue({ id: 2, name: 'Issue 2' }), + makeIssue({ id: 3, name: 'Issue 3' }), + ]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue; + const normalized: IssueEntity = { + ...makeIssue(), + ...rest, + dependsOnIds: Array.isArray(deps) + ? deps.filter((v: unknown) => typeof v === 'number') + : typeof legacy === 'number' + ? [legacy] + : [], + comments: Array.isArray(c) ? c : [], + estimatedTime: et ?? null, + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} + function makeRoute(id = '1', path = 'issues/:id') { return { snapshot: { @@ -37,30 +95,26 @@ function makeRoute(id = '1', path = 'issues/:id') { describe('IssueDetail — existing issue', () => { let component: IssueDetail; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: makeRoute('1') }, + { provide: IssuesStore, useValue: store }, ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -115,7 +169,7 @@ describe('IssueDetail — existing issue', () => { describe('deleteIssue', () => { it('removes the issue and navigates to /issues', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); - (component as any).deleteIssue(); + await (component as any).deleteIssue(); expect(store.getById(1)).toBeUndefined(); expect(spy).toHaveBeenCalledWith(['/issues']); }); @@ -169,9 +223,9 @@ describe('IssueDetail — existing issue', () => { expect((component as any).selectedCandidateId).toBeNull(); }); - it('confirmAddDependency adds the selected id and saves', () => { + it('confirmAddDependency adds the selected id and saves', async () => { (component as any).selectedCandidateId = 2; - (component as any).confirmAddDependency(); + await (component as any).confirmAddDependency(); expect((component as any).issue.dependsOnIds).toContain(2); expect(store.getById(1)?.dependsOnIds).toContain(2); expect((component as any).showAddDependency).toBe(false); @@ -370,9 +424,9 @@ describe('IssueDetail — existing issue', () => { expect(created?.type).toBe('Story'); }); - it('confirmCreateInEpic resets the form', () => { + it('confirmCreateInEpic resets the form', async () => { (component as any).newIssueName = 'Child Issue'; - (component as any).confirmCreateInEpic(); + await (component as any).confirmCreateInEpic(); expect((component as any).showCreateInEpic).toBe(false); expect((component as any).newIssueName).toBe(''); }); @@ -405,9 +459,9 @@ describe('IssueDetail — existing issue', () => { expect((component as any).selectedEpicCandidateId).toBeNull(); }); - it('confirmAddToEpic assigns the epic name to the selected issue', () => { + it('confirmAddToEpic assigns the epic name to the selected issue', async () => { (component as any).selectedEpicCandidateId = 2; - (component as any).confirmAddToEpic(); + await (component as any).confirmAddToEpic(); expect(store.getById(2)?.epic).toBe('My Epic'); expect((component as any).showAddToEpic).toBe(false); }); @@ -436,11 +490,11 @@ describe('IssueDetail — existing issue', () => { describe('IssueDetail — new issue route', () => { let component: IssueDetail; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ @@ -456,20 +510,16 @@ describe('IssueDetail — new issue route', () => { paramMap: of(convertToParamMap({})), }, }, + { provide: IssuesStore, useValue: store }, ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -486,14 +536,14 @@ describe('IssueDetail — new issue route', () => { it('saveIssue without explicit flag does nothing for new route', () => { (component as any).issue.name = 'Draft Name'; const countBefore = store.issues().length; - (component as any).saveIssue(); // explicit = false + (component as any).saveIssue(); expect(store.issues().length).toBe(countBefore); }); it('saveIssue with explicit=true creates the issue and navigates', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).issue.name = 'Brand New Issue'; - (component as any).saveIssue(true); + await (component as any).saveIssue(true); expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true); expect(spy).toHaveBeenCalledWith(['/issues', 10]); }); diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index c37b335..4dc99ae 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -278,8 +278,9 @@ export class IssueDetail { const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; if (isNewIssueRoute) { + const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0); return { - id: 0, + id: draftId, type: 'Story', assignee: '', epic: '', diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 09c39a0..baa25dc 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { provideRouter } from '@angular/router'; @@ -22,30 +23,93 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ ...overrides, }); +class FakeIssuesStore { + private _data = signal([ + makeIssue({ id: 1, name: 'Issue 1', progress: 0 }), + makeIssue({ id: 2, name: 'Issue 2', progress: 0 }), + makeIssue({ id: 3, name: 'Issue 3', progress: 0 }), + ]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + getNextId(): number { + const ids = this._data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: any): Promise { + const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue; + const normalized: IssueEntity = { + type: 'Story', + assignee: '', + epic: '', + name: '', + dueDate: '', + description: '', + estimatedTime: et ?? null, + comments: Array.isArray(c) ? c : [], + priority: 'Moyenne', + status: 'draft', + progress: 0, + ...rest, + dependsOnIds: Array.isArray(deps) + ? deps.filter((v: unknown) => typeof v === 'number') + : typeof legacy === 'number' + ? [legacy] + : [], + }; + this._data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...issues, normalized]; + const copy = [...issues]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } + + deleteById(id: number): Promise { + this._data.update((issues) => + issues + .filter((i) => i.id !== id) + .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), + ); + return Promise.resolve(); + } +} + describe('Issues', () => { let component: Issues; let fixture: ComponentFixture; - let store: IssuesStore; + let store: FakeIssuesStore; let router: Router; beforeEach(async () => { - localStorage.clear(); + store = new FakeIssuesStore(); await TestBed.configureTestingModule({ imports: [Issues], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { provide: IssuesStore, useValue: store }, + ], }).compileComponents(); - store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(Issues); component = fixture.componentInstance; await fixture.whenStable(); }); - afterEach(() => { - localStorage.clear(); - }); - it('should create', () => { expect(component).toBeTruthy(); }); @@ -65,7 +129,6 @@ describe('Issues', () => { it('returns empty array when no issues match the selected type', () => { (component as any).selectedType = 'Epic'; const filtered: IssueEntity[] = (component as any).filteredIssues; - // Default store has no Epics, so this should be empty expect(filtered.every((i) => i.type === 'Epic')).toBe(true); }); }); @@ -97,13 +160,10 @@ describe('Issues', () => { }); describe('createIssue', () => { - it('navigates to /issues/new with a draftId query param', async () => { + it('navigates to /issues/new', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).createIssue(); - expect(spy).toHaveBeenCalledWith( - ['/issues/new'], - expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }), - ); + expect(spy).toHaveBeenCalledWith(['/issues/new']); }); }); diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index 6cdf313..fe02fd6 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { IssueEntity, IssuesStore } from './issues.store'; +import { API_BASE_URL } from './issues-api.service'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -20,29 +23,59 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ describe('IssuesStore', () => { let store: IssuesStore; + let httpMock: HttpTestingController; + + const loadWith = async (issues: IssueEntity[]) => { + const p = store.load(); + httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues); + await p; + }; beforeEach(() => { - localStorage.clear(); - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); store = TestBed.inject(IssuesStore); + httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { - localStorage.clear(); + httpMock.verify(); }); it('should be created', () => { expect(store).toBeTruthy(); }); - it('should load default issues when localStorage is empty', () => { - expect(store.issues().length).toBeGreaterThan(0); + describe('load', () => { + it('populates issues from the API', async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); + expect(store.issues().length).toBe(2); + }); + + it('sets loading to true during load and false after', async () => { + const p = store.load(); + expect(store.loading()).toBe(true); + httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]); + await p; + expect(store.loading()).toBe(false); + expect(store.loaded()).toBe(true); + }); + + it('does not reload if already loaded', async () => { + await loadWith([]); + await store.load(); + httpMock.expectNone(`${API_BASE_URL}/issues`); + }); }); describe('getById', () => { + beforeEach(async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); + }); + it('returns the issue with the given id', () => { - const issue = store.getById(1); - expect(issue?.id).toBe(1); + expect(store.getById(1)?.id).toBe(1); }); it('returns undefined for an unknown id', () => { @@ -51,122 +84,108 @@ describe('IssuesStore', () => { }); describe('getNextId', () => { - it('returns max id + 1', () => { - const ids = store.issues().map((i) => i.id); - const expectedNext = Math.max(...ids) + 1; - expect(store.getNextId()).toBe(expectedNext); + it('returns max id + 1', async () => { + await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 5 }), makeIssue({ id: 3 })]); + expect(store.getNextId()).toBe(6); }); - it('returns 1 when there are no issues', () => { - store.deleteById(1); - store.deleteById(2); - store.deleteById(3); + it('returns 1 when there are no issues', async () => { + await loadWith([]); expect(store.getNextId()).toBe(1); }); }); describe('upsert', () => { - it('adds a new issue when the id does not exist', () => { - const before = store.issues().length; - store.upsert(makeIssue({ id: 999 })); - expect(store.issues().length).toBe(before + 1); - expect(store.getById(999)?.name).toBe('Test Issue'); + beforeEach(async () => { + await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]); }); - it('updates an existing issue', () => { - store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); + it('creates a new issue via POST when id is 0', async () => { + const before = store.issues().length; + const p = store.upsert(makeIssue({ id: 0, name: 'New Issue' })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' })); + await p; + expect(store.issues().length).toBe(before + 1); + expect(store.getById(999)?.name).toBe('New Issue'); + }); + + it('updates an existing issue via PUT', async () => { + const p = store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, name: 'Updated Name' })); + await p; expect(store.getById(1)?.name).toBe('Updated Name'); expect(store.issues().filter((i) => i.id === 1).length).toBe(1); }); - it('persists the issue list to localStorage', () => { - store.upsert(makeIssue({ id: 999 })); - const raw = localStorage.getItem('bonsai.issues'); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw!); - expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(true); - }); - - it('normalizes legacy dependsOnId (single number) to dependsOnIds array when dependsOnIds is absent', () => { - // dependsOnIds must be omitted (not an array) for the legacy field to take effect - store.upsert({ id: 998, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any); + it('normalizes legacy dependsOnId to dependsOnIds array', async () => { + const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] })); + await p; expect(store.getById(998)?.dependsOnIds).toEqual([1]); }); - it('filters non-number values from dependsOnIds', () => { - store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any); + it('filters non-number values from dependsOnIds', async () => { + const issue = { ...makeIssue({ id: 0 }), dependsOnIds: [1, 'two', null] } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] })); + await p; expect(store.getById(997)?.dependsOnIds).toEqual([1]); }); - it('ensures comments is always an array when missing', () => { - store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any); + it('ensures comments is always an array when missing', async () => { + const issue = { ...makeIssue({ id: 0 }), comments: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 996, comments: [] })); + await p; expect(store.getById(996)?.comments).toEqual([]); }); - it('sets default type to Story when type is missing', () => { - store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any); + it('sets default type to Story when type is missing', async () => { + const issue = { ...makeIssue({ id: 0 }), type: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 995, type: 'Story' })); + await p; expect(store.getById(995)?.type).toBe('Story'); }); - it('sets estimatedTime to null when missing', () => { - store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any); + it('sets estimatedTime to null when missing in API response', async () => { + const issue = { ...makeIssue({ id: 0 }), estimatedTime: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined }); + await p; expect(store.getById(994)?.estimatedTime).toBeNull(); }); }); describe('deleteById', () => { - it('removes the issue from the store', () => { - store.upsert(makeIssue({ id: 999 })); - store.deleteById(999); - expect(store.getById(999)).toBeUndefined(); + beforeEach(async () => { + await loadWith([ + makeIssue({ id: 1 }), + makeIssue({ id: 100 }), + makeIssue({ id: 101, dependsOnIds: [100] }), + ]); }); - it('removes the deleted id from dependsOnIds of other issues', () => { - store.upsert(makeIssue({ id: 100 })); - store.upsert(makeIssue({ id: 101, dependsOnIds: [100] })); - store.deleteById(100); + it('removes the issue from the store', async () => { + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + await p; + expect(store.getById(1)).toBeUndefined(); + }); + + it('removes the deleted id from dependsOnIds of other issues', async () => { + const p = store.deleteById(100); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/100` }).flush(null); + await p; expect(store.getById(101)?.dependsOnIds).toEqual([]); }); - it('persists the updated list to localStorage', () => { - store.upsert(makeIssue({ id: 999 })); - store.deleteById(999); - const raw = localStorage.getItem('bonsai.issues'); - const parsed = JSON.parse(raw!); - expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(false); - }); - }); - - describe('localStorage persistence', () => { - it('loads issues from localStorage on construction', () => { - const saved: IssueEntity[] = [makeIssue({ id: 42, name: 'From storage' })]; - localStorage.setItem('bonsai.issues', JSON.stringify(saved)); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.getById(42)?.name).toBe('From storage'); - }); - - it('falls back to defaults when localStorage contains invalid JSON', () => { - localStorage.setItem('bonsai.issues', 'not-valid-json'); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.issues().length).toBeGreaterThan(0); - }); - - it('falls back to defaults when localStorage contains a non-array', () => { - localStorage.setItem('bonsai.issues', '{"key":"value"}'); - - TestBed.resetTestingModule(); - TestBed.configureTestingModule({}); - const freshStore = TestBed.inject(IssuesStore); - - expect(freshStore.issues().length).toBeGreaterThan(0); + it('does not affect issues with unrelated dependsOnIds', async () => { + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + await p; + expect(store.getById(101)?.dependsOnIds).toEqual([100]); }); }); }); diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 0899917..c6bfb19 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -42,6 +42,11 @@ export class IssuesStore { return this.data().find((i) => i.id === id); } + getNextId(): number { + const ids = this.data().map((i) => i.id); + return ids.length === 0 ? 1 : Math.max(...ids) + 1; + } + async load(): Promise { if (this.loaded()) return; this.loading.set(true);