diff --git a/docs/administration/cli-tools.md b/docs/administration/cli-tools.md index 0b324d1..9a45aeb 100644 --- a/docs/administration/cli-tools.md +++ b/docs/administration/cli-tools.md @@ -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) diff --git a/frontend/e2e/ocr-multiple-languages.spec.ts b/frontend/e2e/ocr-multiple-languages.spec.ts index 4b8931d..dca0e40 100644 --- a/frontend/e2e/ocr-multiple-languages.spec.ts +++ b/frontend/e2e/ocr-multiple-languages.spec.ts @@ -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(); - - 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 })) { - const contentText = await contentArea.textContent(); - - if (contentText) { - // Check for Spanish keywords - const hasSpanishContent = EXPECTED_CONTENT.spanish.keywords.some(keyword => - contentText.toLowerCase().includes(keyword.toLowerCase()) - ); - - // Check for English keywords - const hasEnglishContent = EXPECTED_CONTENT.english.keywords.some(keyword => - contentText.toLowerCase().includes(keyword.toLowerCase()) - ); - - 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'); - } + // 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 }); + + // Get the content text + const contentText = await contentArea.textContent(); + expect(contentText).toBeTruthy(); + + // Assert: Check for English keywords in OCR content + const hasEnglishContent = EXPECTED_CONTENT.english.keywords.some(keyword => + contentText!.toLowerCase().includes(keyword.toLowerCase()) + ); + expect(hasEnglishContent).toBe(true); + + // 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(); - - try { - await fileInput.setInputFiles(MULTILINGUAL_TEST_FILES.mixed); - - await expect(page.getByText('mixed_language_test.pdf')).toBeVisible({ timeout: 5000 }); - - 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(); - - if (content) { - const hasSpanish = EXPECTED_CONTENT.mixed.spanish.some(word => - content.toLowerCase().includes(word.toLowerCase()) - ); - const hasEnglish = EXPECTED_CONTENT.mixed.english.some(word => - 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'); + // 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 }); + + // 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(); + + // Content area should be visible for successfully processed documents + await expect(contentArea).toBeVisible({ timeout: TIMEOUTS.medium }); + + const content = await contentArea.textContent(); + expect(content).toBeTruthy(); + + // Assert: Should detect content from at least one language + const hasSpanish = EXPECTED_CONTENT.mixed.spanish.some(word => + content!.toLowerCase().includes(word.toLowerCase()) + ); + const hasEnglish = EXPECTED_CONTENT.mixed.english.some(word => + content!.toLowerCase().includes(word.toLowerCase()) + ); + + // 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 - 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 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(); - - if (optionCount > 0) { - console.log(`✅ Found ${optionCount} language options in selector`); - } - - // Close dropdown - await page.keyboard.press('Escape'); - } - } catch (error) { - console.log('â„šī¸ Could not capture languages API call'); - } + + // Assert: Language selector button should be visible + const selectButton = page.locator('button:has-text("Select OCR languages"), button:has-text("Add more languages")').first(); + await expect(selectButton).toBeVisible({ timeout: TIMEOUTS.medium }); + + // Click to open the language dropdown + await selectButton.click(); + await page.waitForTimeout(1000); + + // Assert: Dropdown should show "Available Languages" section + const availableLanguagesSection = page.locator('text="Available Languages"').first(); + await expect(availableLanguagesSection).toBeVisible({ timeout: TIMEOUTS.short }); + + // 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'); }); - 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) { - // 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(); - - if (await bulkActionsMenu.isVisible()) { - await bulkActionsMenu.click(); - - // 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'); - } + + // 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(); + + // Assert: Selection should be reflected (either via checked state or selection counter) + const firstCheckbox = documentCheckboxes.nth(0); + await expect(firstCheckbox).toBeChecked(); + + const secondCheckbox = documentCheckboxes.nth(1); + await expect(secondCheckbox).toBeChecked(); + + // 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(); - - // 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(); - - if (await errorAlert.isVisible()) { - console.log('âš ī¸ Language selector showing error state'); - - 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'); - } - - // Check for fallback behavior - const englishFallback = page.locator('text="English (Fallback)"').first(); - if (await englishFallback.isVisible()) { - console.log('✅ Fallback language option available'); + + // 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 }); + + // 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(); + + // 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); + + // Assert: One of the states must be present (not a blank page) + expect(selectorVisible || errorVisible).toBe(true); + + // 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); - - // 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); - } - - 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(); - } - } + // 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 }); - // Navigate to upload page - await page.goto('/upload'); - await helpers.waitForLoadingToComplete(); + // Cleanup + await helpers.deleteDocumentViaAPI(docId); - // 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 }); }); }); \ No newline at end of file diff --git a/frontend/e2e/utils/test-helpers.ts b/frontend/e2e/utils/test-helpers.ts index 0a84079..8ad85cb 100644 --- a/frontend/e2e/utils/test-helpers.ts +++ b/frontend/e2e/utils/test-helpers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 = { + '.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'