Files
task-stream/apps/api/e2e/tracker.e2e-spec.ts
Josep Piguillem 341241a52f feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth
A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:26:47 +02:00

561 lines
18 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 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';
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'),
};
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/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' } });
// --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404,
);
});
});