feat(docs): also add docs on changing admin password

This commit is contained in:
perfectra1n 2025-12-09 17:02:33 -08:00
parent e0b126427b
commit 7623892324
3 changed files with 523 additions and 407 deletions

View File

@ -5,6 +5,7 @@
Readur includes several Rust-based command-line utilities for system administration and maintenance. These are compiled binaries (not Python scripts) designed for system administrators and DevOps teams managing Readur deployments.
**Available CLI Tools:**
- `readur reset-admin-password` - Reset the admin user's password
- `migrate_to_s3` - Migrate documents between storage backends
- `batch_ingest` - Bulk import documents
- `debug_pdf_extraction` - Debug PDF processing issues
@ -12,6 +13,109 @@ Readur includes several Rust-based command-line utilities for system administrat
- `test_metadata` - Test metadata extraction
- `test_runner` - Run test suites
## reset-admin-password
**Purpose:** Reset the admin user's password when locked out or for security rotation
This is a subcommand of the main `readur` binary, not a separate tool.
### Usage
```bash
readur reset-admin-password
```
### Help Output
```
Reset the admin user's password
Usage: readur reset-admin-password
Options:
-h, --help Print help
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `ADMIN_USERNAME` | Username of the admin account to reset | `admin` |
| `ADMIN_PASSWORD` | New password to set (min 8 characters) | Auto-generated 24-char secure password |
### Examples
#### Docker Deployments
```bash
# Reset with auto-generated password (recommended)
docker exec readur-app readur reset-admin-password
# Reset with custom password
docker exec -e ADMIN_PASSWORD="your-secure-password" readur-app readur reset-admin-password
# Reset a different admin user
docker exec -e ADMIN_USERNAME="custom-admin" readur-app readur reset-admin-password
# Reset with both custom username and password
docker exec -e ADMIN_USERNAME="superadmin" -e ADMIN_PASSWORD="new-secure-pass" \
readur-app readur reset-admin-password
```
#### Direct/Binary Deployments
```bash
# Auto-generated password
readur reset-admin-password
# Custom password via environment variable
ADMIN_PASSWORD="new-secure-password" readur reset-admin-password
# Custom admin username
ADMIN_USERNAME="admin2" ADMIN_PASSWORD="secure123!" readur reset-admin-password
```
#### Kubernetes Deployments
```bash
# Find the pod
kubectl get pods -l app=readur
# Reset with auto-generated password
kubectl exec deployment/readur -- readur reset-admin-password
# Reset with custom password
kubectl exec deployment/readur -- env ADMIN_PASSWORD="secure-pass" readur reset-admin-password
```
### Example Output
```
🔑 RESET ADMIN PASSWORD
============================================================
==============================================
ADMIN PASSWORD RESET SUCCESSFUL
==============================================
Username: admin
Password: xK9mP2nQ7rT4wY6zA3cF8gH1
⚠️ SAVE THESE CREDENTIALS IMMEDIATELY!
⚠️ This password will not be shown again.
==============================================
```
### Security Notes
- The generated password is cryptographically secure (24 characters)
- Passwords are never logged or stored in plain text
- Always change auto-generated passwords after first login
- Use environment variables, never pass passwords as command-line arguments
- Requires database connectivity to function
### When to Use
- Forgot admin password
- Security incident requiring credential rotation
- Initial setup after database restoration
- Periodic security compliance password rotation
## migrate_to_s3
**Purpose:** Migrate document storage between backends (Local ↔ S3)

View File

@ -334,460 +334,287 @@ test.describe('OCR Multiple Languages', () => {
}
});
test('should validate OCR results contain expected language-specific content', async ({ dynamicAdminPage: page }) => {
await page.goto('/documents');
// Skip: Document detail view doesn't have .document-content/.ocr-text selectors yet
test.skip('should validate OCR results contain expected language-specific content', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the document detail view doesn't have the expected
// .document-content or .ocr-text selectors. To enable this test:
// 1. Add a document content area with data-testid="document-content"
// 2. Display the OCR extracted text in that area
// Setup: Upload a test document via API
const docId = await helpers.uploadDocumentViaAPI(TEST_FILES.englishTest);
// Wait for OCR to complete
const doc = await helpers.waitForOCRComplete(docId);
expect(doc.ocr_status).toBe('completed');
// Navigate to the document view
await page.goto(`/documents/${docId}`);
await helpers.waitForLoadingToComplete();
// Look for uploaded documents
const documentItems = page.locator('.document-item, .document-card, [data-testid="document-item"]');
const documentCount = await documentItems.count();
// Assert: Document content area should be visible
const contentArea = page.locator('.document-content, .ocr-text, [data-testid="document-content"], .MuiTypography-body1').first();
await expect(contentArea).toBeVisible({ timeout: TIMEOUTS.medium });
if (documentCount > 0) {
// Click on first document to view details
await documentItems.first().click();
await helpers.waitForLoadingToComplete();
// Look for document content or OCR text
const contentArea = page.locator('.document-content, .ocr-text, [data-testid="document-content"]').first();
if (await contentArea.isVisible({ timeout: TIMEOUTS.medium })) {
// Get the content text
const contentText = await contentArea.textContent();
expect(contentText).toBeTruthy();
if (contentText) {
// Check for Spanish keywords
const hasSpanishContent = EXPECTED_CONTENT.spanish.keywords.some(keyword =>
contentText.toLowerCase().includes(keyword.toLowerCase())
);
// Check for English keywords
// Assert: Check for English keywords in OCR content
const hasEnglishContent = EXPECTED_CONTENT.english.keywords.some(keyword =>
contentText.toLowerCase().includes(keyword.toLowerCase())
contentText!.toLowerCase().includes(keyword.toLowerCase())
);
expect(hasEnglishContent).toBe(true);
if (hasSpanishContent) {
console.log('✅ Spanish OCR content detected');
}
if (hasEnglishContent) {
console.log('✅ English OCR content detected');
}
console.log(`📄 Document content preview: ${contentText.substring(0, 100)}...`);
}
}
} else {
console.log(' No documents found for content validation');
}
// Cleanup
await helpers.deleteDocumentViaAPI(docId);
});
test('should retry failed OCR with different language', async ({ dynamicAdminPage: page }) => {
// Skip: OCR retry UI with language selection is not currently implemented
test.skip('should retry failed OCR with different language', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the retry OCR with language selection feature
// is not currently implemented in the UI. The application does not have a
// visible "Retry" button with language selection options.
//
// To enable this test, implement the following:
// 1. Add a "Retry OCR" button on failed documents
// 2. Show a dialog with language selection options
// 3. Allow users to retry OCR with a different language
await page.goto('/documents');
await helpers.waitForLoadingToComplete();
// Look for failed documents or retry options
const retryButton = page.locator('button:has-text("Retry"), [data-testid="retry-ocr"]').first();
if (await retryButton.isVisible()) {
// Look for language selection in retry dialog
await retryButton.click();
// Check if retry dialog opens with language options
const retryDialog = page.locator('.retry-dialog, [role="dialog"], .modal').first();
if (await retryDialog.isVisible({ timeout: 5000 })) {
// Look for language selector in retry dialog
const retryLanguageSelector = page.locator('select, [role="combobox"]').first();
if (await retryLanguageSelector.isVisible()) {
// Change language for retry
await retryLanguageSelector.click();
const spanishRetryOption = page.locator('[data-value="spa"], option[value="spa"]').first();
if (await spanishRetryOption.isVisible()) {
await spanishRetryOption.click();
// Confirm retry with new language
const confirmRetryButton = page.locator('button:has-text("Retry"), button:has-text("Confirm")').last();
if (await confirmRetryButton.isVisible()) {
const retryPromise = helpers.waitForApiCall('/retry', TIMEOUTS.ocr);
await confirmRetryButton.click();
try {
await retryPromise;
console.log('✅ OCR retry with different language initiated');
} catch (error) {
console.log(' Retry may have failed or timed out');
}
}
}
}
}
} else {
console.log(' No failed documents found for retry testing');
}
await expect(retryButton).toBeVisible({ timeout: TIMEOUTS.medium });
});
test('should handle mixed language document', async ({ dynamicAdminPage: page }) => {
// Upload mixed language document
await page.goto('/upload');
// Skip: Document detail view doesn't have .document-content/.ocr-text selectors yet
test.skip('should handle mixed language document', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the document detail view doesn't have the expected
// .document-content or .ocr-text selectors for content validation.
// Setup: Upload mixed language document via API
const docId = await helpers.uploadDocumentViaAPI(TEST_FILES.mixedLanguageTest);
// Wait for OCR to complete
const doc = await helpers.waitForOCRComplete(docId);
// Assert: OCR processing completed (either success or failure is acceptable,
// but it must have been processed)
expect(['completed', 'success', 'failed', 'error']).toContain(doc.ocr_status);
// Navigate to the document
await page.goto(`/documents/${docId}`);
await helpers.waitForLoadingToComplete();
const fileInput = page.locator('input[type="file"]').first();
// Assert: Document page should load
const documentTitle = page.locator('h1, h2, .document-title, [data-testid="document-title"]').first();
await expect(documentTitle).toBeVisible({ timeout: TIMEOUTS.medium });
try {
await fileInput.setInputFiles(MULTILINGUAL_TEST_FILES.mixed);
// If OCR completed successfully, verify content detection
if (doc.ocr_status === 'completed' || doc.ocr_status === 'success') {
const contentArea = page.locator('.document-content, .ocr-text, [data-testid="document-content"], .MuiTypography-body1').first();
await expect(page.getByText('mixed_language_test.pdf')).toBeVisible({ timeout: 5000 });
// Content area should be visible for successfully processed documents
await expect(contentArea).toBeVisible({ timeout: TIMEOUTS.medium });
const uploadButton = page.locator('button:has-text("Upload")').first();
if (await uploadButton.isVisible()) {
const uploadPromise = helpers.waitForApiCall('/api/documents', TIMEOUTS.upload);
await uploadButton.click();
await uploadPromise;
// Wait for OCR processing
await page.waitForTimeout(5000);
// Navigate to documents and check content
await page.goto('/documents');
await helpers.waitForLoadingToComplete();
// Look for the mixed document
const mixedDocument = page.locator('text="mixed_language_test.pdf"').first();
if (await mixedDocument.isVisible()) {
await mixedDocument.click();
const contentArea = page.locator('.document-content, .ocr-text').first();
if (await contentArea.isVisible({ timeout: TIMEOUTS.medium })) {
const content = await contentArea.textContent();
expect(content).toBeTruthy();
if (content) {
// Assert: Should detect content from at least one language
const hasSpanish = EXPECTED_CONTENT.mixed.spanish.some(word =>
content.toLowerCase().includes(word.toLowerCase())
content!.toLowerCase().includes(word.toLowerCase())
);
const hasEnglish = EXPECTED_CONTENT.mixed.english.some(word =>
content.toLowerCase().includes(word.toLowerCase())
content!.toLowerCase().includes(word.toLowerCase())
);
if (hasSpanish && hasEnglish) {
console.log('✅ Mixed language document processed successfully');
}
}
}
}
}
} catch (error) {
console.log(' Mixed language test file not found, skipping test');
// At least one language should be detected
expect(hasSpanish || hasEnglish).toBe(true);
}
// Cleanup
await helpers.deleteDocumentViaAPI(docId);
});
test('should persist language preference across sessions', async ({ dynamicAdminPage: page }) => {
// Set language to Spanish
// Skip: Settings page doesn't display language tags as expected
test.skip('should persist language preference across sessions', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the settings page doesn't show language selection
// as distinct "Spanish" tags that can be located. To enable this test:
// 1. Add language tags with data-testid="language-tag" containing language names
// Setup: Set language preference via API
await helpers.updateSettingsViaAPI({ ocr_languages: ['spa'] });
// Navigate to settings page
await page.goto('/settings');
await helpers.waitForLoadingToComplete();
const selectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
if (await selectButton.isVisible()) {
await selectButton.click();
await page.waitForTimeout(500);
// Select Spanish option
const spanishOption = page.locator('button:has(~ div:has-text("Spanish"))').first();
if (await spanishOption.isVisible()) {
await spanishOption.click();
await page.waitForTimeout(500);
// Close dropdown and save
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
const saveButton = page.locator('button:has-text("Save")').first();
if (await saveButton.isVisible()) {
await saveButton.click();
await helpers.waitForToast();
}
}
}
// Assert: Spanish language tag should be visible
const spanishTag = page.locator('span:has-text("Spanish"), [data-testid="language-tag"]:has-text("Spanish")').first();
await expect(spanishTag).toBeVisible({ timeout: TIMEOUTS.medium });
// Reload page to simulate new session
await page.reload();
await helpers.waitForLoadingToComplete();
// Check if Spanish is still selected by looking for the language tag
const spanishTag = page.locator('span:has-text("Spanish")').first();
if (await spanishTag.isVisible({ timeout: 5000 })) {
console.log('✅ Language preference persisted across reload');
} else {
console.log(' Could not verify language persistence');
}
// Assert: Spanish language tag should still be visible after reload
const spanishTagAfterReload = page.locator('span:has-text("Spanish"), [data-testid="language-tag"]:has-text("Spanish")').first();
await expect(spanishTagAfterReload).toBeVisible({ timeout: TIMEOUTS.medium });
// Cleanup: Reset to default language (English)
await helpers.updateSettingsViaAPI({ ocr_languages: ['eng'] });
});
test('should display available languages from API', async ({ dynamicAdminPage: page }) => {
// Navigate to settings and check API call for languages
const languagesPromise = helpers.waitForApiCall('/api/ocr/languages', TIMEOUTS.medium);
// Skip: /api/ocr/languages endpoint is not implemented
test.skip('should display available languages from API', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the /api/ocr/languages endpoint returns 404.
// To enable this test:
// 1. Implement GET /api/ocr/languages endpoint
// 2. Return an array of available language objects
// First, verify the API endpoint works
const languages = await helpers.getOCRLanguagesViaAPI();
// Assert: API should return an array of languages
expect(Array.isArray(languages)).toBe(true);
expect(languages.length).toBeGreaterThan(0);
// Navigate to settings page
await page.goto('/settings');
await helpers.waitForLoadingToComplete();
try {
const languagesResponse = await languagesPromise;
console.log('✅ OCR languages API called successfully');
// Check if language selector shows loading then options
// Assert: Language selector button should be visible
const selectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
if (await selectButton.isVisible()) {
// Click to see available options
await expect(selectButton).toBeVisible({ timeout: TIMEOUTS.medium });
// Click to open the language dropdown
await selectButton.click();
await page.waitForTimeout(1000);
// Count available language options in the dropdown
const languageOptions = page.locator('div:has-text("Spanish"), div:has-text("English"), div:has-text("French")');
const optionCount = await languageOptions.count();
// Assert: Dropdown should show "Available Languages" section
const availableLanguagesSection = page.locator('text="Available Languages"').first();
await expect(availableLanguagesSection).toBeVisible({ timeout: TIMEOUTS.short });
if (optionCount > 0) {
console.log(`✅ Found ${optionCount} language options in selector`);
}
// Assert: At least English should be visible as an option
const englishOption = page.locator('div:has-text("English")').first();
await expect(englishOption).toBeVisible({ timeout: TIMEOUTS.short });
// Close dropdown
await page.keyboard.press('Escape');
}
} catch (error) {
console.log(' Could not capture languages API call');
}
});
test('should handle bulk operations with multiple languages', async ({ dynamicAdminPage: page }) => {
// Skip: Document checkboxes for bulk selection are not implemented
test.skip('should handle bulk operations with multiple languages', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the documents page doesn't have selection checkboxes.
// To enable this test:
// 1. Add checkboxes with data-testid="document-checkbox" to each document
// 2. Implement bulk selection functionality
// Setup: Upload 2 documents via API
const docId1 = await helpers.uploadDocumentViaAPI(TEST_FILES.englishTest);
const docId2 = await helpers.uploadDocumentViaAPI(TEST_FILES.spanishTest);
// Wait for both documents to process
await helpers.waitForOCRComplete(docId1);
await helpers.waitForOCRComplete(docId2);
// Navigate to documents page
await page.goto('/documents');
await helpers.waitForLoadingToComplete();
// Look for documents and select multiple
const documentCheckboxes = page.locator('.document-item input[type="checkbox"], [data-testid="document-checkbox"]');
// Assert: Document checkboxes should be visible for selection
const documentCheckboxes = page.locator('.document-item input[type="checkbox"], [data-testid="document-checkbox"], input[type="checkbox"]');
const checkboxCount = await documentCheckboxes.count();
if (checkboxCount > 1) {
// Assert: At least 2 checkboxes should be available
expect(checkboxCount).toBeGreaterThanOrEqual(2);
// Select first two documents
await documentCheckboxes.nth(0).click();
await documentCheckboxes.nth(1).click();
// Look for bulk action menu
const bulkActionsMenu = page.locator('[data-testid="bulk-actions"], .bulk-actions, button:has-text("Bulk")').first();
// Assert: Selection should be reflected (either via checked state or selection counter)
const firstCheckbox = documentCheckboxes.nth(0);
await expect(firstCheckbox).toBeChecked();
if (await bulkActionsMenu.isVisible()) {
await bulkActionsMenu.click();
const secondCheckbox = documentCheckboxes.nth(1);
await expect(secondCheckbox).toBeChecked();
// Look for language-specific bulk operations
const bulkRetryWithLanguage = page.locator('button:has-text("Retry with Language"), .bulk-retry-language').first();
if (await bulkRetryWithLanguage.isVisible()) {
await bulkRetryWithLanguage.click();
// Check for language selection in bulk retry
const bulkLanguageSelector = page.locator('select, [role="combobox"]').first();
if (await bulkLanguageSelector.isVisible()) {
await bulkLanguageSelector.click();
const spanishBulkOption = page.locator('[data-value="spa"], option[value="spa"]').first();
if (await spanishBulkOption.isVisible()) {
await spanishBulkOption.click();
const confirmBulkButton = page.locator('button:has-text("Confirm"), button:has-text("Apply")').first();
if (await confirmBulkButton.isVisible()) {
const bulkRetryPromise = helpers.waitForApiCall('/bulk-retry', TIMEOUTS.ocr);
await confirmBulkButton.click();
try {
await bulkRetryPromise;
console.log('✅ Bulk retry with Spanish language initiated');
} catch (error) {
console.log(' Bulk retry may have failed or not available');
}
}
}
}
}
}
} else {
console.log(' Not enough documents for bulk operations test');
}
// Cleanup
await helpers.deleteDocumentViaAPI(docId1);
await helpers.deleteDocumentViaAPI(docId2);
});
test('should handle OCR language errors gracefully', async ({ dynamicAdminPage: page }) => {
// Skip: Settings page OCR Languages section has different selectors than expected
test.skip('should handle OCR language errors gracefully', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the settings page doesn't have the expected
// "OCR Languages" text label. To enable this test:
// 1. Add an "OCR Languages" label/heading to the settings page
// 2. Or add data-testid="ocr-languages-section" to the relevant section
await page.goto('/settings');
await helpers.waitForLoadingToComplete();
// Look for language selector component
const languageSelector = page.locator('label:has-text("OCR Languages")').first();
// Assert: The settings page should load without crashing
// Look for language selector component or OCR Languages section
const ocrSection = page.locator('text="OCR Languages", label:has-text("OCR Languages")').first();
await expect(ocrSection).toBeVisible({ timeout: TIMEOUTS.medium });
// Check for error handling in language selector
const errorAlert = page.locator('[role="alert"], .error, .alert-warning').first();
const retryButton = page.locator('button:has-text("Retry"), .retry').first();
// Assert: Either the language selector loads successfully OR an error state with retry is shown
const languageSelectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
const errorAlert = page.locator('[role="alert"]:has-text("error"), .MuiAlert-standardError').first();
if (await errorAlert.isVisible()) {
console.log('⚠️ Language selector showing error state');
// At least one of these should be visible - either working selector or error state
const selectorVisible = await languageSelectButton.isVisible({ timeout: 5000 }).catch(() => false);
const errorVisible = await errorAlert.isVisible({ timeout: 1000 }).catch(() => false);
if (await retryButton.isVisible()) {
await retryButton.click();
console.log('✅ Error retry mechanism available');
}
} else if (await languageSelector.isVisible()) {
console.log('✅ Language selector loaded without errors');
}
// Assert: One of the states must be present (not a blank page)
expect(selectorVisible || errorVisible).toBe(true);
// Check for fallback behavior
const englishFallback = page.locator('text="English (Fallback)"').first();
if (await englishFallback.isVisible()) {
console.log('✅ Fallback language option available');
// If there's an error, verify a retry mechanism exists
if (errorVisible) {
const retryButton = page.locator('button:has-text("Retry"), button:has-text("Try again")').first();
await expect(retryButton).toBeVisible({ timeout: TIMEOUTS.short });
}
});
test('should upload document with multiple languages selected', async ({ dynamicAdminPage: page }) => {
// First set multiple languages in settings
await page.goto('/settings');
// Setup: Set multiple languages via API
await helpers.updateSettingsViaAPI({ ocr_languages: ['eng', 'spa'] });
// Upload document via API (most reliable method)
const docId = await helpers.uploadDocumentViaAPI(TEST_FILES.mixedLanguageTest);
// Wait for OCR to complete
const doc = await helpers.waitForOCRComplete(docId);
// Assert: Document should have been processed
expect(['completed', 'success', 'failed', 'error']).toContain(doc.ocr_status);
// Navigate to verify the document was uploaded
await page.goto(`/documents/${docId}`);
await helpers.waitForLoadingToComplete();
const selectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
if (await selectButton.isVisible()) {
await selectButton.click();
await page.waitForTimeout(500);
// Assert: Document page should load successfully
const documentPage = page.locator('h1, h2, .document-title, [data-testid="document-title"], .MuiTypography-h4, .MuiTypography-h5').first();
await expect(documentPage).toBeVisible({ timeout: TIMEOUTS.medium });
// Select English and Spanish using the correct button structure
const englishOption = page.locator('button:has(~ div:has-text("English"))').first();
if (await englishOption.isVisible()) {
await englishOption.click();
await page.waitForTimeout(500);
}
// Cleanup
await helpers.deleteDocumentViaAPI(docId);
const spanishOption = page.locator('button:has(~ div:has-text("Spanish"))').first();
if (await spanishOption.isVisible()) {
await spanishOption.click();
await page.waitForTimeout(500);
}
// Close dropdown by clicking outside or pressing escape
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
const saveButton = page.locator('button:has-text("Save")').first();
if (await saveButton.isVisible()) {
await saveButton.click();
await helpers.waitForToast();
}
}
// Navigate to upload page
await page.goto('/upload');
await helpers.waitForLoadingToComplete();
// Check if the upload form includes multi-language selector
const uploadLanguageSelector = page.locator('label:has-text("OCR Languages")').first();
if (await uploadLanguageSelector.isVisible()) {
console.log('✅ Multi-language selector available in upload form');
// Click to view language options
const uploadSelectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
if (await uploadSelectButton.isVisible()) {
await uploadSelectButton.click();
await page.waitForTimeout(500);
// Verify languages are selectable for upload
const uploadDropdown = page.locator('text="Available Languages"').first();
if (await uploadDropdown.isVisible()) {
console.log('✅ Language options available for upload');
}
// Close the dropdown
const uploadCloseButton = page.locator('button:has-text("Close")').first();
if (await uploadCloseButton.isVisible()) {
await uploadCloseButton.click();
}
}
}
// Upload a test file
const fileInput = page.locator('input[type="file"]').first();
if (await fileInput.isVisible({ timeout: 10000 })) {
try {
await fileInput.setInputFiles(MULTILINGUAL_TEST_FILES.mixed);
// Verify file appears in upload list
await expect(page.getByText('mixed_language_test.pdf')).toBeVisible({ timeout: 5000 });
// Click upload button
const uploadButton = page.locator('button:has-text("Upload")').first();
if (await uploadButton.isVisible()) {
const uploadPromise = helpers.waitForApiCall('/api/documents', TIMEOUTS.upload);
await uploadButton.click();
await uploadPromise;
console.log('✅ Multi-language document uploaded successfully');
}
} catch (error) {
console.log(' Mixed language test file not found, skipping upload test');
}
}
// Reset settings
await helpers.updateSettingsViaAPI({ ocr_languages: ['eng'] });
});
test('should retry failed OCR with multiple languages', async ({ dynamicAdminPage: page }) => {
// Skip: OCR retry UI with multiple language selection is not currently implemented
test.skip('should retry failed OCR with multiple languages', async ({ dynamicAdminPage: page }) => {
// This test is skipped because the retry OCR with multiple language selection feature
// is not currently implemented in the UI. The application does not have a
// visible "Retry" button with multi-language selection options.
//
// To enable this test, implement the following:
// 1. Add a "Retry OCR" button on failed documents
// 2. Show a dialog with "Multiple Languages" toggle
// 3. Allow users to select multiple languages for retry
// 4. Process OCR with the selected languages
await page.goto('/documents');
await helpers.waitForLoadingToComplete();
// Look for retry button on any document
const retryButton = page.locator('button:has-text("Retry"), [data-testid="retry-ocr"]').first();
if (await retryButton.isVisible()) {
await retryButton.click();
// Check if retry dialog opens with multi-language options
const retryDialog = page.locator('[role="dialog"], .modal').first();
if (await retryDialog.isVisible({ timeout: 5000 })) {
// Look for multi-language toggle buttons
const multiLanguageButton = page.locator('button:has-text("Multiple Languages")').first();
if (await multiLanguageButton.isVisible()) {
await multiLanguageButton.click();
console.log('✅ Multi-language mode activated in retry dialog');
// Look for language selector in retry dialog
const retryLanguageSelector = page.locator('label:has-text("OCR Languages")').first();
if (await retryLanguageSelector.isVisible()) {
const retrySelectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first();
if (await retrySelectButton.isVisible()) {
await retrySelectButton.click();
// Select multiple languages for retry
const retryEnglishOption = page.locator('button:has(~ div:has-text("English"))').first();
if (await retryEnglishOption.isVisible()) {
await retryEnglishOption.click();
await page.waitForTimeout(500);
}
const retrySpanishOption = page.locator('button:has(~ div:has-text("Spanish"))').first();
if (await retrySpanishOption.isVisible()) {
await retrySpanishOption.click();
await page.waitForTimeout(500);
}
// Close language selector
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
}
// Confirm retry with multiple languages
const confirmRetryButton = page.locator('button:has-text("Retry OCR")').first();
if (await confirmRetryButton.isVisible()) {
const retryPromise = helpers.waitForApiCall('/retry', TIMEOUTS.ocr);
await confirmRetryButton.click();
try {
await retryPromise;
console.log('✅ OCR retry with multiple languages initiated');
} catch (error) {
console.log(' Multi-language retry may have failed or timed out');
}
}
}
}
} else {
console.log(' No retry buttons found for multi-language retry testing');
}
await expect(retryButton).toBeVisible({ timeout: TIMEOUTS.medium });
});
});

View File

@ -1,9 +1,194 @@
import { Page, expect } from '@playwright/test';
import { TEST_FILES } from './test-data';
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
// ES Module compatibility for __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export class TestHelpers {
constructor(private page: Page) {}
/**
* Get auth token from localStorage (must be logged in via UI first)
*/
async getAuthToken(): Promise<string> {
const token = await this.page.evaluate(() => localStorage.getItem('token'));
if (!token) {
throw new Error('No auth token found in localStorage. Ensure user is logged in.');
}
return token;
}
/**
* Upload a document via API (faster and more reliable than UI)
* Returns the document ID
*/
async uploadDocumentViaAPI(filePath: string): Promise<string> {
const token = await this.getAuthToken();
// Resolve the file path relative to the frontend directory (two levels up from e2e/utils/)
const absolutePath = path.resolve(__dirname, '../..', filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Test file not found: ${absolutePath}`);
}
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
const response = await this.page.request.post('/api/documents', {
headers: {
'Authorization': `Bearer ${token}`,
},
multipart: {
file: {
name: fileName,
mimeType: this.getMimeType(fileName),
buffer: fileBuffer,
}
},
timeout: 60000
});
if (!response.ok()) {
const errorText = await response.text();
throw new Error(`Failed to upload document via API: ${response.status()} - ${errorText}`);
}
const result = await response.json();
console.log(`✅ Uploaded document via API: ${fileName} (ID: ${result.id || result.document_id})`);
return result.id || result.document_id;
}
/**
* Get document details via API
*/
async getDocumentViaAPI(documentId: string): Promise<any> {
const token = await this.getAuthToken();
const response = await this.page.request.get(`/api/documents/${documentId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
timeout: 10000
});
if (!response.ok()) {
throw new Error(`Failed to get document: ${response.status()}`);
}
return response.json();
}
/**
* Wait for OCR processing to complete on a document
*/
async waitForOCRComplete(documentId: string, timeoutMs: number = 120000): Promise<any> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const doc = await this.getDocumentViaAPI(documentId);
if (doc.ocr_status === 'completed' || doc.ocr_status === 'success') {
console.log(`✅ OCR completed for document ${documentId}`);
return doc;
}
if (doc.ocr_status === 'failed' || doc.ocr_status === 'error') {
console.log(`❌ OCR failed for document ${documentId}`);
return doc;
}
// Wait before checking again
await this.page.waitForTimeout(2000);
}
throw new Error(`OCR did not complete within ${timeoutMs}ms for document ${documentId}`);
}
/**
* Delete a document via API
*/
async deleteDocumentViaAPI(documentId: string): Promise<void> {
const token = await this.getAuthToken();
const response = await this.page.request.delete(`/api/documents/${documentId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
timeout: 10000
});
if (!response.ok()) {
console.warn(`Failed to delete document ${documentId}: ${response.status()}`);
} else {
console.log(`🗑️ Deleted document ${documentId}`);
}
}
/**
* Get OCR languages via API
*/
async getOCRLanguagesViaAPI(): Promise<any[]> {
const token = await this.getAuthToken();
const response = await this.page.request.get('/api/ocr/languages', {
headers: {
'Authorization': `Bearer ${token}`,
},
timeout: 10000
});
if (!response.ok()) {
throw new Error(`Failed to get OCR languages: ${response.status()}`);
}
return response.json();
}
/**
* Update settings via API
*/
async updateSettingsViaAPI(settings: Record<string, any>): Promise<void> {
const token = await this.getAuthToken();
const response = await this.page.request.put('/api/settings', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: settings,
timeout: 10000
});
if (!response.ok()) {
throw new Error(`Failed to update settings: ${response.status()}`);
}
console.log('✅ Settings updated via API');
}
/**
* Get MIME type for a file
*/
private getMimeType(fileName: string): string {
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.txt': 'text/plain',
};
return mimeTypes[ext] || 'application/octet-stream';
}
async waitForApiCall(urlPattern: string | RegExp, timeout = 10000) {
return this.page.waitForResponse(resp =>
typeof urlPattern === 'string'