import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { ConversionJobService } from './conversion-job.service'; import { ConversionWorker } from './conversion-worker.service'; import { ConversionError, ConversionRequest, ConversionResult, PandocConverter, } from './pandoc.converter'; /** * Conversion job queue (issue #62): the enqueue → worker → poll flow, driven * by an injected fake converter so no live pandoc sidecar is needed. The real * PandocServerConverter's transport (timeout, error mapping) is covered by * pandoc.converter.test.ts. */ /** A converter whose behaviour each test sets: succeed with fixed bytes, or * throw a chosen ConversionError (to exercise retries and failure). */ class FakeConverter extends PandocConverter { behaviour: (request: ConversionRequest) => ConversionResult = () => ({ output: Buffer.from('CONVERTED'), mimeType: 'application/octet-stream', }); calls = 0; convert(request: ConversionRequest): Promise { this.calls += 1; return Promise.resolve(this.behaviour(request)); } reachable(): Promise { return Promise.resolve(true); } } describe.skipIf(!hasTestDb)('conversion job queue (e2e, issue #62)', () => { let app: INestApplication; let prisma: PrismaClient; let jobs: ConversionJobService; let worker: ConversionWorker; let fake: FakeConverter; const suffix = uniqueSuffix(); const password = 'konvertiere meine dokumente 1'; const owner = { username: `carla-convert-${suffix}`, displayName: `Carla Convert ${suffix}` }; const other = { username: `oscar-other-${suffix}`, displayName: `Oscar Other ${suffix}` }; let ownerId: string; let ownerCookie: string; let otherCookie: string; const api = () => request(app.getHttpServer()); async function loginOf(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); fake = new FakeConverter(); app = await createTestApp((builder) => builder.overrideProvider(PandocConverter).useValue(fake), ); jobs = app.get(ConversionJobService); worker = app.get(ConversionWorker); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: owner.displayName, password, locale: 'en', }); ownerId = ownerUser.id; const verify = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204); ownerCookie = await loginOf(owner.username); const otherUser = await users.createUser({ username: other.username, email: `${other.username}@example.org`, displayName: other.displayName, password, locale: 'en', }); await users.markEmailVerified(otherUser.id); otherCookie = await loginOf(other.username); }); afterAll(async () => { await prisma.conversionJob.deleteMany({ where: { owner: { username: { contains: suffix } } } }); // Verifying the owner's e-mail created a personal pond (+ owner-admin // grant); clear those before the users they reference. const where = { pond: { owner: { username: { contains: suffix } } } }; await prisma.roleGrant.deleteMany({ where }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('enqueues, the worker converts, and the owner polls status and result', async () => { fake.behaviour = () => ({ output: Buffer.from('DOCX-BYTES'), mimeType: 'application/x-test' }); const job = await jobs.enqueue({ ownerId, kind: 'export_test', from: 'markdown', to: 'docx', input: Buffer.from('# Hi'), }); // Pending until the worker runs (wake() is a no-op under test). const pending = await api() .get(`/api/v1/jobs/${job.id}`) .set('Cookie', ownerCookie) .expect(200); expect(pending.body.status).toBe('pending'); await worker.drain(); const done = await api().get(`/api/v1/jobs/${job.id}`).set('Cookie', ownerCookie).expect(200); expect(done.body.status).toBe('succeeded'); expect(done.body.errorCode).toBeNull(); const result = await api() .get(`/api/v1/jobs/${job.id}/result`) .set('Cookie', ownerCookie) .expect(200); expect(result.headers['content-type']).toContain('application/x-test'); expect(result.headers['content-disposition']).toContain('attachment'); expect(result.text).toBe('DOCX-BYTES'); }); it('hides a foreign or unknown job (404, not 403)', async () => { fake.behaviour = () => ({ output: Buffer.from('x'), mimeType: 'text/plain' }); const job = await jobs.enqueue({ ownerId, kind: 'export_test', from: 'markdown', to: 'html', input: Buffer.from('hi'), }); await worker.drain(); await api().get(`/api/v1/jobs/${job.id}`).set('Cookie', otherCookie).expect(404); await api().get(`/api/v1/jobs/${job.id}/result`).set('Cookie', otherCookie).expect(404); await api().get(`/api/v1/jobs/${crypto.randomUUID()}`).set('Cookie', ownerCookie).expect(404); }); it('a queued job survives an API restart and completes', async () => { // Persist a PENDING job directly (as if enqueued just before a crash), // with no worker having touched it. const persisted = await prisma.conversionJob.create({ data: { ownerId, kind: 'export_test', sourceFormat: 'markdown', targetFormat: 'docx', input: new Uint8Array(Buffer.from('# survives restart')), }, }); expect(persisted.status).toBe('PENDING'); // A brand-new application = a fresh worker with empty memory, exactly like // a real process restart. It must pick the persisted job up from the DB. const restartFake = new FakeConverter(); restartFake.behaviour = () => ({ output: Buffer.from('AFTER-RESTART'), mimeType: 'text/plain', }); const restarted = await createTestApp((builder) => builder.overrideProvider(PandocConverter).useValue(restartFake), ); try { await restarted.get(ConversionWorker).drain(); const row = await prisma.conversionJob.findUniqueOrThrow({ where: { id: persisted.id } }); expect(row.status).toBe('SUCCEEDED'); expect(Buffer.from(row.result!).toString()).toBe('AFTER-RESTART'); } finally { await restarted.close(); } }); it('sidecar down → job fails after retries; the API stays healthy', async () => { fake.calls = 0; fake.behaviour = () => { throw new ConversionError('converter_unavailable', true, 'sidecar down'); }; const job = await jobs.enqueue({ ownerId, kind: 'export_test', from: 'markdown', to: 'docx', input: Buffer.from('# retry me'), }); await worker.drain(); const row = await prisma.conversionJob.findUniqueOrThrow({ where: { id: job.id } }); expect(row.status).toBe('FAILED'); expect(row.errorCode).toBe('converter_unavailable'); expect(row.attempts).toBe(3); // MAX_ATTEMPTS — retried, then given up expect(fake.calls).toBe(3); // The API is unharmed by the failed conversion. await api().get('/api/v1/healthz').expect(200); }); });