Files
task-stream/apps/api/e2e/tracker.e2e-spec.ts
Josep Piguillem 76312c079d feat: dashboard weighs quality and splits work by a list field
The insights reading adds quality (open bugs per priority, bugs created
and closed per week for 8 weeks, QA returns and passes per area over 90
days, bugs reported in the 14 days after each of the last three
releases) and, for each list custom field, open tickets per option. The
dashboard shows both; the field to split by can be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:06:11 +02:00

1445 lines
47 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
IdentityProvider,
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { Client, createClient } from 'graphql-ws';
import { createHmac } from 'node:crypto';
import WebSocket from 'ws';
process.env['POSTGRES_URL'] ??=
'postgres://taskstream:taskstream@localhost:5432/taskstream';
process.env['POSTGRES_SYNCHRONIZE'] = 'true';
process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['OPENAPI_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
const { AppModule } = require('../src/app/app.module');
const { configureApp } = require('../src/app/configure');
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
const users = new UserFactory();
const person = (id: string, name: string, email: string) =>
users.create({ id, name, email, isActive: true });
const people: Record<string, User> = {
'token-ana': person('101', 'Ana (lead)', 'ana@x.dev'),
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
};
const byId = (id: string) =>
Object.values(people).find((p) => p.id === id) ?? null;
const fakePireon: Pick<IdentityProvider, 'whoAmI' | 'findUser' | 'listUsers'> =
{
whoAmI: async (token) => {
const user = people[token];
if (!user) throw new InvalidAccessTokenError();
return user;
},
findUser: async (id) => byId(id),
listUsers: async () => Object.values(people),
};
describe('task-stream (e2e)', () => {
let app: INestApplication;
let url: string;
const clients: Client[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql<T = Record<string, any>>(
token: string,
query: string,
variables?: object,
): Promise<T> {
const response = await fetch(`${url}/graphql`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
const body = await response.json();
if (body.errors)
throw new Error(
body.errors.map((e: { message: string }) => e.message).join('; '),
);
return body.data;
}
/** REST call under /api; returns status and parsed body. */
async function rest<T = Record<string, any>>( // eslint-disable-line @typescript-eslint/no-explicit-any
token: string | null,
method: string,
path: string,
body?: object,
): Promise<{ status: number; body: T }> {
const response = await fetch(`${url}/api${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
}
/** The read model is eventually consistent: poll until it catches up. */
async function eventually<T>(
read: () => Promise<T>,
ok: (value: T) => boolean,
): Promise<T> {
const deadline = Date.now() + 10_000;
let last: unknown;
while (Date.now() < deadline) {
try {
const value = await read();
if (ok(value)) return value;
last = value;
} catch (error) {
last = error;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Read model did not converge: ${JSON.stringify(last)}`);
}
function subscribe<T>(
token: string | null,
query: string,
variables: Record<string, unknown>,
) {
const client = createClient({
url: url.replace('http', 'ws') + '/graphql',
webSocketImpl: WebSocket,
connectionParams: token ? { authorization: `Bearer ${token}` } : {},
retryAttempts: 0,
});
clients.push(client);
const received: T[] = [];
const errors: unknown[] = [];
client.subscribe<T>(
{ query, variables },
{
next: (msg) =>
msg.errors ? errors.push(msg.errors) : received.push(msg.data as T),
error: (e) => errors.push(e),
complete: () => undefined,
},
);
return { received, errors };
}
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(IdentityProvider)
.useValue(fakePireon)
.compile();
app = moduleRef.createNestApplication({ rawBody: true });
configureApp(app);
await app.listen(0);
url = await app.getUrl();
url = url.replace('[::1]', 'localhost');
});
afterAll(async () => {
await Promise.all(clients.map((c) => c.dispose()));
await app?.close();
});
it('runs the whole ticket lifecycle with CQRS and realtime updates', async () => {
const key = `E${Date.now().toString(36).slice(-5).toUpperCase()}`;
// --- Project & team (identity comes from Pireon) ---
const { createProject } = await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key version } }`,
{
i: { key, name: 'Checkout revamp' },
},
);
const projectId = createProject.id;
expect(createProject.key).toBe(key);
await eventually(
() =>
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
k: key,
}),
(d) => !!d.project,
);
for (const userId of ['102', '103']) {
await gql(
'token-ana',
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
{
i: { projectId, userId, role: 'MEMBER' },
},
);
}
// Subscriptions are authorised against the read model: wait for Bob's membership.
await eventually(
async () => (await gql('token-bob', `{ myProjects { id } }`)).myProjects,
(p) => p.some((x: { id: string }) => x.id === projectId),
);
// --- Realtime: Bob watches the board ---
const board = subscribe<{
issueChanged: { action: string; issue: { key: string; status: string } };
}>(
'token-bob',
`subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status assignee { name } } } }`,
{ p: projectId },
);
const feed = subscribe<{ activityAdded: { summary: string } }>(
'token-ana',
`subscription($p: ID!) { activityAdded(projectId: $p) { summary } }`,
{ p: projectId },
);
const intruder = subscribe(
'token-eve',
`subscription($p: ID!) { issueChanged(projectId: $p) { action } }`,
{ p: projectId },
);
const anonymous = subscribe(
null,
`subscription($p: ID!) { issueChanged(projectId: $p) { action } }`,
{ p: projectId },
);
await new Promise((r) => setTimeout(r, 300)); // let subscriptions register
// --- QA reports a bug, a UI change against Figma, and the backend dependency ---
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key version } }`;
const bug = (
await gql('token-qa', create, {
i: {
projectId,
type: 'BUG',
title: 'Pay button does nothing on Safari',
area: 'FRONTEND',
priority: 'CRITICAL',
assigneeId: '102',
labels: ['Safari', 'checkout'],
},
})
).createIssue;
const ui = (
await gql('token-qa', create, {
i: {
projectId,
type: 'UI_CHANGE',
title: 'Summary card spacing differs from Figma',
area: 'FRONTEND',
figmaUrl: 'https://www.figma.com/design/abc/Checkout',
},
})
).createIssue;
const api = (
await gql('token-ana', create, {
i: {
projectId,
type: 'FEATURE',
title: 'Expose /payments/intent',
area: 'BACKEND',
},
})
).createIssue;
expect([bug.key, ui.key, api.key]).toEqual([
`${key}-1`,
`${key}-2`,
`${key}-3`,
]);
// --- Relations FE ↔ BE ---
await gql(
'token-bob',
`mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`,
{
i: { issueId: bug.id, type: 'DEPENDS_ON', targetIssueId: api.id },
},
);
const backend = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) { issue(idOrKey: $k) { links { type direction issueKey } } }`,
{ k: api.key },
),
(d) => d.issue.links.length === 1,
);
expect(backend.issue.links[0]).toEqual({
type: 'DEPENDS_ON',
direction: 'INWARD',
issueKey: bug.key,
});
// --- Workflow including a failed QA round ---
const move = (token: string, status: string, reason?: string) =>
gql(
token,
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
{
i: { issueId: bug.id, status, reason },
},
);
await move('token-bob', 'IN_PROGRESS');
await move('token-bob', 'READY_FOR_QA');
await move('token-qa', 'IN_QA');
await expect(move('token-qa', 'QA_FAILED')).rejects.toThrow(
/reason is required/,
);
await move('token-qa', 'QA_FAILED', 'Still failing on Safari 17');
await expect(move('token-bob', 'DONE')).rejects.toThrow(/Cannot move/);
// --- Comments ---
await gql(
'token-bob',
`mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`,
{
i: bug.id,
b: 'Root cause: Safari blocks the payment popup. Decision: use redirect flow.',
},
);
// --- Releases ---
const release = (
await gql(
'token-ana',
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
{
i: { projectId, name: 'v2.0.0' },
},
)
).createRelease;
await eventually(
() =>
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
id: release.id,
}),
(d) => !!d.release,
);
await gql(
'token-ana',
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
{ i: bug.id, r: release.id },
);
await expect(
gql(
'token-ana',
`mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`,
{ r: release.id },
),
).rejects.toThrow(/unfinished/);
// --- Read model (MongoDB) converged ---
const view = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) {
issue(idOrKey: $k) {
key status priority labels commentCount allowedTransitions
assignee { name } reporter { name } release { name }
links { type direction issueKey } linkedIssues { key }
comments { total items { body author { name } } }
history { summary }
}
}`,
{ k: bug.key },
),
(d) => d.issue.commentCount === 1 && d.issue.release?.name === 'v2.0.0',
);
expect(view.issue).toMatchObject({
status: 'QA_FAILED',
priority: 'CRITICAL',
labels: ['safari', 'checkout'],
assignee: { name: 'Bob (frontend)' },
reporter: { name: 'Quinn (QA)' },
allowedTransitions: ['IN_PROGRESS', 'TODO'],
links: [{ type: 'DEPENDS_ON', direction: 'OUTWARD', issueKey: api.key }],
linkedIssues: [{ key: api.key }],
comments: { total: 1, items: [{ author: { name: 'Bob (frontend)' } }] },
});
expect(
view.issue.history.map((h: { summary: string }) => h.summary),
).toContain(
`Quinn (QA) movió ${bug.key} de En QA a QA fallido: Still failing on Safari 17`,
);
const { project } = await gql(
'token-ana',
`query($k: String!) {
project(idOrKey: $k) {
members { user { name } role }
stats { total open byStatus { key count } byAssignee { user { name } count } }
board { status total }
releases { name progress { total finished percent } }
}
}`,
{ k: key },
);
expect(project.members).toHaveLength(3);
expect(project.stats).toMatchObject({ total: 3, open: 3 });
expect(
project.board.find((c: { status: string }) => c.status === 'QA_FAILED')
.total,
).toBe(1);
expect(project.releases[0]).toMatchObject({
name: 'v2.0.0',
progress: { total: 1, finished: 0, percent: 0 },
});
// --- Authorisation on the read side ---
await expect(
gql('token-eve', `query($k: String!) { issue(idOrKey: $k) { key } }`, {
k: bug.key,
}),
).rejects.toThrow(/not found/);
// --- Realtime delivered ---
await eventually(
async () => board.received,
(r) => r.some((m) => m.issueChanged.issue.status === 'QA_FAILED'),
);
expect(board.received[0].issueChanged).toMatchObject({
action: 'IssueCreatedEvent',
issue: { key: bug.key },
});
expect(feed.received.map((m) => m.activityAdded.summary)).toContain(
`Bob (frontend) comentó en ${bug.key}`,
);
// Non-members get an error at subscribe time; unauthenticated sockets are closed.
expect(intruder.received).toHaveLength(0);
expect(JSON.stringify(intruder.errors)).toMatch(/not found/);
expect(anonymous.received).toHaveLength(0);
expect(anonymous.errors).toHaveLength(1);
});
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
// --- The contract other apps generate clients from ---
const docs = await rest(null, 'GET', '/docs-json');
expect(docs.status).toBe(200);
expect(Object.keys(docs.body['paths'])).toEqual(
expect.arrayContaining([
'/api/projects',
'/api/projects/{project}/issues',
'/api/issues/{issue}/status',
'/api/releases/{releaseId}/publish',
'/api/projects/{project}/settings',
'/api/projects/{project}/analytics',
'/api/releases/{releaseId}/burnup',
'/api/users/me',
]),
);
expect(Object.keys(docs.body['components']['securitySchemes'])).toEqual(
expect.arrayContaining(['pireon-oauth', 'pireon']),
);
// --- Every consumer goes through Pireon's OAuth ---
expect((await rest(null, 'GET', '/projects')).status).toBe(401);
expect((await rest('not-a-token', 'GET', '/projects')).status).toBe(401);
expect((await rest('token-ana', 'GET', '/users/me')).body).toMatchObject({
id: '101',
name: 'Ana (lead)',
});
// --- Project and members, addressed by key ---
const key = 'R' + Date.now().toString(36).slice(-5).toUpperCase();
const created = await rest('token-ana', 'POST', '/projects', {
key,
name: 'REST project',
});
expect(created.status).toBe(201);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
expect(
(
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '103',
role: 'MEMBER',
})
).status,
).toBe(201);
// --- Tickets: create, workflow, relations, comments ---
const invalid = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'NOPE',
title: '',
});
expect(invalid.status).toBe(400);
const be = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Endpoint de cupones',
area: 'BACKEND',
});
const qa = await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'QA',
title: 'Probar cupones',
});
expect(be.body).toMatchObject({ key: `${key}-1`, version: 1 });
const beKey = be.body['key'] as string;
const qaKey = qa.body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.status === 200,
);
const skip = await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'DONE',
});
expect(skip.status).toBe(422);
expect(skip.body).toMatchObject({ code: 'BUSINESS_RULE' });
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'TODO',
})
).status,
).toBe(200);
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/assignee`, {
assigneeId: '103',
})
).status,
).toBe(200);
expect(
(
await rest('token-qa', 'POST', `/issues/${qaKey}/links`, {
type: 'TESTS',
targetIssue: beKey,
})
).status,
).toBe(201);
expect(
(
await rest('token-qa', 'POST', `/issues/${beKey}/comments`, {
body: 'Lo pruebo en cuanto esté en QA',
})
).status,
).toBe(201);
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.body?.['status'] === 'TODO' && r.body?.['links']?.length === 1,
);
expect(issue.body).toMatchObject({
key: beKey,
assignee: { id: '103' },
links: [{ type: 'TESTS', direction: 'INWARD', issueKey: qaKey }],
allowedTransitions: expect.arrayContaining(['IN_PROGRESS']),
});
const search = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?status=TODO,BACKLOG&type=FEATURE`,
);
expect(search.body).toMatchObject({ total: 1, items: [{ key: beKey }] });
const comments = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}/comments`),
(r) => r.body?.['total'] === 1,
);
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
// --- My work across projects ---
const mine = await eventually(
() => rest('token-qa', 'GET', '/me/issues'),
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
);
// Created by Quinn but assigned to nobody: not in "assigned to me".
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
qaKey,
);
const todo = await rest(
'token-ana',
'GET',
'/me/issues?assignedToMe=false&status=TODO',
);
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
beKey,
);
const viaGraphql = await gql(
'token-qa',
`{ myIssues { total items { key assignee { id } } } }`,
);
expect(viaGraphql['myIssues'].items).toEqual(
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
);
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v9.9.9',
targetDate: '2099-01-01',
});
await eventually(
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
(r) =>
Array.isArray(r.body) &&
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
);
// Not a member of the project: none of its tickets.
const outsider = await rest('token-eve', 'GET', '/me/issues');
expect(
outsider.body['items'].map((i: { key: string }) => i.key),
).not.toContain(beKey);
// --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404,
);
});
it('lets each project customise its workflow, types and areas, and measures it', async () => {
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
const projectId = (
await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
{ i: { key, name: 'Custom workflow' } },
)
).createProject.id;
await eventually(
() =>
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
k: key,
}),
(d) => !!d.project,
);
await gql(
'token-ana',
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
{ i: { projectId, userId: '102', role: 'MEMBER' } },
);
// --- Every project starts with the default settings ---
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
statuses { id name category color requiresReason archived }
transitions { from to }
issueTypes { id name icon color archived }
areas { id name archived }
} } }`;
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
.project;
expect(settings.statuses[0]).toEqual({
id: 'BACKLOG',
name: 'Backlog',
category: 'TODO',
color: 'stone',
requiresReason: false,
archived: false,
});
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
// --- A staging step, a Spike type, a Data area; Mobile archived ---
type Item = { id: string; archived?: boolean };
const reviewAt = settings.statuses.findIndex(
(s: Item) => s.id === 'IN_REVIEW',
);
settings.statuses.splice(reviewAt + 1, 0, {
id: 'STAGING',
name: 'En staging',
category: 'QA',
color: 'teal',
requiresReason: false,
archived: false,
});
settings.transitions = settings.transitions.map(
(t: { from: string; to: string[] }) =>
t.from === 'IN_REVIEW'
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
: t,
);
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
settings.issueTypes.push({
id: 'SPIKE',
name: 'Spike',
icon: 'light-bulb',
color: 'teal',
archived: false,
});
settings.areas = settings.areas.map((a: Item) =>
a.id === 'MOBILE' ? { ...a, archived: true } : a,
);
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
const save = (token: string, next: object) =>
gql(
token,
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
{ p: projectId, s: next },
);
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
await expect(
save('token-ana', {
...settings,
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
}),
).rejects.toThrow(/archívalo/);
await save('token-ana', settings);
await eventually(
() => gql('token-ana', settingsQuery, { k: key }),
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
);
// --- Tickets follow the project's settings ---
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
await expect(
gql('token-bob', create, {
i: { projectId, type: 'EPIC', title: 'Unknown type' },
}),
).rejects.toThrow(/Unknown ticket type/);
await expect(
gql('token-bob', create, {
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
}),
).rejects.toThrow(/archived/);
const spike = (
await gql('token-bob', create, {
i: {
projectId,
type: 'SPIKE',
title: 'Evaluate a column store',
area: 'DATA',
assigneeId: '102',
},
})
).createIssue;
const release = (
await gql(
'token-ana',
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
{ i: { projectId, name: 'v1.0.0' } },
)
).createRelease;
await eventually(
() =>
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
id: release.id,
}),
(d) => !!d.release,
);
await gql(
'token-ana',
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
{ i: spike.id, r: release.id },
);
const move = (status: string) =>
gql(
'token-bob',
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
{ i: { issueId: spike.id, status } },
);
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
await move(status);
const inReview = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
{ k: spike.key },
),
(d) => d.issue.status === 'IN_REVIEW',
);
expect(inReview.issue).toEqual({
status: 'IN_REVIEW',
statusCategory: 'IN_PROGRESS',
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
});
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
await move('STAGING');
await move('DONE');
// --- Board, history, releases and statistics use the categories ---
const { project } = await eventually(
() =>
gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) {
board { status total }
stats { total open }
releases { progress { finished percent } burnup { scope done } }
analytics(days: 30) {
createdVsResolved { created resolved }
cumulativeFlow { series { status counts } }
cycleTime { count }
leadTime { count }
throughput { user { id } count }
qa { passed failed failureRate }
}
} }`,
{ k: key },
),
(d) => d.project.analytics.cycleTime.count === 1,
);
const columns = project.board.map((c: { status: string }) => c.status);
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
expect(columns).not.toContain('CANCELLED');
expect(project.stats).toEqual({ total: 1, open: 0 });
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
const analytics = project.analytics;
const sum = (
rows: { created: number; resolved: number }[],
f: 'created' | 'resolved',
) => rows.reduce((total, row) => total + row[f], 0);
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
expect(analytics.cumulativeFlow.series).toEqual([
expect.objectContaining({ status: 'DONE' }),
]);
expect(analytics.leadTime.count).toBe(1);
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
const history = await gql(
'token-ana',
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
{ k: spike.key },
);
expect(
history.issue.history.map((h: { summary: string }) => h.summary),
).toContain(
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
);
const done = await gql(
'token-bob',
`{ myIssues(categories: [DONE]) { items { key } } }`,
);
expect(done.myIssues.items).toEqual(
expect.arrayContaining([{ key: spike.key }]),
);
// --- Same over REST ---
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
expect(viaRest.body['settings']['transitions']).toEqual(
expect.arrayContaining([
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
]),
);
const renamed = viaRest.body['settings'];
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
);
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
.status,
).toBe(200);
const removed = await rest(
'token-ana',
'PUT',
`/projects/${key}/settings`,
{ ...renamed, statuses: [] },
);
expect(removed.status).toBe(422);
expect(removed.body['message']).toMatch(/archívalo/);
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...renamed,
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
issueTypes: [
...renamed.issueTypes,
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
],
});
expect(neon.status).toBe(400);
const stats = await rest(
'token-ana',
'GET',
`/projects/${key}/analytics?days=30`,
);
expect(stats.status).toBe(200);
expect(stats.body['cycleTime']['count']).toBe(1);
const burnup = await rest(
'token-ana',
'GET',
`/releases/${release.id}/burnup`,
);
expect(burnup.body).toMatchObject({ releaseId: release.id });
expect(
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
).toBe(404);
});
it('opens public projects to anyone, read-only', async () => {
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
const created = await rest('token-ana', 'POST', '/projects', {
key,
name: 'Open roadmap',
isPublic: true,
});
expect(created.status).toBe(201);
const projectId = created.body['id'] as string;
await eventually(
() => rest(null, 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const issue = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Dark mode',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${issue}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${issue}/comments`, {
body: 'Planned for Q4',
});
// REST without a token: project, tickets, comments, statistics, discovery.
const anonymous = await eventually(
() => rest(null, 'GET', `/issues/${issue}/comments`),
(r) => r.body?.['total'] === 1,
);
expect(anonymous.status).toBe(200);
expect((await rest(null, 'GET', `/projects/${key}`)).body).toMatchObject({
key,
isPublic: true,
});
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(200);
expect((await rest(null, 'GET', `/projects/${key}/analytics`)).status).toBe(
200,
);
const explore = await rest(null, 'GET', '/explore/projects');
expect(explore.body.map((p: { key: string }) => p.key)).toContain(key);
// GraphQL without a token, nested fields included.
const viaGraphql = await fetch(`${url}/graphql`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: `query($k: String!) {
publicProjects { key }
project(idOrKey: $k) { key isPublic board { status total } stats { total } releases { name } }
issues(filter: { projectId: "${projectId}" }) { total items { key comments { total } linkedIssues { key } } }
}`,
variables: { k: key },
}),
}).then((r) => r.json());
expect(viaGraphql.errors).toBeUndefined();
expect(viaGraphql.data.project).toMatchObject({
key,
isPublic: true,
stats: { total: 1 },
});
expect(viaGraphql.data.issues.items[0]).toMatchObject({
key: issue,
comments: { total: 1 },
});
// Reading is open, writing is not: anonymous → 401, outsiders → 403.
expect(
(
await rest(null, 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'x',
})
).status,
).toBe(401);
expect(
(
await rest('token-eve', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'x',
})
).status,
).toBe(403);
expect((await rest('token-eve', 'GET', `/issues/${issue}`)).status).toBe(
200,
);
// A bad token is still rejected, even where none is needed.
expect((await rest('bad-token', 'GET', `/projects/${key}`)).status).toBe(
401,
);
// Back to private: anonymous readers get 404 again.
await rest('token-ana', 'PATCH', `/projects/${key}`, { isPublic: false });
await eventually(
() => rest(null, 'GET', `/projects/${key}`),
(r) => r.status === 404,
);
const hidden = await rest(null, 'GET', `/issues/${issue}`);
expect(hidden.status).toBe(404);
// The answer names what was asked, not the private project behind it.
expect(hidden.body['message']).toContain(issue);
expect(hidden.body['message']).not.toContain(projectId);
const project = await rest(null, 'GET', `/projects/${key}`);
expect(project.body['message']).toContain(key);
expect(project.body['message']).not.toContain(projectId);
});
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
const other = 'O' + Date.now().toString(36).slice(-5).toUpperCase();
for (const k of [key, other])
await rest('token-ana', 'POST', '/projects', { key: k, name: k });
await eventually(
() => rest('token-ana', 'GET', `/projects/${other}`),
(r) => r.status === 200,
);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
role: 'ADMIN',
});
const doomed = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Doomed',
})
).body['key'] as string;
const survivor = (
await rest('token-ana', 'POST', `/projects/${other}/issues`, {
type: 'TASK',
title: 'Survivor',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.status === 200,
);
await eventually(
() => rest('token-ana', 'GET', `/issues/${doomed}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${doomed}/comments`, {
body: 'bye',
});
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v1.0.0',
});
await rest('token-ana', 'POST', `/issues/${survivor}/links`, {
type: 'DEPENDS_ON',
targetIssue: doomed,
});
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.body?.['links']?.length === 1,
);
// Owner only, confirming the key.
expect(
(await rest('token-bob', 'DELETE', `/projects/${key}?confirm=${key}`))
.status,
).toBe(403);
expect(
(await rest('token-ana', 'DELETE', `/projects/${key}?confirm=NOPE`))
.status,
).toBe(422);
const deleted = await gql(
'token-ana',
`mutation($p: ID!, $k: String!) { deleteProject(projectId: $p, confirmKey: $k) { version } }`,
{
p: (await rest('token-ana', 'GET', `/projects/${key}`)).body['id'],
k: key.toLowerCase(),
},
);
expect(deleted.deleteProject.version).toBeGreaterThan(1);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 404,
);
// The saga deletes the tickets and the link from the other project.
await eventually(
() => rest('token-ana', 'GET', `/issues/${doomed}`),
(r) => r.status === 404,
);
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.body?.['links']?.length === 0,
);
const mine = await rest('token-bob', 'GET', '/projects');
expect(mine.body.map((p: { key: string }) => p.key)).not.toContain(key);
// The key can be used again.
expect(
(await rest('token-ana', 'POST', '/projects', { key, name: 'Reborn' }))
.status,
).toBe(201);
});
it('archiving a release lets its unfinished tickets go (saga)', async () => {
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const release = (
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v2.0.0',
})
).body['id'] as string;
await eventually(
() => rest('token-ana', 'GET', `/releases/${release}`),
(r) => r.status === 200,
);
const open = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Still open',
releaseId: release,
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${open}`),
(r) => r.body?.['release']?.id === release,
);
expect(
(await rest('token-ana', 'POST', `/releases/${release}/archive`)).status,
).toBe(200);
const released = await eventually(
() => rest('token-ana', 'GET', `/issues/${open}`),
(r) => r.body?.['release'] === null,
);
expect(released.body['release']).toBeNull();
});
it('unassigns the open tickets of a user Pireon deactivates (saga)', async () => {
const key = 'U' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
// Dan signs in once (so the identity context knows him) and joins.
expect((await rest('token-dan', 'GET', '/users/me')).status).toBe(200);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '105',
});
const ticket = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Dan has it',
assigneeId: '105',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.body?.['assignee']?.id === '105',
);
const body = JSON.stringify({
event: 'user.deactivated',
data: { user_id: 105 },
});
const webhook = await fetch(`${url}/api/webhooks/pireon`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webhook-signature': createHmac('sha256', 'e2e-webhook-secret')
.update(body)
.digest('hex'),
},
body,
});
expect(webhook.status).toBe(204);
const unassigned = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200 && r.body['assignee'] === null,
);
expect(unassigned.body['assignee']).toBeNull();
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { actor: { name: string } }) => h.actor.name),
).toContain('TaskStream');
});
it('adds custom fields to a project and lets tickets hold and filter by them', async () => {
const key = 'C' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const settings = project.body['settings'];
expect(settings.customFields).toEqual([]);
// REST: define them (a client that leaves them out keeps them).
const fields = [
{
id: 'BROWSER',
name: 'Navegador',
type: 'SELECT',
options: ['Chrome', 'Safari'],
},
{ id: 'COST', name: 'Coste', type: 'NUMBER' },
];
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...settings,
customFields: fields,
})
).status,
).toBe(200);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].customFields.length === 2,
);
const { customFields: _left, ...older } = settings;
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, older))
.status,
).toBe(200);
// REST: a ticket with values; a wrong one is refused.
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Pago roto',
customFields: { BROWSER: 'Safari', COST: '1,5' },
});
expect(created.status).toBe(201);
const ticket = created.body['key'] as string;
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(issue.body['customFields']).toEqual({
BROWSER: 'Safari',
COST: '1.5',
});
expect(
(
await rest('token-ana', 'PATCH', `/issues/${ticket}`, {
customFields: { BROWSER: 'Edge' },
})
).status,
).toBe(400);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Otro',
customFields: { BROWSER: 'Chrome' },
});
// GraphQL: change one, clear another; filter by value.
await gql(
'token-ana',
`mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`,
{
i: {
issueId: issue.body['id'],
customFields: [
{ fieldId: 'BROWSER', value: 'Chrome' },
{ fieldId: 'COST', value: null },
],
},
},
);
const filtered = await eventually(
() =>
gql(
'token-ana',
`query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`,
{
f: {
projectId: project.body['id'],
customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }],
},
},
),
(d) => d['issues'].total === 2,
);
expect(
filtered['issues'].items.find((i: { key: string }) => i.key === ticket)
.customFields,
).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]);
const bySafari = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?field=BROWSER:Safari`,
);
expect(bySafari.body['total']).toBe(0);
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { summary: string }) => h.summary).join(' | '),
).toContain('los campos personalizados');
});
it('starts a project from another one: its configuration and rules', async () => {
const stamp = Date.now().toString(36).slice(-4).toUpperCase();
const source = `T${stamp}`;
await rest('token-ana', 'POST', '/projects', { key: source, name: source });
const created = await eventually(
() => rest('token-ana', 'GET', `/projects/${source}`),
(r) => r.status === 200,
);
const sourceId = created.body['id'];
await rest('token-ana', 'POST', `/projects/${source}/members`, {
userId: '102',
});
const settings = created.body['settings'];
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
expect(
(
await rest('token-ana', 'PUT', `/projects/${source}/settings`, {
...settings,
customFields: [
{
id: 'CLIENT',
name: 'Cliente',
type: 'SELECT',
options: ['Acme'],
},
],
})
).status,
).toBe(200);
const rule = (name: string, kind: string, value: string) =>
gql(
'token-ana',
`mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`,
{
i: {
projectId: sourceId,
name,
trigger: { kind: 'ISSUE_CREATED' },
action: { kind, value },
},
},
);
const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[
'createAutomation'
].id;
await gql(
'token-ana',
`mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`,
{ a: paused },
);
// Bob is in the source project only: this one stays behind.
await rule('Para Bob', 'ASSIGN', '102');
// Only who manages the source can copy it.
expect(
(
await rest('token-bob', 'POST', '/projects', {
key: `B${stamp}`,
name: 'Copia',
template: source,
})
).status,
).toBe(403);
const copy = `C${stamp}`;
const { createProject } = await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`,
{ i: { key: copy, name: 'Copia', templateProjectId: sourceId } },
);
expect(createProject.key).toBe(copy);
const copied = await eventually(
() => rest('token-ana', 'GET', `/projects/${copy}`),
(r) => r.status === 200 && r.body['settings'].customFields.length === 1,
);
expect(
copied.body['settings'].areas.map((a: { id: string }) => a.id),
).toContain('DATA');
expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']);
const { automations } = await gql(
'token-ana',
`query($p: ID!) { automations(projectId: $p) { name active } }`,
{ p: createProject.id },
);
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
});
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const late = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late',
dueDate: '2020-01-01',
})
).body['key'];
const waits = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Waits',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${waits}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${late}/links`, {
type: 'BLOCKS',
targetIssue: waits,
});
const insights = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
);
expect(insights.body['sprint']).toBeNull();
expect(insights.body['risks'].overdue[0]).toMatchObject({
key: late,
dueDate: '2020-01-01',
});
expect(insights.body['risks'].blocked[0].key).toBe(waits);
expect(
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
).toContain('IN_PROGRESS');
const { project } = await gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
// What changed since a moment, and what the backlog lacks.
const since = new Date(Date.now() - 60_000).toISOString();
const later = await rest(
'token-ana',
'GET',
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
);
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
expect(later.body['backlog']).toMatchObject({
open: 2,
unestimated: 2,
unassigned: 2,
});
expect(
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
).toContain('101');
const unestimated = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?noEstimate=true&noArea=true`,
);
expect(unestimated.body['total']).toBe(2);
expect(later.body['quality'].openBugsByPriority).toHaveLength(5);
expect(later.body['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]);
});
});