first commit
This commit is contained in:
408
apps/api/e2e/tracker.e2e-spec.ts
Normal file
408
apps/api/e2e/tracker.e2e-spec.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { INestApplication, ValidationPipe } 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 { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
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['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||
|
||||
const { AppModule } = require('../src/app/app.module');
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user