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 = { '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 = { 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>( token: string, query: string, variables?: object, ): Promise { 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( read: () => Promise, ok: (value: T) => boolean, ): Promise { 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( token: string | null, query: string, variables: Record, ) { 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( { 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); }); });