A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1419 lines
46 KiB
TypeScript
1419 lines
46 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);
|
|
});
|
|
});
|