54d1534d4d
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
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> = {}): IssueEntity => ({
|
|
id: 99,
|
|
type: 'Story',
|
|
assignee: '',
|
|
epic: '',
|
|
name: 'Test Issue',
|
|
startDate: '',
|
|
startDateMode: 'forced',
|
|
endDate: '',
|
|
dueDate: '',
|
|
description: '',
|
|
estimatedTime: null,
|
|
dependsOnIds: [],
|
|
comments: [],
|
|
priority: 'MOYENNE',
|
|
status: 'draft',
|
|
progress: 0,
|
|
...overrides,
|
|
});
|
|
|
|
describe('IssuesStore', () => {
|
|
let store: IssuesStore;
|
|
let httpMock: HttpTestingController;
|
|
|
|
const PROJECT_ID = 1;
|
|
const ISSUES_URL = `${API_BASE_URL}/issues?projectId=${PROJECT_ID}`;
|
|
|
|
const loadWith = async (issues: IssueEntity[]) => {
|
|
const p = store.load(PROJECT_ID);
|
|
httpMock.expectOne(ISSUES_URL).flush(issues);
|
|
await p;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
});
|
|
store = TestBed.inject(IssuesStore);
|
|
httpMock = TestBed.inject(HttpTestingController);
|
|
});
|
|
|
|
afterEach(() => {
|
|
httpMock.verify();
|
|
});
|
|
|
|
it('should be created', () => {
|
|
expect(store).toBeTruthy();
|
|
});
|
|
|
|
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(PROJECT_ID);
|
|
expect(store.loading()).toBe(true);
|
|
httpMock.expectOne(ISSUES_URL).flush([]);
|
|
await p;
|
|
expect(store.loading()).toBe(false);
|
|
expect(store.loaded()).toBe(true);
|
|
});
|
|
|
|
it('does not reload if already loaded for the same project', async () => {
|
|
await loadWith([]);
|
|
await store.load(PROJECT_ID);
|
|
httpMock.expectNone(ISSUES_URL);
|
|
});
|
|
|
|
it('reloads when projectId changes', async () => {
|
|
await loadWith([makeIssue({ id: 1 })]);
|
|
const url2 = `${API_BASE_URL}/issues?projectId=2`;
|
|
const p = store.load(2);
|
|
httpMock.expectOne(url2).flush([makeIssue({ id: 2 })]);
|
|
await p;
|
|
expect(store.issues().length).toBe(1);
|
|
expect(store.issues()[0].id).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getById', () => {
|
|
beforeEach(async () => {
|
|
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
|
|
});
|
|
|
|
it('returns the issue with the given id', () => {
|
|
expect(store.getById(1)?.id).toBe(1);
|
|
});
|
|
|
|
it('returns undefined for an unknown id', () => {
|
|
expect(store.getById(9999)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getNextId', () => {
|
|
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', async () => {
|
|
await loadWith([]);
|
|
expect(store.getNextId()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('upsert', () => {
|
|
beforeEach(async () => {
|
|
await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
|
|
});
|
|
|
|
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('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', 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', 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', 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 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();
|
|
});
|
|
|
|
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 });
|
|
await p;
|
|
expect(store.getById(993)?.startDate).toBe('');
|
|
});
|
|
|
|
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 });
|
|
await p;
|
|
expect(store.getById(992)?.endDate).toBe('');
|
|
});
|
|
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(991)?.startDate).toBe('2026-01-01');
|
|
expect(store.getById(991)?.endDate).toBe('2026-01-31');
|
|
});
|
|
|
|
it('restores startDateMode when the API response omits it', async () => {
|
|
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);
|
|
await p;
|
|
expect(store.getById(1)?.startDateMode).toBe('calculated');
|
|
});
|
|
|
|
it('restores linkedIssueIds in comments when API response omits them', async () => {
|
|
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);
|
|
await p;
|
|
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]);
|
|
});
|
|
|
|
it('keeps existing linkedIssueIds in comments when already present in API response', async () => {
|
|
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);
|
|
await p;
|
|
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]);
|
|
});
|
|
});
|
|
|
|
describe('cascade recalculation of calculated-mode issues', () => {
|
|
it('updates startDate of a calculated-mode dependent when its dependency endDate changes', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-01' }),
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
|
});
|
|
|
|
it('recalculates endDate of a calculated-mode dependent based on estimatedTime', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-01' }),
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
|
expect(store.getById(2)?.endDate).toBe('2026-06-11');
|
|
});
|
|
|
|
it('clears endDate when dependency loses its endDate', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-10' }),
|
|
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: '' }));
|
|
await p;
|
|
expect(store.getById(2)?.startDate).toBe('');
|
|
expect(store.getById(2)?.endDate).toBe('');
|
|
});
|
|
|
|
it('cascades through a chain A → B → C', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-01' }),
|
|
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 8, endDate: '2026-06-01' }),
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
|
expect(store.getById(2)?.endDate).toBe('2026-06-10');
|
|
expect(store.getById(3)?.startDate).toBe('2026-06-10');
|
|
expect(store.getById(3)?.endDate).toBe('2026-06-11');
|
|
});
|
|
|
|
it('does not affect forced-mode issues', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-01' }),
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(2)?.startDate).toBe('2026-05-01');
|
|
expect(store.getById(2)?.endDate).toBe('2026-05-15');
|
|
});
|
|
|
|
it('uses the latest endDate among multiple dependencies', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-01' }),
|
|
makeIssue({ id: 2, endDate: '2026-06-05' }),
|
|
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' }));
|
|
await p;
|
|
expect(store.getById(3)?.startDate).toBe('2026-06-10');
|
|
expect(store.getById(3)?.endDate).toBe('2026-06-10');
|
|
});
|
|
|
|
it('recalculates after deleteById removes a dependency', async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1, endDate: '2026-06-10' }),
|
|
makeIssue({ id: 2, endDate: '2026-06-05' }),
|
|
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);
|
|
await p;
|
|
expect(store.getById(3)?.startDate).toBe('2026-06-05');
|
|
expect(store.getById(3)?.endDate).toBe('2026-06-05');
|
|
});
|
|
});
|
|
|
|
describe('deleteById', () => {
|
|
beforeEach(async () => {
|
|
await loadWith([
|
|
makeIssue({ id: 1 }),
|
|
makeIssue({ id: 100 }),
|
|
makeIssue({ id: 101, dependsOnIds: [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('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]);
|
|
});
|
|
});
|
|
});
|