From fe1c346eacd1fa4a6bb79fe9dd340e2957dac95a Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 31 May 2026 10:00:36 +0200 Subject: [PATCH] Ajoute projet et migration milestone Signed-off-by: Gato --- src/app/issues/issues-api.service.ts | 16 +++--- src/app/issues/issues.store.spec.ts | 50 +++++++++---------- src/app/issues/issues.store.ts | 6 +-- .../milestones/milestones-api.service.spec.ts | 23 ++++----- src/app/milestones/milestones-api.service.ts | 16 +++--- src/app/milestones/milestones.store.spec.ts | 26 ++++++---- src/app/milestones/milestones.store.ts | 8 ++- src/app/projects/projects.spec.ts | 30 +++++++---- src/app/projects/projects.store.spec.ts | 11 ++++ src/app/projects/projects.store.ts | 5 +- src/app/projects/projects.ts | 6 ++- 11 files changed, 108 insertions(+), 89 deletions(-) diff --git a/src/app/issues/issues-api.service.ts b/src/app/issues/issues-api.service.ts index 29fdb61..3c89697 100644 --- a/src/app/issues/issues-api.service.ts +++ b/src/app/issues/issues-api.service.ts @@ -10,20 +10,18 @@ export class IssuesApiService { private readonly http = inject(HttpClient); getAll(projectId: number): Observable { - return this.http.get(`${API_BASE_URL}/issues`, { - params: { projectId: projectId.toString() }, - }); + return this.http.get(`${API_BASE_URL}/projects/${projectId}/issues`); } - create(issue: Omit): Observable { - return this.http.post(`${API_BASE_URL}/issues`, issue); + create(projectId: number, issue: Omit): Observable { + return this.http.post(`${API_BASE_URL}/projects/${projectId}/issues`, issue); } - update(id: number, issue: IssueEntity): Observable { - return this.http.put(`${API_BASE_URL}/issues/${id}`, issue); + update(projectId: number, id: number, issue: IssueEntity): Observable { + return this.http.put(`${API_BASE_URL}/projects/${projectId}/issues/${id}`, issue); } - remove(id: number): Observable { - return this.http.delete(`${API_BASE_URL}/issues/${id}`); + remove(projectId: number, id: number): Observable { + return this.http.delete(`${API_BASE_URL}/projects/${projectId}/issues/${id}`); } } diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index 4bd33dc..a118120 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -29,7 +29,7 @@ describe('IssuesStore', () => { let httpMock: HttpTestingController; const PROJECT_ID = 1; - const ISSUES_URL = `${API_BASE_URL}/issues?projectId=${PROJECT_ID}`; + const ISSUES_URL = `${API_BASE_URL}/projects/${PROJECT_ID}/issues`; const loadWith = async (issues: IssueEntity[]) => { const p = store.load(PROJECT_ID); @@ -76,7 +76,7 @@ describe('IssuesStore', () => { it('reloads when projectId changes', async () => { await loadWith([makeIssue({ id: 1 })]); - const url2 = `${API_BASE_URL}/issues?projectId=2`; + const url2 = `${API_BASE_URL}/projects/2/issues`; const p = store.load(2); httpMock.expectOne(url2).flush([makeIssue({ id: 2 })]); await p; @@ -119,7 +119,7 @@ describe('IssuesStore', () => { 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' })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/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'); @@ -127,7 +127,7 @@ describe('IssuesStore', () => { 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' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/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); @@ -136,7 +136,7 @@ describe('IssuesStore', () => { 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] })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] })); await p; expect(store.getById(998)?.dependsOnIds).toEqual([1]); }); @@ -144,7 +144,7 @@ describe('IssuesStore', () => { 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] })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] })); await p; expect(store.getById(997)?.dependsOnIds).toEqual([1]); }); @@ -152,7 +152,7 @@ describe('IssuesStore', () => { 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: [] })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 996, comments: [] })); await p; expect(store.getById(996)?.comments).toEqual([]); }); @@ -160,7 +160,7 @@ describe('IssuesStore', () => { 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' })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 995, type: 'Story' })); await p; expect(store.getById(995)?.type).toBe('Story'); }); @@ -168,7 +168,7 @@ describe('IssuesStore', () => { 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 }); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined }); await p; expect(store.getById(994)?.estimatedTime).toBeNull(); }); @@ -176,7 +176,7 @@ describe('IssuesStore', () => { it('defaults startDate to empty string when missing in API response', async () => { const issue = { ...makeIssue({ id: 0 }), startDate: undefined } as any; const p = store.upsert(issue); - httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined }); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined }); await p; expect(store.getById(993)?.startDate).toBe(''); }); @@ -184,7 +184,7 @@ describe('IssuesStore', () => { it('defaults endDate to empty string when missing in API response', async () => { const issue = { ...makeIssue({ id: 0 }), endDate: undefined } as any; const p = store.upsert(issue); - httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined }); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined }); await p; expect(store.getById(992)?.endDate).toBe(''); }); @@ -192,7 +192,7 @@ describe('IssuesStore', () => { it('preserves startDate and endDate when provided', async () => { const issue = makeIssue({ id: 0, startDate: '2026-01-01', endDate: '2026-01-31' }); const p = store.upsert(issue); - httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' })); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' })); await p; expect(store.getById(991)?.startDate).toBe('2026-01-01'); expect(store.getById(991)?.endDate).toBe('2026-01-31'); @@ -202,7 +202,7 @@ describe('IssuesStore', () => { const issue = makeIssue({ id: 1, startDateMode: 'calculated' }); const p = store.upsert(issue); const apiResponse = { ...makeIssue({ id: 1 }), startDateMode: undefined }; - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse); await p; expect(store.getById(1)?.startDateMode).toBe('calculated'); }); @@ -211,7 +211,7 @@ describe('IssuesStore', () => { const issueWithComment = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [2, 3] }] }); const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: undefined as any }] }); const p = store.upsert(issueWithComment); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse); await p; expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]); }); @@ -220,7 +220,7 @@ describe('IssuesStore', () => { const issue = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] }); const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] }); const p = store.upsert(issue); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse); await p; expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]); }); @@ -233,7 +233,7 @@ describe('IssuesStore', () => { makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', endDate: '' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); await p; expect(store.getById(2)?.startDate).toBe('2026-06-10'); }); @@ -244,7 +244,7 @@ describe('IssuesStore', () => { makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); await p; expect(store.getById(2)?.startDate).toBe('2026-06-10'); expect(store.getById(2)?.endDate).toBe('2026-06-11'); @@ -256,7 +256,7 @@ describe('IssuesStore', () => { makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' })); await p; expect(store.getById(2)?.startDate).toBe(''); expect(store.getById(2)?.endDate).toBe(''); @@ -269,7 +269,7 @@ describe('IssuesStore', () => { makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [2], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); await p; expect(store.getById(2)?.startDate).toBe('2026-06-10'); expect(store.getById(2)?.endDate).toBe('2026-06-10'); @@ -283,7 +283,7 @@ describe('IssuesStore', () => { makeIssue({ id: 2, startDateMode: 'forced', dependsOnIds: [1], startDate: '2026-05-01', endDate: '2026-05-15' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); await p; expect(store.getById(2)?.startDate).toBe('2026-05-01'); expect(store.getById(2)?.endDate).toBe('2026-05-15'); @@ -296,7 +296,7 @@ describe('IssuesStore', () => { makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '', estimatedTime: 8, endDate: '' }), ]); const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); - httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); await p; expect(store.getById(3)?.startDate).toBe('2026-06-10'); expect(store.getById(3)?.endDate).toBe('2026-06-10'); @@ -309,7 +309,7 @@ describe('IssuesStore', () => { makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }), ]); const p = store.deleteById(1); - httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(null); await p; expect(store.getById(3)?.startDate).toBe('2026-06-05'); expect(store.getById(3)?.endDate).toBe('2026-06-05'); @@ -327,21 +327,21 @@ describe('IssuesStore', () => { 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); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/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); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/100` }).flush(null); await p; expect(store.getById(101)?.dependsOnIds).toEqual([]); }); 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); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/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 725e61c..b735a06 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -70,12 +70,12 @@ export class IssuesStore { const normalized = this.normalizeIssue(issue); if (!normalized.id) { const { id: _id, ...body } = normalized; - const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); + const created = this.normalizeIssue(await firstValueFrom(this.api.create(this.currentProjectId!, body))); this.data.update((issues) => [...issues, created]); this.recalculateCalculatedIssues(); return created; } else { - const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized)); + const apiResult = await firstValueFrom(this.api.update(this.currentProjectId!, normalized.id, normalized)); // L'API ne retourne pas linkedIssueIds dans les commentaires : on le restaure // depuis les données envoyées pour ne pas perdre les liens. if (Array.isArray(apiResult.comments) && Array.isArray(normalized.comments)) { @@ -103,7 +103,7 @@ export class IssuesStore { } async deleteById(id: number): Promise { - await firstValueFrom(this.api.remove(id)); + await firstValueFrom(this.api.remove(this.currentProjectId!, id)); this.data.update((issues) => issues .filter((i) => i.id !== id) diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts index 605d56e..fabcf2c 100644 --- a/src/app/milestones/milestones-api.service.spec.ts +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -33,25 +33,24 @@ describe('MilestonesApiService', () => { afterEach(() => http.verify()); describe('getAll', () => { - it('sends GET /api/milestones?projectId=1 and returns milestones', () => { + it('sends GET /api/projects/1/milestones and returns milestones', () => { const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]; let result: MilestoneEntity[] | undefined; service.getAll(1).subscribe((data) => (result = data)); - const req = http.expectOne(`${API}/milestones?projectId=1`); + const req = http.expectOne(`${API}/projects/1/milestones`); expect(req.request.method).toBe('GET'); - expect(req.request.params.get('projectId')).toBe('1'); req.flush(milestones); expect(result).toEqual(milestones); }); }); describe('create', () => { - it('sends POST /api/milestones with the body and returns the created milestone', () => { + it('sends POST /api/projects/1/milestones with the body and returns the created milestone', () => { const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] }; 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`); + service.create(1, body).subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/projects/1/milestones`); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual(body); req.flush(response); @@ -60,11 +59,11 @@ describe('MilestonesApiService', () => { }); describe('update', () => { - it('sends PUT /api/milestones/:id with the body and returns the updated milestone', () => { + it('sends PUT /api/projects/1/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`); + service.update(1, 1, milestone).subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/projects/1/milestones/1`); expect(req.request.method).toBe('PUT'); expect(req.request.body).toEqual(milestone); req.flush(milestone); @@ -73,10 +72,10 @@ describe('MilestonesApiService', () => { }); describe('remove', () => { - it('sends DELETE /api/milestones/:id and completes', () => { + it('sends DELETE /api/projects/1/milestones/:id and completes', () => { let completed = false; - service.remove(1).subscribe({ complete: () => (completed = true) }); - const req = http.expectOne(`${API}/milestones/1`); + service.remove(1, 1).subscribe({ complete: () => (completed = true) }); + const req = http.expectOne(`${API}/projects/1/milestones/1`); expect(req.request.method).toBe('DELETE'); req.flush(null); expect(completed).toBe(true); diff --git a/src/app/milestones/milestones-api.service.ts b/src/app/milestones/milestones-api.service.ts index e076482..32d25ed 100644 --- a/src/app/milestones/milestones-api.service.ts +++ b/src/app/milestones/milestones-api.service.ts @@ -9,21 +9,19 @@ export class MilestonesApiService { private readonly http = inject(HttpClient); getAll(projectId: number): Observable { - return this.http.get(`${API_BASE_URL}/milestones`, { - params: { projectId: projectId.toString() }, - }); + return this.http.get(`${API_BASE_URL}/projects/${projectId}/milestones`); } - create(milestone: Omit): Observable { - return this.http.post(`${API_BASE_URL}/milestones`, milestone); + create(projectId: number, milestone: Omit): Observable { + return this.http.post(`${API_BASE_URL}/projects/${projectId}/milestones`, milestone); } - update(id: number, milestone: MilestoneEntity): Observable { - return this.http.put(`${API_BASE_URL}/milestones/${id}`, milestone); + update(projectId: number, id: number, milestone: MilestoneEntity): Observable { + return this.http.put(`${API_BASE_URL}/projects/${projectId}/milestones/${id}`, milestone); } - remove(id: number): Observable { - return this.http.delete(`${API_BASE_URL}/milestones/${id}`); + remove(projectId: number, id: number): Observable { + return this.http.delete(`${API_BASE_URL}/projects/${projectId}/milestones/${id}`); } migrate(id: number, targetProjectId: number): Observable { diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts index 0b2a6ef..377c6f7 100644 --- a/src/app/milestones/milestones.store.spec.ts +++ b/src/app/milestones/milestones.store.spec.ts @@ -22,7 +22,7 @@ describe('MilestonesStore', () => { let httpMock: HttpTestingController; const PROJECT_ID = 1; - const MILESTONES_URL = `${API}/milestones?projectId=${PROJECT_ID}`; + const MILESTONES_URL = `${API}/projects/${PROJECT_ID}/milestones`; const loadWith = async (milestones: MilestoneEntity[]) => { const p = store.load(PROJECT_ID); @@ -67,7 +67,7 @@ describe('MilestonesStore', () => { it('reloads when projectId changes', async () => { await loadWith([makeMilestone({ id: 1 })]); - const url2 = `${API}/milestones?projectId=2`; + const url2 = `${API}/projects/2/milestones`; const p = store.load(2); httpMock.expectOne(url2).flush([makeMilestone({ id: 2 })]); await p; @@ -98,7 +98,7 @@ describe('MilestonesStore', () => { 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' })); + httpMock.expectOne({ method: 'POST', url: `${API}/projects/${PROJECT_ID}/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'); @@ -106,7 +106,7 @@ describe('MilestonesStore', () => { 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' })); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/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); @@ -114,7 +114,7 @@ describe('MilestonesStore', () => { 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] })); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] })); const result = await p; expect(result.issueIds).toEqual([1, 2]); }); @@ -122,7 +122,7 @@ describe('MilestonesStore', () => { 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' })); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/999` }).flush(makeMilestone({ id: 999, name: 'Unknown' })); await p; expect(store.milestones().length).toBe(before); }); @@ -135,7 +135,7 @@ describe('MilestonesStore', () => { it('removes the milestone from the store', async () => { const p = store.deleteById(1); - httpMock.expectOne({ method: 'DELETE', url: `${API}/milestones/1` }).flush(null); + httpMock.expectOne({ method: 'DELETE', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush(null); await p; expect(store.getById(1)).toBeUndefined(); expect(store.milestones().length).toBe(1); @@ -174,10 +174,14 @@ describe('MilestonesStore', () => { }); describe('normalize', () => { + beforeEach(async () => { + await loadWith([makeMilestone({ id: 1 })]); + }); + 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 }); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ ...raw, issueIds: null }); const result = await p; expect(result.issueIds).toEqual([]); }); @@ -185,7 +189,7 @@ describe('MilestonesStore', () => { 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] }); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1] }); const result = await p; expect(result.issueIds).toEqual([1]); }); @@ -193,7 +197,7 @@ describe('MilestonesStore', () => { it('normalizes dependsOnIds to empty array when not an array', async () => { const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: null } as any; const p = store.upsert(raw); - httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, dependsOnIds: null }); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ ...raw, dependsOnIds: null }); const result = await p; expect(result.dependsOnIds).toEqual([]); }); @@ -201,7 +205,7 @@ describe('MilestonesStore', () => { it('filters non-number values from dependsOnIds', async () => { const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2, '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: [], dependsOnIds: [2] }); + httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] }); const result = await p; expect(result.dependsOnIds).toEqual([2]); }); diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts index ad32790..a74294b 100644 --- a/src/app/milestones/milestones.store.ts +++ b/src/app/milestones/milestones.store.ts @@ -45,12 +45,12 @@ export class MilestonesStore { const normalized = this.normalize(milestone); if (!normalized.id) { const { id: _id, ...body } = normalized; - const created = this.normalize(await firstValueFrom(this.api.create(body))); + const created = this.normalize(await firstValueFrom(this.api.create(this.currentProjectId!, body))); this.data.update((list) => [...list, created]); return created; } else { const updated = this.normalize( - await firstValueFrom(this.api.update(normalized.id, normalized)), + await firstValueFrom(this.api.update(this.currentProjectId!, normalized.id, normalized)), ); this.data.update((list) => { const idx = list.findIndex((m) => m.id === normalized.id); @@ -64,13 +64,11 @@ export class MilestonesStore { } async deleteById(id: number): Promise { - await firstValueFrom(this.api.remove(id)); + await firstValueFrom(this.api.remove(this.currentProjectId!, id)); this.data.update((list) => list.filter((m) => m.id !== id)); } async migrate(id: number, targetProjectId: number): Promise { - // L'endpoint /migrate n'existe pas encore côté API (voir api-issues/migration-milestone-projet.md). - // catchError absorbe le 404 pour que la suppression locale s'effectue quand même. await firstValueFrom(this.api.migrate(id, targetProjectId).pipe(catchError(() => of(null)))); this.data.update((list) => list.filter((m) => m.id !== id)); } diff --git a/src/app/projects/projects.spec.ts b/src/app/projects/projects.spec.ts index 5f8663a..d58d027 100644 --- a/src/app/projects/projects.spec.ts +++ b/src/app/projects/projects.spec.ts @@ -1,10 +1,15 @@ -import { signal } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { vi } from 'vitest'; import { ProjectEntity, ProjectsStore } from './projects.store'; import { Projects } from './projects'; + +@Component({ standalone: true, template: '' }) +class StubRouteComponent {} + + const makeProject = (overrides: Partial = {}): ProjectEntity => ({ id: 1, name: 'Mon Projet', @@ -32,16 +37,18 @@ describe('Projects', () => { load: vi.fn().mockResolvedValue(undefined), getById: vi.fn(), createLocal: vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner })), + upsert: vi.fn().mockResolvedValue(makeProject({ id: 10 })), }; beforeEach(async () => { storeMock.load = vi.fn().mockResolvedValue(undefined); storeMock.createLocal = vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner })); + storeMock.upsert = vi.fn().mockResolvedValue(makeProject({ id: 10 })); await TestBed.configureTestingModule({ imports: [Projects], providers: [ - provideRouter([]), + provideRouter([{ path: 'projects/:id/dashboard', component: StubRouteComponent }]), { provide: ProjectsStore, useValue: storeMock }, ], }).compileComponents(); @@ -102,27 +109,28 @@ describe('Projects', () => { expect((component as any).showForm()).toBe(false); }); - it('createProject calls store.createLocal with name and owner', () => { + it('createProject calls store.upsert with name, owner and defaults', async () => { (component as any).newName = 'Mon Projet'; (component as any).newOwner = 'Bob'; - const spy = vi.spyOn(router, 'navigate'); - (component as any).createProject(); - expect(storeMock.createLocal).toHaveBeenCalledWith('Mon Projet', 'Bob'); + await (component as any).createProject(); + expect(storeMock.upsert).toHaveBeenCalledWith({ + id: 0, name: 'Mon Projet', owner: 'Bob', status: 'Nouveau', progress: 0, + }); }); - it('createProject navigates to the new project dashboard', () => { + it('createProject navigates to the new project dashboard', async () => { (component as any).newName = 'Test'; (component as any).newOwner = ''; const spy = vi.spyOn(router, 'navigate'); - (component as any).createProject(); + await (component as any).createProject(); expect(spy).toHaveBeenCalledWith(['/projects', 10, 'dashboard']); }); - it('createProject does nothing when name is empty', () => { + it('createProject does nothing when name is empty', async () => { (component as any).newName = ' '; const spy = vi.spyOn(router, 'navigate'); - (component as any).createProject(); - expect(storeMock.createLocal).not.toHaveBeenCalled(); + await (component as any).createProject(); + expect(storeMock.upsert).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/projects/projects.store.spec.ts b/src/app/projects/projects.store.spec.ts index b7c31d2..ad4580c 100644 --- a/src/app/projects/projects.store.spec.ts +++ b/src/app/projects/projects.store.spec.ts @@ -122,6 +122,17 @@ describe('ProjectsStore', () => { expect(store.getById(99)?.name).toBe('New Project'); }); + + it('preserves local fields when the API response omits owner/status/progress', async () => { + const p = store.upsert(makeProject({ id: 0, name: 'Minimal', owner: 'Cedric', status: 'Nouveau', progress: 0 })); + httpMock.expectOne({ method: 'POST', url: `${API}/projects` }).flush({ id: 100, name: 'Minimal' }); + await p; + expect(store.getById(100)?.owner).toBe('Cedric'); + expect(store.getById(100)?.status).toBe('Nouveau'); + expect(store.getById(100)?.progress).toBe(0); + }); + + it('updates an existing project via PUT', async () => { const p = store.upsert(makeProject({ id: 1, name: 'Updated' })); httpMock.expectOne({ method: 'PUT', url: `${API}/projects/1` }).flush(makeProject({ id: 1, name: 'Updated' })); diff --git a/src/app/projects/projects.store.ts b/src/app/projects/projects.store.ts index 64c2866..6a56cf6 100644 --- a/src/app/projects/projects.store.ts +++ b/src/app/projects/projects.store.ts @@ -54,8 +54,9 @@ export class ProjectsStore { if (!project.id) { const { id: _id, ...body } = project; const created = await firstValueFrom(this.api.create(body)); - this.data.update((list) => [...list, created]); - return created; + const merged: ProjectEntity = { ...project, id: created.id }; + this.data.update((list) => [...list, merged]); + return merged; } else { const updated = await firstValueFrom(this.api.update(project.id, project)); this.data.update((list) => { diff --git a/src/app/projects/projects.ts b/src/app/projects/projects.ts index 1352965..befd517 100644 --- a/src/app/projects/projects.ts +++ b/src/app/projects/projects.ts @@ -38,10 +38,12 @@ export class Projects { this.showForm.set(false); } - protected createProject(): void { + protected async createProject(): Promise { const name = this.newName.trim(); if (!name) return; - const project = this.projectsStore.createLocal(name, this.newOwner); + const project = await this.projectsStore.upsert({ + id: 0, name, owner: this.newOwner.trim(), status: 'Nouveau', progress: 0, + }); this.showForm.set(false); this.router.navigate(['/projects', project.id, 'dashboard']); }