// TEMPORARY (UI work): API on :3001 with fake Pireon users and seeded data. Not part of the repo. import { ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { IdentityProvider, InvalidAccessTokenError, InvalidCredentialsError, } from '@task-stream/api/identity/application'; import { User, UserFactory } from '@task-stream/api/identity/domain'; import { DomainErrorFilter } from '@task-stream/api/shared/presenters'; process.env['POSTGRES_URL'] ??= 'postgres://taskstream:taskstream@localhost:5432/taskstream'; process.env['POSTGRES_SYNCHRONIZE'] = 'true'; process.env['MONGO_URL'] = `mongodb://localhost:27017/taskstream_ui_${Date.now()}`; process.env['REDIS_URL'] ??= 'redis://localhost:6379'; process.env['EVENT_STREAM_KEY'] = `task-stream:ui:${Date.now()}`; process.env['GRAPHQL_SCHEMA_FILE'] = ''; process.env['AUTH_SESSION_TTL_SECONDS'] = '0'; const { AppModule } = require('../src/app/app.module'); 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 García', 'ana@pireon.dev'), 'token-bob': person('102', 'Bob Martín', 'bob@pireon.dev'), 'token-qa': person('103', 'Quinn Ruiz', 'quinn@pireon.dev'), 'token-dan': person('104', 'Dan Ortega', 'dan@pireon.dev'), }; const byId = (id: string) => Object.values(people).find((p) => p.id === id) ?? null; const PATH: Record = { BACKLOG: [], TODO: ['TODO'], IN_PROGRESS: ['TODO', 'IN_PROGRESS'], IN_REVIEW: ['TODO', 'IN_PROGRESS', 'IN_REVIEW'], READY_FOR_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA'], IN_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA'], QA_FAILED: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED'], DONE: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'], CANCELLED: ['CANCELLED'], }; describe('ui dev server', () => { it('serves until stopped', async () => { const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(IdentityProvider) .useValue({ whoAmI: async (token: string) => { const u = people[token]; if (!u) throw new InvalidAccessTokenError(); return u; }, findUser: async (id: string) => byId(id), listUsers: async () => Object.values(people), // Email + any password "pireon" → that user's token. issueToken: async (grant: { type: string; username?: string; password?: string }) => { const entry = Object.entries(people).find(([, u]) => u.email === grant.username); if (grant.type !== 'password' || !entry || grant.password !== 'pireon') throw new InvalidCredentialsError('Email o contraseña incorrectos'); return { accessToken: entry[0], refreshToken: null, expiresIn: 86400, tokenType: 'Bearer' }; }, authorizationConfig: () => ({ authorizeUrl: 'https://oauth.pireon.dev/oauth/authorize', clientId: 'ui-dev', scopes: ['users:read'], passwordGrant: true }), }) .compile(); const app = moduleRef.createNestApplication({ rawBody: true }); app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new DomainErrorFilter()); await app.listen(3001); // eslint-disable-next-line @typescript-eslint/no-explicit-any const gql = async (token: string, query: string, variables?: object): Promise => { const r = await fetch('http://localhost:3001/graphql', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }); const body = await r.json(); if (body.errors) throw new Error(JSON.stringify(body.errors)); return body.data; }; const ana = 'token-ana'; const project = async (key: string, name: string, description: string) => (await gql(ana, `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, { i: { key, name, description } })).createProject.id as string; const member = (projectId: string, userId: string, role: string) => gql(ana, `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, { i: { projectId, userId, role } }); const release = async (projectId: string, name: string, targetDate: string, description: string) => (await gql(ana, `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, { i: { projectId, name, targetDate, description } })).createRelease.id as string; const move = async (issueId: string, status: string, reason?: string) => { for (const step of PATH[status]) await gql(ana, `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, { i: { issueId, status: step, reason: step === 'QA_FAILED' || step === 'CANCELLED' ? reason : undefined } }); }; const issue = async (projectId: string, i: Record, status = 'BACKLOG', reason?: string) => { const id = (await gql(ana, `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }`, { i: { projectId, ...i } })).createIssue.id as string; await move(id, status, reason); return id; }; const comment = (token: string, issueId: string, body: string) => gql(token, `mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`, { i: issueId, b: body }); const link = (issueId: string, targetIssueId: string, type: string) => gql(ana, `mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`, { i: { issueId, targetIssueId, type } }); const web = await project('WEB', 'Checkout revamp', 'Rediseño del checkout: pago en un paso, cupones y QA en cada release.'); for (const [id, role] of [['102', 'MEMBER'], ['103', 'MEMBER'], ['104', 'ADMIN']]) await member(web, id, role); const v132 = await release(web, 'v1.3.2', '2026-09-26', 'Tarjetas guardadas y webhooks v2.'); const v140 = await release(web, 'v1.4.0', '2026-10-17', 'Checkout en un paso.'); await release(web, 'v1.5.0', '2026-11-14', 'Nuevos métodos de pago.'); const pay = await issue(web, { type: 'BUG', priority: 'CRITICAL', area: 'FRONTEND', assigneeId: '102', releaseId: v140, labels: ['safari', 'checkout'], title: 'El botón de pagar no responde en Safari 17', description: '1. Abrir el checkout en Safari 17\n2. Rellenar la tarjeta\n3. Pulsar «Pagar»: no pasa nada y no hay error en consola.' }, 'IN_PROGRESS'); const cart = await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['carrito'], title: 'Guardar el carrito entre dispositivos', description: 'El carrito se asocia al usuario y se fusiona al iniciar sesión.' }, 'IN_REVIEW'); const summary = await issue(web, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'DESIGN', assigneeId: '102', releaseId: v140, figmaUrl: 'https://www.figma.com/design/abc123/Checkout?node-id=12-345', title: 'Nuevo resumen del pedido según Figma' }, 'READY_FOR_QA'); const regression = await issue(web, { type: 'QA', priority: 'HIGH', area: 'QA', assigneeId: '103', releaseId: v140, title: 'Regresión del checkout en iOS y Android' }, 'IN_QA'); const coupon = await issue(web, { type: 'BUG', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['cupones'], title: 'El cupón se aplica dos veces si se recarga la página' }, 'QA_FAILED', 'Sigue duplicándose si se recarga durante el pago.'); await issue(web, { type: 'TASK', priority: 'LOW', area: 'DEVOPS', assigneeId: '104', title: 'Alertas de latencia del servicio de pagos' }, 'TODO'); await issue(web, { type: 'FEATURE', priority: 'MEDIUM', area: 'FRONTEND', title: 'Pago con Bizum' }); await issue(web, { type: 'BUG', priority: 'LOW', area: 'FRONTEND', assigneeId: '102', title: 'El foco se pierde al cerrar el modal de dirección' }, 'TODO'); await issue(web, { type: 'UI_CHANGE', priority: 'LOW', area: 'DESIGN', title: 'Estados vacíos del historial de pedidos' }); await issue(web, { type: 'TASK', priority: 'MEDIUM', area: 'BACKEND', assigneeId: '104', releaseId: v132, title: 'Migrar webhooks de Stripe a la v2' }, 'DONE'); await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'FRONTEND', assigneeId: '102', releaseId: v132, title: 'Guardar tarjeta para próximas compras' }, 'DONE'); await issue(web, { type: 'QA', priority: 'MEDIUM', area: 'QA', assigneeId: '103', title: 'Pruebas exploratorias de devoluciones' }, 'TODO'); await issue(web, { type: 'BUG', priority: 'MEDIUM', area: 'MOBILE', title: 'La app se cierra al girar en el paso de envío' }); await issue(web, { type: 'TASK', priority: 'LOWEST', area: 'DEVOPS', title: 'Limpiar feature flags del checkout antiguo' }, 'CANCELLED', 'Se hará con la limpieza general de flags.'); await gql(ana, `mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`, { r: v132 }); await link(regression, summary, 'TESTS'); await link(summary, cart, 'DEPENDS_ON'); await link(coupon, regression, 'BLOCKS'); await comment('token-bob', pay, 'Reproducido: el `onClick` llega, pero el iframe de pago no termina de cargar en Safari.'); await comment('token-qa', pay, 'En Safari 16 funciona. Adjunto vídeo en el ticket de regresión.'); await comment(ana, pay, 'Decisión: usar redirect en lugar de popup para el 3-D Secure.'); const api = await project('API', 'Plataforma API', 'Servicios compartidos: identidad, pagos y notificaciones.'); await member(api, '104', 'ADMIN'); await issue(api, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', title: 'Rate limiting por cliente' }, 'IN_PROGRESS'); await issue(api, { type: 'BUG', priority: 'MEDIUM', area: 'BACKEND', title: 'Timeout en /payments/refund con importes altos' }, 'TODO'); const mob = await project('MOB', 'App móvil', 'iOS y Android: compra rápida y notificaciones.'); await member(mob, '102', 'MEMBER'); await issue(mob, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'MOBILE', title: 'Nueva navegación inferior' }, 'IN_REVIEW'); console.log('UI DEV API READY on :3001'); await new Promise(() => undefined); }, 24 * 60 * 60 * 1000); });