Files
task-stream/libs/web/tracker/application/src/lib/facades/facades.spec.ts
Josep Piguillem 0e301ad545 feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:45 +02:00

241 lines
7.3 KiB
TypeScript

import { Component, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Events } from '@ngrx/signals/events';
import { AuthFacade } from '@task-stream/web/identity/application';
import {
ActivityGateway,
CommentsGateway,
IssuesGateway,
Project,
ProjectRole,
ProjectsGateway,
ReleasesGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import { NEVER, of } from 'rxjs';
import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { HomeFacade } from './home.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
const ok = { id: 'x', key: null, aggregateVersion: 1 };
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
const issue = { id: 'i1', key: 'WEB-1', links: [] };
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
return {
projects: {
project: vi.fn(() => of(project)),
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
updateSettings: vi.fn(() => of(ok)),
analytics: vi.fn(() => of(analytics)),
},
issues: {
board: vi.fn(() => of([])),
issue: vi.fn(() => of(issue)),
changeStatus: vi.fn(() => of(ok)),
},
list: { list: vi.fn(() => of([])) },
realtime: {
issueChanges: () => NEVER,
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
projectChanges: () => NEVER,
},
};
}
/** Facades are scoped to a route component, like in the app. */
@Component({
template: '',
providers: [
provideProjectFacade(),
provideBoardFacade(),
provideIssueFacade(),
],
})
class Host {
readonly project = inject(ProjectFacade);
readonly board = inject(BoardFacade);
readonly issue = inject(IssueFacade);
}
function setup() {
const fakes = gateways();
TestBed.configureTestingModule({
providers: [
{ provide: ProjectsGateway, useValue: fakes.projects },
{ provide: IssuesGateway, useValue: fakes.issues },
{ provide: ReleasesGateway, useValue: fakes.list },
{ provide: ActivityGateway, useValue: fakes.list },
{ provide: CommentsGateway, useValue: fakes.list },
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
},
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
],
});
const host = TestBed.createComponent(Host).componentInstance;
return { ...host, fakes };
}
describe('BoardFacade', () => {
it('opens the board for a new project and refilters the same one', () => {
const { board, fakes } = setup();
const seen: string[] = [];
TestBed.inject(Events)
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
.subscribe((event) => seen.push(event.type));
board.show({ projectId: 'p1' });
board.show({ projectId: 'p1', assigneeId: 'u2' });
board.show({ projectId: 'p2' });
expect(seen).toEqual([
boardPageEvents.opened.type,
boardPageEvents.filterChanged.type,
boardPageEvents.opened.type,
]);
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
expect(board.filter()).toEqual({ projectId: 'p2' });
});
});
describe('ProjectFacade', () => {
it('ignores commands until a project is open, then sends its id', () => {
const { project, fakes } = setup();
project.addMember('u2');
expect(fakes.projects.addMember).not.toHaveBeenCalled();
project.open('WEB');
expect(project.project()?.id).toBe('p1');
project.addMember('u2', ProjectRole.VIEWER);
expect(fakes.projects.addMember).toHaveBeenCalledWith({
projectId: 'p1',
userId: 'u2',
role: ProjectRole.VIEWER,
});
});
it('saves settings and loads statistics of the open project', () => {
const { project, fakes } = setup();
project.open('WEB');
const settings = {
statuses: [],
transitions: [],
issueTypes: [],
areas: [],
};
project.saveSettings(settings);
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
expect(project.savingSettings()).toBe(false);
project.loadAnalytics(30);
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
expect(project.analyticsDays()).toBe(30);
expect(project.analytics()).toBe(analytics);
// Unknown ids still get a readable name.
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];
TestBed.inject(Events)
.on(projectPageEvents.userSearchRequested)
.subscribe(({ payload }) => seen.push(payload));
project.searchUsers(' a ');
project.searchUsers(' an ');
expect(seen).toEqual(['an']);
});
});
describe('IssueFacade', () => {
it('acts on the open ticket', () => {
const { issue, fakes } = setup();
issue.changeStatus('IN_PROGRESS');
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
issue.open('WEB-1');
issue.changeStatus('QA_FAILED', 'Falla en Safari');
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
issueId: 'i1',
status: 'QA_FAILED',
reason: 'Falla en Safari',
});
});
});
describe('HomeFacade', () => {
const issue = (key: string, status: string, statusCategory: string) =>
({ id: key, key, status, statusCategory, title: key }) as never;
it('loads my work and groups it by category, QA failures first', () => {
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
of(
filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
issue('WEB-4', 'BACKLOG', 'TODO'),
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
],
total: 3,
}
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
),
);
TestBed.configureTestingModule({
providers: [
{ provide: IssuesGateway, useValue: { mine } },
{
provide: ReleasesGateway,
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
},
],
});
const home = TestBed.inject(HomeFacade);
home.load();
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({ assignedToMe: true }),
);
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({
assignedToMe: false,
categories: ['QA'],
}),
);
expect(home.loaded()).toBe(true);
expect(
home
.mineByCategory()
.map((g) => [g.category, g.issues.map((i) => i.key)]),
).toEqual([
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
['TODO', ['WEB-4']],
]);
expect(home.qaFailed()).toBe(1);
expect(home.awaitingQaTotal()).toBe(1);
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});