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
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
224 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|