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 = { '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 = { 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; } /** REST call under /api; returns status and parsed body. */ async function rest>( // 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( 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 }); 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, ); expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404); }); 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'); }); });