| #{{ issue.id }} |
diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts
new file mode 100644
index 0000000..07d53b5
--- /dev/null
+++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts
@@ -0,0 +1,600 @@
+import { signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { provideRouter } from '@angular/router';
+import { of } from 'rxjs';
+import { afterEach, vi } from 'vitest';
+import { MilestoneDetail } from './milestone-detail';
+import { IssueEntity, IssuesStore } from '../../issues/issues.store';
+import { MilestoneEntity, MilestonesStore } from '../milestones.store';
+
+const makeIssue = (overrides: Partial = {}): IssueEntity => ({
+ id: 99,
+ type: 'Story',
+ assignee: '',
+ epic: '',
+ name: 'Test Issue',
+ dueDate: '',
+ description: '',
+ estimatedTime: null,
+ dependsOnIds: [],
+ comments: [],
+ priority: 'MOYENNE',
+ status: 'draft',
+ progress: 0,
+ ...overrides,
+});
+
+const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({
+ id: 1,
+ name: 'Sprint 1',
+ description: '',
+ dueDate: '',
+ issueIds: [],
+ ...overrides,
+});
+
+class FakeIssuesStore {
+ private _data = signal([]);
+
+ readonly issues = this._data.asReadonly();
+ readonly loading = signal(false);
+ readonly loaded = signal(true);
+
+ seed(issues: IssueEntity[]): void {
+ this._data.set(issues);
+ }
+
+ getById(id: number): IssueEntity | undefined {
+ return this._data().find((i) => i.id === id);
+ }
+
+ load(): Promise {
+ return Promise.resolve();
+ }
+
+ upsert(issue: IssueEntity): Promise {
+ const normalized = { ...makeIssue(), ...issue };
+ this._data.update((list) => {
+ const idx = list.findIndex((i) => i.id === normalized.id);
+ if (idx === -1) return [...list, { ...normalized, id: normalized.id || list.length + 1 }];
+ const copy = [...list];
+ copy[idx] = normalized;
+ return copy;
+ });
+ return Promise.resolve(normalized);
+ }
+}
+
+class FakeMilestonesStore {
+ private _data = signal([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
+
+ readonly milestones = this._data.asReadonly();
+ readonly loading = signal(false);
+ readonly loaded = signal(true);
+
+ seed(milestones: MilestoneEntity[]): void {
+ this._data.set(milestones);
+ }
+
+ getById(id: number): MilestoneEntity | undefined {
+ return this._data().find((m) => m.id === id);
+ }
+
+ load(): Promise {
+ return Promise.resolve(this._data());
+ }
+
+ upsert(milestone: MilestoneEntity): Promise {
+ this._data.update((list) => {
+ const idx = list.findIndex((m) => m.id === milestone.id);
+ if (idx === -1) return [...list, milestone];
+ const copy = [...list];
+ copy[idx] = milestone;
+ return copy;
+ });
+ return Promise.resolve(milestone);
+ }
+
+ deleteById(id: number): Promise {
+ this._data.update((list) => list.filter((m) => m.id !== id));
+ return Promise.resolve();
+ }
+}
+
+function makeRoute(id = '1', path = 'milestones/:id') {
+ return {
+ snapshot: {
+ routeConfig: { path },
+ paramMap: convertToParamMap(id ? { id } : {}),
+ },
+ paramMap: of(convertToParamMap(id ? { id } : {})),
+ };
+}
+
+describe('MilestoneDetail', () => {
+ let component: MilestoneDetail;
+ let fixture: ComponentFixture;
+ let issuesStore: FakeIssuesStore;
+ let milestonesStore: FakeMilestonesStore;
+ let router: Router;
+
+ beforeEach(async () => {
+ issuesStore = new FakeIssuesStore();
+ milestonesStore = new FakeMilestonesStore();
+ await TestBed.configureTestingModule({
+ imports: [MilestoneDetail],
+ providers: [
+ provideRouter([]),
+ { provide: ActivatedRoute, useValue: makeRoute('1') },
+ { provide: IssuesStore, useValue: issuesStore },
+ { provide: MilestonesStore, useValue: milestonesStore },
+ ],
+ }).compileComponents();
+
+ router = TestBed.inject(Router);
+ fixture = TestBed.createComponent(MilestoneDetail);
+ component = fixture.componentInstance;
+ await fixture.whenStable();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('linkedIssues', () => {
+ it('returns issues whose id is in milestone.issueIds', () => {
+ issuesStore.seed([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
+ (component as any).milestone.issueIds = [1];
+ const linked: IssueEntity[] = (component as any).linkedIssues;
+ expect(linked.map((i) => i.id)).toEqual([1]);
+ });
+
+ it('returns empty array when milestone has no issues', () => {
+ issuesStore.seed([makeIssue({ id: 1 })]);
+ (component as any).milestone.issueIds = [];
+ expect((component as any).linkedIssues).toHaveLength(0);
+ });
+ });
+
+ describe('availableIssues', () => {
+ beforeEach(() => {
+ issuesStore.seed([
+ makeIssue({ id: 1, name: 'Issue 1', epic: '' }),
+ makeIssue({ id: 2, name: 'Issue 2', epic: '' }),
+ makeIssue({ id: 3, name: 'Issue 3', epic: '' }),
+ ]);
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] });
+ });
+
+ it('returns all issues when milestone is empty', () => {
+ expect((component as any).availableIssues).toHaveLength(3);
+ });
+
+ it('excludes issues already in this milestone', () => {
+ (component as any).milestone.issueIds = [1];
+ const available: IssueEntity[] = (component as any).availableIssues;
+ expect(available.some((i) => i.id === 1)).toBe(false);
+ expect(available).toHaveLength(2);
+ });
+
+ it('excludes issues assigned to another milestone', () => {
+ milestonesStore.seed([
+ makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }),
+ makeMilestone({ id: 2, name: 'Sprint 2', issueIds: [2] }),
+ ]);
+ const available: IssueEntity[] = (component as any).availableIssues;
+ expect(available.some((i) => i.id === 2)).toBe(false);
+ expect(available).toHaveLength(2);
+ });
+
+ it('excludes children of an epic already in this milestone', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }),
+ makeIssue({ id: 2, name: 'Child 1', epic: 'My Epic' }),
+ makeIssue({ id: 3, name: 'Independent', epic: '' }),
+ ]);
+ (component as any).milestone.issueIds = [1];
+ const available: IssueEntity[] = (component as any).availableIssues;
+ expect(available.some((i) => i.id === 2)).toBe(false);
+ expect(available.some((i) => i.id === 3)).toBe(true);
+ });
+
+ it('does not exclude issues whose epic is not in the milestone', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }),
+ makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }),
+ makeIssue({ id: 3, name: 'Independent', epic: '' }),
+ ]);
+ (component as any).milestone.issueIds = [];
+ const available: IssueEntity[] = (component as any).availableIssues;
+ expect(available.some((i) => i.id === 2)).toBe(true);
+ });
+ });
+
+ describe('displayedIssues', () => {
+ it('shows all linked issues when no epic is in the milestone', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, name: 'Story A', epic: '' }),
+ makeIssue({ id: 2, name: 'Story B', epic: '' }),
+ ]);
+ (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2] });
+ expect((component as any).displayedIssues).toHaveLength(2);
+ });
+
+ it('hides children of an epic already in the milestone', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }),
+ makeIssue({ id: 2, name: 'Child', epic: 'My Epic' }),
+ makeIssue({ id: 3, name: 'Independent', epic: '' }),
+ ]);
+ (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2, 3] });
+ const displayed: IssueEntity[] = (component as any).displayedIssues;
+ expect(displayed.some((i) => i.id === 1)).toBe(true);
+ expect(displayed.some((i) => i.id === 2)).toBe(false);
+ expect(displayed.some((i) => i.id === 3)).toBe(true);
+ });
+
+ it('shows children when their epic is not in the milestone', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }),
+ makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }),
+ ]);
+ (component as any).milestone = makeMilestone({ id: 1, issueIds: [2] });
+ const displayed: IssueEntity[] = (component as any).displayedIssues;
+ expect(displayed.some((i) => i.id === 2)).toBe(true);
+ });
+ });
+
+ describe('progress', () => {
+ it('returns 0 when milestone has no issues', () => {
+ issuesStore.seed([]);
+ (component as any).milestone.issueIds = [];
+ expect((component as any).progress).toBe(0);
+ });
+
+ it('returns 100 when all linked issues are done', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, status: 'done' }),
+ makeIssue({ id: 2, status: 'done' }),
+ ]);
+ (component as any).milestone.issueIds = [1, 2];
+ expect((component as any).progress).toBe(100);
+ });
+
+ it('returns 50 when half the linked issues are done', () => {
+ issuesStore.seed([
+ makeIssue({ id: 1, status: 'done' }),
+ makeIssue({ id: 2, status: 'todo' }),
+ ]);
+ (component as any).milestone.issueIds = [1, 2];
+ expect((component as any).progress).toBe(50);
+ });
+ });
+
+ describe('issueSuggestions', () => {
+ beforeEach(() => {
+ issuesStore.seed([
+ makeIssue({ id: 1, name: 'Fix login bug' }),
+ makeIssue({ id: 2, name: 'Add dashboard' }),
+ makeIssue({ id: 3, name: 'Fix signup bug' }),
+ ]);
+ milestonesStore.seed([makeMilestone({ id: 1, issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, issueIds: [] });
+ });
+
+ it('returns all available issues when query is empty', () => {
+ (component as any).issueSearchQuery = '';
+ expect((component as any).issueSuggestions).toHaveLength(3);
+ });
+
+ it('filters by name match', () => {
+ (component as any).issueSearchQuery = 'Fix';
+ const suggestions: IssueEntity[] = (component as any).issueSuggestions;
+ expect(suggestions).toHaveLength(2);
+ expect(suggestions.every((i) => i.name.toLowerCase().includes('fix'))).toBe(true);
+ });
+
+ it('filters by id match', () => {
+ (component as any).issueSearchQuery = '2';
+ const suggestions: IssueEntity[] = (component as any).issueSuggestions;
+ expect(suggestions.some((i) => i.id === 2)).toBe(true);
+ });
+ });
+
+ describe('openAddIssue / cancelAddIssue', () => {
+ it('openAddIssue shows the add form', () => {
+ (component as any).openAddIssue();
+ expect((component as any).showAddIssue).toBe(true);
+ expect((component as any).showCreateIssue).toBe(false);
+ });
+
+ it('cancelAddIssue hides the form', () => {
+ (component as any).showAddIssue = true;
+ (component as any).cancelAddIssue();
+ expect((component as any).showAddIssue).toBe(false);
+ });
+ });
+
+ describe('openCreateIssue / cancelCreateIssue', () => {
+ it('openCreateIssue shows the create form', () => {
+ (component as any).openCreateIssue();
+ expect((component as any).showCreateIssue).toBe(true);
+ expect((component as any).showAddIssue).toBe(false);
+ });
+
+ it('cancelCreateIssue hides the form and clears name', () => {
+ (component as any).showCreateIssue = true;
+ (component as any).newIssueName = 'Draft';
+ (component as any).cancelCreateIssue();
+ expect((component as any).showCreateIssue).toBe(false);
+ expect((component as any).newIssueName).toBe('');
+ });
+ });
+
+ describe('navigateToIssue', () => {
+ it('navigates to the issue detail page', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).navigateToIssue(42);
+ expect(spy).toHaveBeenCalledWith(['/issues', 42]);
+ });
+ });
+
+ describe('toggleMoreMenu / closeMoreMenu', () => {
+ it('toggleMoreMenu switches moreMenuOpen', () => {
+ expect((component as any).moreMenuOpen).toBe(false);
+ (component as any).toggleMoreMenu();
+ expect((component as any).moreMenuOpen).toBe(true);
+ (component as any).toggleMoreMenu();
+ expect((component as any).moreMenuOpen).toBe(false);
+ });
+
+ it('closeMoreMenu sets moreMenuOpen to false', () => {
+ (component as any).moreMenuOpen = true;
+ (component as any).closeMoreMenu();
+ expect((component as any).moreMenuOpen).toBe(false);
+ });
+ });
+
+ describe('addIssueFromSearch', () => {
+ it('adds the issue to milestone.issueIds and saves', async () => {
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] });
+ await (component as any).addIssueFromSearch(5);
+ expect((component as any).milestone.issueIds).toContain(5);
+ expect((component as any).showAddIssue).toBe(false);
+ });
+ });
+
+ describe('removeIssue', () => {
+ it('removes the issue from milestone.issueIds and saves', async () => {
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] });
+ await (component as any).removeIssue(1);
+ expect((component as any).milestone.issueIds).not.toContain(1);
+ expect((component as any).milestone.issueIds).toContain(2);
+ });
+ });
+
+ describe('confirmCreateIssue', () => {
+ beforeEach(() => {
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] });
+ });
+
+ it('creates an issue and adds it to the milestone', async () => {
+ (component as any).newIssueName = 'New Task';
+ await (component as any).confirmCreateIssue();
+ expect((component as any).milestone.issueIds).toHaveLength(1);
+ expect((component as any).showCreateIssue).toBe(false);
+ expect((component as any).newIssueName).toBe('');
+ });
+
+ it('does nothing when name is blank', async () => {
+ (component as any).newIssueName = ' ';
+ await (component as any).confirmCreateIssue();
+ expect((component as any).milestone.issueIds).toHaveLength(0);
+ });
+ });
+
+ describe('saveMilestone', () => {
+ it('persists the milestone to the store', async () => {
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'Updated', issueIds: [] });
+ await (component as any).saveMilestone();
+ expect(milestonesStore.getById(1)?.name).toBe('Updated');
+ });
+
+ it('does nothing when name is blank', async () => {
+ (component as any).milestone.name = ' ';
+ await (component as any).saveMilestone();
+ expect(milestonesStore.getById(1)?.name).toBe('Sprint 1');
+ });
+ });
+
+ describe('statusBadge', () => {
+ it('returns correct label and colors for draft', () => {
+ const badge = (component as any).statusBadge('draft');
+ expect(badge.label).toBe('BROUILLON');
+ });
+
+ it('returns correct label for done', () => {
+ expect((component as any).statusBadge('done').label).toBe('TERMINÉ');
+ });
+
+ it('returns correct label for in-progress', () => {
+ expect((component as any).statusBadge('in-progress').label).toBe('EN COURS');
+ });
+
+ it('returns correct label for todo', () => {
+ expect((component as any).statusBadge('todo').label).toBe('À FAIRE');
+ });
+ });
+
+ describe('priorityDisplay', () => {
+ it('returns correct symbol for TRES_HAUTE', () => {
+ expect((component as any).priorityDisplay('TRES_HAUTE').symbol).toBe('↑↑');
+ });
+
+ it('returns correct symbol for BASSE', () => {
+ expect((component as any).priorityDisplay('BASSE').symbol).toBe('↓');
+ });
+
+ it('returns correct symbol for MOYENNE', () => {
+ expect((component as any).priorityDisplay('MOYENNE').symbol).toBe('–');
+ });
+ });
+
+ describe('typeIcon', () => {
+ it('returns correct letter for Epic', () => {
+ expect((component as any).typeIcon('Epic').letter).toBe('E');
+ });
+
+ it('returns correct letter for Bug', () => {
+ expect((component as any).typeIcon('Bug').letter).toBe('B');
+ });
+ });
+
+ describe('deleteMilestone', () => {
+ it('removes the milestone and navigates to /milestones', async () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ await (component as any).deleteMilestone();
+ expect(spy).toHaveBeenCalledWith(['/milestones']);
+ });
+ });
+
+ describe('cancelCreation', () => {
+ it('navigates to /milestones', () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ (component as any).cancelCreation();
+ expect(spy).toHaveBeenCalledWith(['/milestones']);
+ });
+ });
+
+ describe('descriptionHtml', () => {
+ it('returns sanitized HTML from markdown description', () => {
+ (component as any).milestone.description = '**bold**';
+ const html = (component as any).descriptionHtml;
+ expect(html).toBeTruthy();
+ });
+
+ it('returns sanitized HTML for empty description', () => {
+ (component as any).milestone.description = '';
+ const html = (component as any).descriptionHtml;
+ expect(html).toBeTruthy();
+ });
+ });
+
+ describe('hideIssueSuggestions', () => {
+ afterEach(() => vi.useRealTimers());
+
+ it('hides suggestions after 150ms', () => {
+ vi.useFakeTimers();
+ (component as any).showIssueSuggestions = true;
+ (component as any).hideIssueSuggestions();
+ expect((component as any).showIssueSuggestions).toBe(true);
+ vi.advanceTimersByTime(150);
+ expect((component as any).showIssueSuggestions).toBe(false);
+ });
+ });
+
+ describe('onDescriptionPaste', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('does not change description when clipboard has no image', () => {
+ const ta = document.createElement('textarea');
+ const event = {
+ clipboardData: { items: [] },
+ preventDefault: vi.fn(),
+ target: ta,
+ } as unknown as ClipboardEvent;
+ const before = (component as any).milestone.description;
+ (component as any).onDescriptionPaste(event);
+ expect((component as any).milestone.description).toBe(before);
+ });
+
+ it('updates description with markdown when an image is pasted', async () => {
+ vi.stubGlobal('FileReader', class {
+ readonly result = 'data:image/png;base64,abc';
+ onload: ((e: any) => void) | null = null;
+ readAsDataURL(_file: File) {
+ Promise.resolve().then(() => this.onload?.({ target: { result: this.result } }));
+ }
+ });
+ const file = new File([''], 'img.png', { type: 'image/png' });
+ const ta = document.createElement('textarea');
+ ta.value = '';
+ const event = {
+ clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
+ preventDefault: vi.fn(),
+ target: ta,
+ } as unknown as ClipboardEvent;
+ (component as any).milestone.description = '';
+ (component as any).onDescriptionPaste(event);
+ await Promise.resolve();
+ expect((component as any).milestone.description).toContain('![image]');
+ });
+ });
+});
+
+describe('MilestoneDetail — new route', () => {
+ let component: MilestoneDetail;
+ let fixture: ComponentFixture;
+ let milestonesStore: FakeMilestonesStore;
+ let router: Router;
+
+ beforeEach(async () => {
+ milestonesStore = new FakeMilestonesStore();
+ await TestBed.configureTestingModule({
+ imports: [MilestoneDetail],
+ providers: [
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ routeConfig: { path: 'milestones/new' },
+ paramMap: convertToParamMap({}),
+ },
+ paramMap: of(convertToParamMap({})),
+ },
+ },
+ { provide: IssuesStore, useValue: new FakeIssuesStore() },
+ { provide: MilestonesStore, useValue: milestonesStore },
+ ],
+ }).compileComponents();
+
+ router = TestBed.inject(Router);
+ fixture = TestBed.createComponent(MilestoneDetail);
+ component = fixture.componentInstance;
+ await fixture.whenStable();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('isNewRoute is true', () => {
+ expect((component as any).isNewRoute).toBe(true);
+ });
+
+ it('buildMilestone creates an empty milestone', () => {
+ expect((component as any).milestone.id).toBe(0);
+ expect((component as any).milestone.name).toBe('');
+ });
+
+ it('saveMilestone without explicit flag does nothing', async () => {
+ (component as any).milestone.name = 'New Sprint';
+ await (component as any).saveMilestone();
+ expect(milestonesStore.getById(0)).toBeUndefined();
+ });
+
+ it('saveMilestone with explicit=true saves and navigates to the new milestone', async () => {
+ const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]);
+ (component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] });
+ await (component as any).saveMilestone(true);
+ expect(spy).toHaveBeenCalledWith(['/milestones', 1]);
+ });
+});
diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts
index a2e32c2..bc589f6 100644
--- a/src/app/milestones/milestone-detail/milestone-detail.ts
+++ b/src/app/milestones/milestone-detail/milestone-detail.ts
@@ -61,14 +61,29 @@ export class MilestoneDetail {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
}
+ protected get displayedIssues(): IssueEntity[] {
+ const epicNamesInMilestone = new Set(
+ this.linkedIssues.filter((i) => i.type === 'Epic').map((i) => i.name),
+ );
+ return this.linkedIssues.filter((i) => !epicNamesInMilestone.has(i.epic));
+ }
+
protected get availableIssues(): IssueEntity[] {
const assignedElsewhere = new Set(
this.milestonesStore.milestones()
.filter((m) => m.id !== this.milestone.id)
.flatMap((m) => m.issueIds),
);
+ const epicNamesInMilestone = new Set(
+ this.issues()
+ .filter((i) => i.type === 'Epic' && this.milestone.issueIds.includes(i.id))
+ .map((i) => i.name),
+ );
return this.issues().filter(
- (i) => !this.milestone.issueIds.includes(i.id) && !assignedElsewhere.has(i.id),
+ (i) =>
+ !this.milestone.issueIds.includes(i.id) &&
+ !assignedElsewhere.has(i.id) &&
+ !epicNamesInMilestone.has(i.epic),
);
}
diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts
new file mode 100644
index 0000000..7270385
--- /dev/null
+++ b/src/app/milestones/milestones-api.service.spec.ts
@@ -0,0 +1,81 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { MilestonesApiService } from './milestones-api.service';
+import { MilestoneEntity } from './milestones.store';
+
+const API = '/api';
+
+const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({
+ id: 1,
+ name: 'Sprint 1',
+ description: '',
+ dueDate: '',
+ issueIds: [],
+ ...overrides,
+});
+
+describe('MilestonesApiService', () => {
+ let service: MilestonesApiService;
+ let http: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ });
+ service = TestBed.inject(MilestonesApiService);
+ http = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => http.verify());
+
+ describe('getAll', () => {
+ it('sends GET /api/milestones and returns milestones', () => {
+ const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })];
+ let result: MilestoneEntity[] | undefined;
+ service.getAll().subscribe((data) => (result = data));
+ const req = http.expectOne(`${API}/milestones`);
+ expect(req.request.method).toBe('GET');
+ req.flush(milestones);
+ expect(result).toEqual(milestones);
+ });
+ });
+
+ describe('create', () => {
+ it('sends POST /api/milestones with the body and returns the created milestone', () => {
+ const body = { name: 'Sprint 2', description: '', dueDate: '', issueIds: [] };
+ const response = makeMilestone({ id: 2, name: 'Sprint 2' });
+ let result: MilestoneEntity | undefined;
+ service.create(body).subscribe((data) => (result = data));
+ const req = http.expectOne(`${API}/milestones`);
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body).toEqual(body);
+ req.flush(response);
+ expect(result).toEqual(response);
+ });
+ });
+
+ describe('update', () => {
+ it('sends PUT /api/milestones/:id with the body and returns the updated milestone', () => {
+ const milestone = makeMilestone({ id: 1, name: 'Updated Sprint' });
+ let result: MilestoneEntity | undefined;
+ service.update(1, milestone).subscribe((data) => (result = data));
+ const req = http.expectOne(`${API}/milestones/1`);
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(milestone);
+ req.flush(milestone);
+ expect(result).toEqual(milestone);
+ });
+ });
+
+ describe('remove', () => {
+ it('sends DELETE /api/milestones/:id and completes', () => {
+ let completed = false;
+ service.remove(1).subscribe({ complete: () => (completed = true) });
+ const req = http.expectOne(`${API}/milestones/1`);
+ expect(req.request.method).toBe('DELETE');
+ req.flush(null);
+ expect(completed).toBe(true);
+ });
+ });
+});
diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts
new file mode 100644
index 0000000..6ae878c
--- /dev/null
+++ b/src/app/milestones/milestones.store.spec.ts
@@ -0,0 +1,146 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { MilestoneEntity, MilestonesStore } from './milestones.store';
+
+const API = '/api';
+
+const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({
+ id: 1,
+ name: 'Sprint 1',
+ description: '',
+ dueDate: '',
+ issueIds: [],
+ ...overrides,
+});
+
+describe('MilestonesStore', () => {
+ let store: MilestonesStore;
+ let httpMock: HttpTestingController;
+
+ const loadWith = async (milestones: MilestoneEntity[]) => {
+ const p = store.load();
+ httpMock.expectOne(`${API}/milestones`).flush(milestones);
+ await p;
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ });
+ store = TestBed.inject(MilestonesStore);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => httpMock.verify());
+
+ it('should be created', () => {
+ expect(store).toBeTruthy();
+ });
+
+ describe('load', () => {
+ it('populates milestones from the API', async () => {
+ await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]);
+ expect(store.milestones().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}/milestones`).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}/milestones`);
+ });
+ });
+
+ describe('getById', () => {
+ beforeEach(async () => {
+ await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]);
+ });
+
+ it('returns the milestone with the given id', () => {
+ expect(store.getById(1)?.id).toBe(1);
+ });
+
+ it('returns undefined for an unknown id', () => {
+ expect(store.getById(9999)).toBeUndefined();
+ });
+ });
+
+ describe('upsert', () => {
+ beforeEach(async () => {
+ await loadWith([makeMilestone({ id: 1, name: 'Existing' }), makeMilestone({ id: 2 })]);
+ });
+
+ it('creates a new milestone via POST when id is 0', async () => {
+ const before = store.milestones().length;
+ const p = store.upsert(makeMilestone({ id: 0, name: 'New Sprint' }));
+ httpMock.expectOne({ method: 'POST', url: `${API}/milestones` }).flush(makeMilestone({ id: 99, name: 'New Sprint' }));
+ await p;
+ expect(store.milestones().length).toBe(before + 1);
+ expect(store.getById(99)?.name).toBe('New Sprint');
+ });
+
+ it('updates an existing milestone via PUT', async () => {
+ const p = store.upsert(makeMilestone({ id: 1, name: 'Updated Sprint' }));
+ httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated Sprint' }));
+ await p;
+ expect(store.getById(1)?.name).toBe('Updated Sprint');
+ expect(store.milestones().filter((m) => m.id === 1).length).toBe(1);
+ });
+
+ it('returns the normalized milestone after update', async () => {
+ const p = store.upsert(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] }));
+ httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] }));
+ const result = await p;
+ expect(result.issueIds).toEqual([1, 2]);
+ });
+
+ it('leaves list unchanged when PUT response id is not found in store', async () => {
+ const before = store.milestones().length;
+ const p = store.upsert(makeMilestone({ id: 999, name: 'Unknown' }));
+ httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/999` }).flush(makeMilestone({ id: 999, name: 'Unknown' }));
+ await p;
+ expect(store.milestones().length).toBe(before);
+ });
+ });
+
+ describe('deleteById', () => {
+ beforeEach(async () => {
+ await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]);
+ });
+
+ it('removes the milestone from the store', async () => {
+ const p = store.deleteById(1);
+ httpMock.expectOne({ method: 'DELETE', url: `${API}/milestones/1` }).flush(null);
+ await p;
+ expect(store.getById(1)).toBeUndefined();
+ expect(store.milestones().length).toBe(1);
+ });
+ });
+
+ describe('normalize', () => {
+ it('normalizes issueIds to empty array when not an array', async () => {
+ const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any;
+ const p = store.upsert(raw);
+ httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, issueIds: null });
+ const result = await p;
+ expect(result.issueIds).toEqual([]);
+ });
+
+ it('filters non-number values from issueIds', async () => {
+ const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1, 'bad', null] } as any;
+ const p = store.upsert(raw);
+ httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1] });
+ const result = await p;
+ expect(result.issueIds).toEqual([1]);
+ });
+ });
+});
|