dorfteich/apps/api/src/import-export/conversion-job.e2e.db.test.ts
Claude Opus 4.8 4755c18ef5
All checks were successful
CD / Build and push images (push) Successful in 4m9s
CI / Lint, typecheck, test (push) Successful in 2m50s
CI / Auth e2e pack (push) Successful in 3m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
Add conversion job queue and pandoc sidecar integration (#62)
Import/export conversions run asynchronously against an internal pandoc-server
sidecar with limits and graceful failure (ADR 0009). This is the plumbing;
the import (#63) and export (#65) features enqueue jobs onto it.

Sidecar & config:
- pandoc/core:3.6 in HTTP server mode added to the Compose stack, internal
  network only, with a wget healthcheck on /version; the api depends on it
  healthy and reaches it via the new PANDOC_URL env (default http://pandoc:3030).
- readyz gains a warning-level `converter` check: an unreachable sidecar
  degrades import/export but never flips the instance to unready (new `warn`
  status on ReadinessCheck).

Conversion flow (apps/api/src/import-export/):
- ConversionJob table (per-request work queue, distinct from the name-keyed
  maintenance Job table): owner, formats, input/result bytes, status, attempts,
  lockedAt. Migration + owner cascade.
- PandocConverter (abstract) + PandocServerConverter: POST / with
  {text,from,to,standalone}; binary input formats (docx/odt/…) are base64-encoded
  in `text`; 60 s AbortController timeout; input/output size caps. Failures map
  to distinct localized codes — converter_unavailable / converter_timeout
  (retryable) and conversion_failed (final).
- ConversionWorker: claims one job at a time with `FOR UPDATE SKIP LOCKED`
  (safe against overlapping sweeps and a second process), recovers a stale
  RUNNING lock, retries transient failures up to 3 attempts then fails. A 2 s
  sweep plus wake-on-enqueue means a queued job survives an API restart.
- ConversionJobService.enqueue (size-limited) + owner-scoped GET /jobs/:id
  (poll) and GET /jobs/:id/result (stream the output); a foreign/unknown id is
  404. ConversionJobView in @dorfteich/shared.

Tests:
- conversion-job.e2e.db.test.ts (fake converter injected via a new createTestApp
  override hook): enqueue→convert→poll→result; foreign/unknown job 404; a
  persisted PENDING job picked up by a fresh app's worker (restart survival);
  sidecar-down fails after 3 retries while the API stays healthy.
- pandoc.converter.test.ts: success, non-200→conversion_failed, refused→
  converter_unavailable, and a delay-injecting server→converter_timeout.
- Verified locally against a real pandoc/core:3.6 container: markdown→html,
  markdown→docx (valid PK/OOXML bytes), and a docx→markdown round-trip.

Local: typecheck, lint, i18n:check, build all green; api 193 tests
(9 new), shared 121, web 50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 04:06:27 +02:00

224 lines
8.0 KiB
TypeScript

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<ConversionResult> {
this.calls += 1;
return Promise.resolve(this.behaviour(request));
}
reachable(): Promise<boolean> {
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<string> {
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);
});
});