refactor(upload): unify docx-to-pdf conversion into finalize handler and remove legacy route

Remove the separate docx-to-pdf upload API and integrate DOCX conversion directly into the blob upload finalize route. Update client and uploader logic to treat DOCX as a supported upload type and streamline batch state handling. Adjust document caching and test helpers to align with the new unified flow. Add server-side DOCX-to-PDF conversion utility and corresponding unit tests.

This change simplifies the upload pipeline, consolidates conversion logic, and improves maintainability.
This commit is contained in:
Richard R 2026-06-04 13:00:05 -06:00
parent 7344fcbc51
commit 2ecd281f75
11 changed files with 316 additions and 370 deletions

View file

@ -1,3 +1,4 @@
import path from 'path';
import { createHash } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
@ -12,8 +13,10 @@ import {
isMissingBlobError,
isPreconditionFailed,
isValidTempUploadToken,
putDocumentBlob,
putTempDocumentFinalizeReceipt,
} from '@/lib/server/documents/blobstore';
import { convertDocxBufferToPdfBuffer } from '@/lib/server/documents/docx-convert';
import { registerUploadedDocument } from '@/lib/server/documents/register-upload';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { errorResponse } from '@/lib/server/errors/next-response';
@ -133,23 +136,48 @@ async function finalizeOne(input: {
throw new Error('Temporary upload expired before finalize');
}
const documentId = createHash('sha256').update(temp.body).digest('hex');
const isDocxUpload = input.upload.type === 'docx';
const finalizedType: DocumentType = isDocxUpload ? 'pdf' : input.upload.type;
const finalizedBody = isDocxUpload
? await convertDocxBufferToPdfBuffer(temp.body)
: temp.body;
const finalizedContentType = finalizedType === 'pdf'
? 'application/pdf'
: temp.contentType;
const finalizedName = finalizedType === 'pdf' && input.upload.type === 'docx'
? safeDocumentName(`${path.parse(input.upload.name).name}.pdf`, 'upload.pdf')
: input.upload.name;
const documentId = createHash('sha256').update(finalizedBody).digest('hex');
try {
await headDocumentBlob(documentId, input.namespace);
} catch (error) {
if (!isMissingBlobError(error)) throw error;
try {
await copyTempDocumentBlobToDocument(
input.upload.token,
input.userId,
documentId,
input.namespace,
temp.contentType,
{ ifNoneMatch: true },
);
} catch (copyError) {
if (!isPreconditionFailed(copyError)) throw copyError;
if (!isDocxUpload) {
try {
await copyTempDocumentBlobToDocument(
input.upload.token,
input.userId,
documentId,
input.namespace,
finalizedContentType,
{ ifNoneMatch: true },
);
} catch (copyError) {
if (!isPreconditionFailed(copyError)) throw copyError;
}
} else {
try {
await putDocumentBlob(
documentId,
finalizedBody,
finalizedContentType,
input.namespace,
{ ifNoneMatch: true },
);
} catch (putError) {
if (!isPreconditionFailed(putError)) throw putError;
}
}
}
@ -158,9 +186,9 @@ async function finalizeOne(input: {
documentId,
userId: input.userId,
namespace: input.namespace,
name: input.upload.name,
type: input.upload.type,
size: canonicalHead.contentLength > 0 ? canonicalHead.contentLength : temp.size,
name: finalizedName,
type: finalizedType,
size: canonicalHead.contentLength > 0 ? canonicalHead.contentLength : finalizedBody.byteLength,
lastModified: input.upload.lastModified,
});

View file

@ -1,225 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
import { spawn } from 'child_process';
import path from 'path';
import { existsSync } from 'fs';
import { randomUUID, createHash } from 'crypto';
import { pathToFileURL } from 'url';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { findReusableParsedPdfResult } from '@/lib/server/documents/parsed-pdf-reuse';
import { safeDocumentName } from '@/lib/server/documents/utils';
import { enqueueDocumentPreview } from '@/lib/server/documents/previews';
import { startPdfParseOperation } from '@/lib/server/documents/pdf-parse-operation';
import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job';
import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
import { PDF_PARSER_VERSION } from '@openreader/compute-core';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
async function ensureTempDir() {
if (!existsSync(DOCSTORE_DIR)) {
await mkdir(DOCSTORE_DIR, { recursive: true });
}
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR, { recursive: true });
}
}
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
return new Promise((resolve, reject) => {
const args: string[] = [];
if (profileDir) {
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
}
args.push('--headless', '--nologo', '--convert-to', 'pdf', '--outdir', outputDir, inputPath);
const proc = spawn('soffice', args);
proc.on('error', (error) => reject(error));
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`LibreOffice conversion failed with code ${code}`));
});
});
}
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
const files = await readdir(dir);
const pdf = files.find((f) => f.toLowerCase().endsWith('.pdf'));
if (pdf) {
const pdfPath = path.join(dir, pdf);
try {
const first = await stat(pdfPath);
await new Promise((res) => setTimeout(res, intervalMs));
const second = await stat(pdfPath);
if (second.size > 0 && second.size === first.size) {
return pdfPath;
}
} catch {
// ignore transient errors
}
}
await new Promise((res) => setTimeout(res, intervalMs));
}
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
}
export async function POST(req: NextRequest) {
try {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
const testNamespace = getOpenReaderTestNamespace(req.headers);
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const storageUserId = ctxOrRes.userId;
await ensureTempDir();
const formData = await req.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!file.name.toLowerCase().endsWith('.docx')) {
return NextResponse.json({ error: 'File must be a .docx document' }, { status: 400 });
}
const docxBytes = Buffer.from(await file.arrayBuffer());
// Keep stable IDs tied to source bytes.
const id = createHash('sha256').update(docxBytes).digest('hex');
const tempId = randomUUID();
const jobDir = path.join(TEMP_DIR, tempId);
await mkdir(jobDir, { recursive: true });
const profileDir = path.join(jobDir, 'lo-profile');
await mkdir(profileDir, { recursive: true });
const inputPath = path.join(jobDir, 'input.docx');
await writeFile(inputPath, docxBytes);
try {
await convertDocxToPdf(inputPath, jobDir, profileDir);
const pdfPath = await waitForPdfReady(jobDir);
const pdfContent = await readFile(pdfPath);
try {
await putDocumentBlob(id, pdfContent, 'application/pdf', testNamespace);
} catch (error) {
// Idempotent behavior: if blob already exists for this sha, continue.
const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } } | undefined;
const isPreconditionFailed = maybe?.$metadata?.httpStatusCode === 412 || maybe?.name === 'PreconditionFailed';
if (!isPreconditionFailed) {
throw error;
}
}
const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`);
const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now();
const reusableParsedPdf = await findReusableParsedPdfResult(id);
const startedParse = reusableParsedPdf
? null
: await startPdfParseOperation({
documentId: id,
userId: storageUserId,
namespace: testNamespace,
});
const parseState = stringifyDocumentParseState(
reusableParsedPdf
? { status: 'ready', progress: null, updatedAt: Date.now(), parserVersion: PDF_PARSER_VERSION }
: startedParse!.parseState,
);
await db
.insert(documents)
.values({
id,
userId: storageUserId,
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
filePath: id,
parseState,
parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null,
})
.onConflictDoUpdate({
target: [documents.id, documents.userId],
set: {
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
filePath: id,
parseState,
parsedJsonKey: reusableParsedPdf?.parsedJsonKey ?? null,
},
});
await enqueueDocumentPreview(
{
id,
type: 'pdf',
lastModified,
},
testNamespace,
).catch((error) => {
serverLogger.warn({
event: 'documents.docx.preview_enqueue.failed',
degraded: true,
fallbackPath: 'skip_preview_enqueue',
documentId: id,
error: errorToLog(error),
}, 'Failed to enqueue preview for converted DOCX');
});
if (startedParse) {
enqueueParsePdfJob({
documentId: id,
userId: storageUserId,
namespace: testNamespace,
initialOpId: startedParse.workerState.opId,
initialJobId: startedParse.workerState.jobId,
initialStatus: startedParse.parseState.status === 'running' ? 'running' : 'pending',
});
}
return NextResponse.json({
stored: {
id,
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
},
});
} finally {
await rm(jobDir, { recursive: true, force: true }).catch(() => {});
}
} catch (error) {
serverLogger.error({
event: 'documents.docx.convert_upload.failed',
error: errorToLog(error),
}, 'Failed converting/uploading DOCX');
return errorResponse(error, {
apiErrorMessage: 'Failed to convert document',
normalize: { code: 'DOCUMENTS_DOCX_CONVERT_UPLOAD_FAILED', errorClass: 'upstream' },
});
}
}

View file

@ -122,16 +122,14 @@ interface DocumentListInnerProps {
function SidebarUploadLoader({
totalFiles,
completedFiles,
phase,
currentFileName,
}: {
totalFiles: number;
completedFiles: number;
phase: 'uploading' | 'converting';
phase: 'uploading';
currentFileName: string | null;
}) {
const progress = totalFiles > 0 ? Math.min(100, Math.round((completedFiles / totalFiles) * 100)) : 0;
const label = phase === 'converting' ? 'Converting' : 'Uploading';
const radius = 7;
const stroke = 2;
const size = 18;
@ -143,12 +141,12 @@ function SidebarUploadLoader({
<div className="rounded-md border border-line bg-surface-sunken px-2 py-1.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight">
<span className="font-medium text-foreground">{label}</span>
<span className="font-medium text-foreground">Uploading</span>
<span className="shrink-0 tabular-nums text-soft">{completedFiles}/{totalFiles}</span>
</div>
<div className="shrink-0 flex items-center gap-1 text-accent" aria-label={`Upload progress ${progress}%`}>
<span className="text-[10px] tabular-nums text-soft">{progress}%</span>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={phase === 'converting' ? 'animate-spin' : ''}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
cx={size / 2}
cy={size / 2}
@ -573,10 +571,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
if (batches.length === 0) return null;
const totalFiles = batches.reduce((sum, batch) => sum + batch.totalFiles, 0);
const completedFiles = batches.reduce((sum, batch) => sum + batch.completedFiles, 0);
const convertingBatch = batches.find((batch) => batch.phase === 'converting');
const phase: 'uploading' | 'converting' = convertingBatch ? 'converting' : 'uploading';
const currentFileName = convertingBatch?.currentFileName ?? batches.find((batch) => batch.currentFileName)?.currentFileName ?? null;
return { totalFiles, completedFiles, phase, currentFileName };
const currentFileName = batches.find((batch) => batch.currentFileName)?.currentFileName ?? null;
return { totalFiles, completedFiles, phase: 'uploading' as const, currentFileName };
}, [activeUploadBatches]);
const fallbackViewMode: ViewMode = viewMode;

View file

@ -4,7 +4,6 @@ import { useState, useCallback, useId, type ReactNode } from 'react';
import { useDropzone } from 'react-dropzone';
import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { uploadDocxAsPdf } from '@/lib/client/api/documents';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { dropzoneSurfaceClass } from '@/components/ui';
@ -20,7 +19,7 @@ export interface UploadBatchState {
isActive: boolean;
totalFiles: number;
completedFiles: number;
phase: 'uploading' | 'converting';
phase: 'uploading';
currentFileName: string | null;
}
@ -34,10 +33,8 @@ export function DocumentUploader({
const enableDocx = useFeatureFlag('enableDocxConversion');
const {
uploadDocuments,
refreshDocuments,
} = useDocuments();
const [isUploading, setIsUploading] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const emitBatchState = useCallback((state: Omit<UploadBatchState, 'uploaderId'>) => {
@ -61,75 +58,24 @@ export function DocumentUploader({
});
try {
const docxFiles: File[] = [];
const regularFiles: File[] = [];
const uploadableFiles = acceptedFiles.filter((file) =>
file.type === 'application/pdf'
|| file.type === 'application/epub+zip'
|| file.type === 'text/plain'
|| file.type === 'text/markdown'
|| file.name.endsWith('.md')
|| (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
);
for (const file of acceptedFiles) {
if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
docxFiles.push(file);
continue;
}
if (
file.type === 'application/pdf'
|| file.type === 'application/epub+zip'
|| file.type === 'text/plain'
|| file.type === 'text/markdown'
|| file.name.endsWith('.md')
) {
regularFiles.push(file);
}
}
if (regularFiles.length > 0) {
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: regularFiles[0]?.name ?? null,
});
await uploadDocuments(regularFiles);
completedFiles += regularFiles.length;
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: null,
});
}
for (const file of docxFiles) {
// Preserve prior UX: show "Converting DOCX..." state rather than generic uploading.
setIsUploading(false);
setIsConverting(true);
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'converting',
currentFileName: file.name,
});
await uploadDocxAsPdf(file);
await refreshDocuments();
setIsConverting(false);
setIsUploading(true);
completedFiles += 1;
emitBatchState({
isActive: true,
totalFiles,
completedFiles,
phase: 'uploading',
currentFileName: null,
});
if (uploadableFiles.length > 0) {
await uploadDocuments(uploadableFiles);
completedFiles = uploadableFiles.length;
}
} catch (err) {
setError('Failed to upload file. Please try again.');
console.error('Upload error:', err);
} finally {
setIsUploading(false);
setIsConverting(false);
emitBatchState({
isActive: false,
totalFiles,
@ -138,7 +84,7 @@ export function DocumentUploader({
currentFileName: null,
});
}
}, [uploadDocuments, refreshDocuments, enableDocx, emitBatchState]);
}, [uploadDocuments, enableDocx, emitBatchState]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
@ -152,12 +98,12 @@ export function DocumentUploader({
} : {})
},
multiple: true,
disabled: isUploading || isConverting,
disabled: isUploading,
noClick: variant === 'overlay',
noKeyboard: variant === 'overlay'
});
const isDisabled = isUploading || isConverting;
const isDisabled = isUploading;
if (variant === 'overlay') {
const rootProps = getRootProps();
@ -210,8 +156,6 @@ export function DocumentUploader({
<UploadIcon className="w-3.5 h-3.5 text-soft group-hover:text-accent shrink-0 transition-colors duration-base" />
{isUploading ? (
<p className="text-[12px] font-medium truncate flex-1">Uploading</p>
) : isConverting ? (
<p className="text-[12px] font-medium truncate flex-1">Converting DOCX</p>
) : (
<div className="flex items-center gap-2 min-w-0 flex-1">
<p className="text-[12px] truncate flex-1">
@ -226,8 +170,6 @@ export function DocumentUploader({
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-soft" />
{isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
) : isConverting ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Converting DOCX to PDF...</p>
) : (
<>
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">

View file

@ -110,6 +110,26 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
stored.map(async (document, index) => {
const file = files[index];
if (!file) return;
const sourceType = file.name
? (
file.name.toLowerCase().endsWith('.pdf')
? 'pdf'
: file.name.toLowerCase().endsWith('.epub')
? 'epub'
: file.name.toLowerCase().endsWith('.docx')
? 'docx'
: 'html'
)
: (
file.type === 'application/pdf'
? 'pdf'
: file.type === 'application/epub+zip'
? 'epub'
: file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
? 'docx'
: 'html'
);
if (document.type !== sourceType) return;
await cacheStoredDocumentFromBytes(document, await file.arrayBuffer());
}),
);

View file

@ -504,23 +504,3 @@ export async function getDocumentPreviewStatus(
throw new Error(`Failed to load preview status (status ${res.status})`);
}
export async function uploadDocxAsPdf(file: File, options?: { signal?: AbortSignal }): Promise<BaseDocument> {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/documents/docx-to-pdf/upload', {
method: 'POST',
body: form,
signal: options?.signal,
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error || 'Failed to convert DOCX');
}
const data = (await res.json()) as { stored: BaseDocument };
if (!data?.stored) throw new Error('DOCX conversion succeeded but returned no document');
return data.stored;
}

View file

@ -0,0 +1,78 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises';
import path from 'path';
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'url';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
async function ensureTempDir(): Promise<void> {
if (!existsSync(DOCSTORE_DIR)) {
await mkdir(DOCSTORE_DIR, { recursive: true });
}
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR, { recursive: true });
}
}
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
return new Promise((resolve, reject) => {
const args: string[] = [];
if (profileDir) {
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
}
args.push('--headless', '--nologo', '--convert-to', 'pdf', '--outdir', outputDir, inputPath);
const proc = spawn('soffice', args);
proc.on('error', (error) => reject(error));
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`LibreOffice conversion failed with code ${code}`));
});
});
}
async function waitForPdfReady(dir: string, timeoutMs = 20_000, intervalMs = 100): Promise<string> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
const files = await readdir(dir);
const pdf = files.find((f) => f.toLowerCase().endsWith('.pdf'));
if (pdf) {
const pdfPath = path.join(dir, pdf);
try {
const first = await stat(pdfPath);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
const second = await stat(pdfPath);
if (second.size > 0 && second.size === first.size) {
return pdfPath;
}
} catch {
// Ignore transient filesystem races while LibreOffice is still writing.
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
}
export async function convertDocxBufferToPdfBuffer(docxBytes: Buffer): Promise<Buffer> {
await ensureTempDir();
const tempId = randomUUID();
const jobDir = path.join(TEMP_DIR, tempId);
await mkdir(jobDir, { recursive: true });
const profileDir = path.join(jobDir, 'lo-profile');
await mkdir(profileDir, { recursive: true });
const inputPath = path.join(jobDir, 'input.docx');
try {
await writeFile(inputPath, docxBytes);
await convertDocxToPdf(inputPath, jobDir, profileDir);
const pdfPath = await waitForPdfReady(jobDir);
return await readFile(pdfPath);
} finally {
await rm(jobDir, { recursive: true, force: true }).catch(() => {});
}
}

View file

@ -11,7 +11,6 @@ export interface BaseDocument {
parsedJsonKey?: string | null;
scope?: 'user';
folderId?: string;
isConverting?: boolean;
}
export interface PDFDocument extends BaseDocument {

View file

@ -1,6 +1,4 @@
import { Page, expect, type TestInfo, type Locator } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { createHash } from 'crypto';
const DIR = './tests/files/';
@ -10,14 +8,6 @@ function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function fixturePath(fileName: string) {
return path.join(__dirname, 'files', fileName);
}
function sha256HexOfFile(filePath: string) {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
async function waitForPdfViewerReady(page: Page, timeout = 60000) {
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) });
const loader = page.getByTestId('pdf-status-loader').first();
@ -40,7 +30,7 @@ async function waitForPdfViewerReady(page: Page, timeout = 60000) {
}
/**
* Upload a sample epub or pdf
* Upload a sample document fixture
*/
export async function uploadFile(page: Page, filePath: string) {
const input = page.locator('input[type=file]').first();
@ -50,7 +40,7 @@ export async function uploadFile(page: Page, filePath: string) {
await input.setInputFiles(`${DIR}${filePath}`);
// Wait for the uploader to finish processing. The input is disabled while
// uploading/converting via react-dropzone's `disabled` prop.
// uploading via react-dropzone's `disabled` prop.
// Tolerate extremely fast operations where the disabled state may be missed.
try {
await expect(input).toBeDisabled({ timeout: 2000 });
@ -69,14 +59,8 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
const lower = fileName.toLowerCase();
if (lower.endsWith('.docx')) {
// Best-effort: conversion can complete before we observe this UI state.
try {
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible({ timeout: 5000 });
} catch {
// ignore
}
const expectedId = sha256HexOfFile(fixturePath(fileName));
const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
const convertedName = `${fileName.replace(/\.[^.]+$/, '')}.pdf`;
const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(convertedName), 'i') }).first();
await expect(targetLink).toBeVisible({ timeout: 15000 });
await dismissOnboardingModals(page);
await targetLink.click();

View file

@ -0,0 +1,150 @@
import { createHash } from 'node:crypto';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { NextRequest } from 'next/server';
const hoisted = vi.hoisted(() => ({
requireAuthContext: vi.fn(),
registerUploadedDocument: vi.fn(),
convertDocxBufferToPdfBuffer: vi.fn(),
headTempDocumentBlob: vi.fn(),
getTempDocumentBlob: vi.fn(),
getTempDocumentFinalizeReceipt: vi.fn(),
putTempDocumentFinalizeReceipt: vi.fn(),
deleteTempDocumentUpload: vi.fn(),
headDocumentBlob: vi.fn(),
copyTempDocumentBlobToDocument: vi.fn(),
putDocumentBlob: vi.fn(),
}));
vi.mock('@/lib/server/auth/auth', () => ({
requireAuthContext: hoisted.requireAuthContext,
}));
vi.mock('@/lib/server/documents/register-upload', () => ({
registerUploadedDocument: hoisted.registerUploadedDocument,
}));
vi.mock('@/lib/server/documents/docx-convert', () => ({
convertDocxBufferToPdfBuffer: hoisted.convertDocxBufferToPdfBuffer,
}));
vi.mock('@/lib/server/documents/blobstore', () => ({
TEMP_DOCUMENT_UPLOAD_TTL_MS: 24 * 60 * 60 * 1000,
copyTempDocumentBlobToDocument: hoisted.copyTempDocumentBlobToDocument,
deleteTempDocumentUpload: hoisted.deleteTempDocumentUpload,
getTempDocumentBlob: hoisted.getTempDocumentBlob,
getTempDocumentFinalizeReceipt: hoisted.getTempDocumentFinalizeReceipt,
headDocumentBlob: hoisted.headDocumentBlob,
headTempDocumentBlob: hoisted.headTempDocumentBlob,
isMissingBlobError: vi.fn((error: unknown) => {
const maybe = error as { code?: string } | undefined;
return maybe?.code === 'NoSuchKey';
}),
isPreconditionFailed: vi.fn(() => false),
isValidTempUploadToken: vi.fn(() => true),
putDocumentBlob: hoisted.putDocumentBlob,
putTempDocumentFinalizeReceipt: hoisted.putTempDocumentFinalizeReceipt,
}));
vi.mock('@/lib/server/testing/test-namespace', () => ({
getOpenReaderTestNamespace: vi.fn(() => null),
}));
vi.mock('@/lib/server/storage/s3', () => ({
isS3Configured: vi.fn(() => true),
}));
vi.mock('@/lib/server/logger', () => ({
errorToLog: vi.fn((error: unknown) => error),
serverLogger: {
warn: vi.fn(),
error: vi.fn(),
},
}));
describe('POST /api/documents/blob/upload/finalize DOCX flow', () => {
beforeEach(() => {
hoisted.requireAuthContext.mockReset();
hoisted.registerUploadedDocument.mockReset();
hoisted.convertDocxBufferToPdfBuffer.mockReset();
hoisted.headTempDocumentBlob.mockReset();
hoisted.getTempDocumentBlob.mockReset();
hoisted.getTempDocumentFinalizeReceipt.mockReset();
hoisted.putTempDocumentFinalizeReceipt.mockReset();
hoisted.deleteTempDocumentUpload.mockReset();
hoisted.headDocumentBlob.mockReset();
hoisted.copyTempDocumentBlobToDocument.mockReset();
hoisted.putDocumentBlob.mockReset();
hoisted.requireAuthContext.mockResolvedValue({ userId: 'user-1' });
hoisted.getTempDocumentFinalizeReceipt.mockResolvedValue(null);
hoisted.headTempDocumentBlob.mockResolvedValue({
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
contentLength: 12,
lastModified: Date.now(),
});
hoisted.getTempDocumentBlob.mockResolvedValue(Buffer.from('docx-bytes'));
hoisted.convertDocxBufferToPdfBuffer.mockResolvedValue(Buffer.from('pdf-bytes'));
hoisted.headDocumentBlob
.mockRejectedValueOnce({ code: 'NoSuchKey' })
.mockResolvedValue({
contentLength: Buffer.byteLength('pdf-bytes'),
contentType: 'application/pdf',
eTag: 'etag-1',
});
hoisted.registerUploadedDocument.mockImplementation(async (input: { documentId: string; name: string; type: string; size: number; lastModified: number }) => ({
id: input.documentId,
name: input.name,
type: input.type,
size: input.size,
lastModified: input.lastModified,
scope: 'user',
}));
hoisted.putTempDocumentFinalizeReceipt.mockResolvedValue(undefined);
hoisted.deleteTempDocumentUpload.mockResolvedValue(undefined);
});
test('converts raw DOCX during finalize and registers a PDF', async () => {
const { POST } = await import('../../src/app/api/documents/blob/upload/finalize/route');
const request = new NextRequest('http://localhost/api/documents/blob/upload/finalize', {
method: 'POST',
body: JSON.stringify({
uploads: [{
token: '123e4567-e89b-12d3-a456-426614174000',
name: 'Report.docx',
type: 'docx',
lastModified: 1700000000000,
}],
}),
headers: {
'Content-Type': 'application/json',
},
});
const response = await POST(request);
const expectedId = createHash('sha256').update(Buffer.from('pdf-bytes')).digest('hex');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
stored: [{
id: expectedId,
name: 'Report.pdf',
type: 'pdf',
}],
});
expect(hoisted.convertDocxBufferToPdfBuffer).toHaveBeenCalledTimes(1);
expect(hoisted.putDocumentBlob).toHaveBeenCalledWith(
expectedId,
Buffer.from('pdf-bytes'),
'application/pdf',
null,
{ ifNoneMatch: true },
);
expect(hoisted.copyTempDocumentBlobToDocument).not.toHaveBeenCalled();
expect(hoisted.registerUploadedDocument).toHaveBeenCalledWith(expect.objectContaining({
documentId: expectedId,
name: 'Report.pdf',
type: 'pdf',
}));
});
});

View file

@ -100,13 +100,7 @@ test.describe('Document Upload Tests', () => {
test('uploads and converts a DOCX document', async ({ page }) => {
await uploadFile(page, 'sample.docx');
// Should see the converting message (best-effort; conversion may complete extremely fast)
try {
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible({ timeout: 5000 });
} catch {
// ignore
}
// After conversion, should see the PDF with the same name
// DOCX uploads are normalized into stored PDFs with the same basename.
await expectDocumentListed(page, 'sample.pdf');
});