feat(tests): deduplicate tests too
This commit is contained in:
parent
a6f3cae96c
commit
14b29872e8
|
|
@ -1,53 +1,124 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Centralized test credentials to eliminate duplication
|
||||
export const TEST_CREDENTIALS = {
|
||||
admin: {
|
||||
username: 'admin',
|
||||
password: 'readur2024'
|
||||
},
|
||||
user: {
|
||||
username: 'user',
|
||||
password: 'userpass123'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const TIMEOUTS = {
|
||||
login: 10000,
|
||||
navigation: 10000,
|
||||
api: 5000
|
||||
} as const;
|
||||
|
||||
export interface AuthFixture {
|
||||
authenticatedPage: Page;
|
||||
adminPage: Page;
|
||||
userPage: Page;
|
||||
}
|
||||
|
||||
// Shared authentication helper functions
|
||||
export class AuthHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async loginAs(credentials: typeof TEST_CREDENTIALS.admin | typeof TEST_CREDENTIALS.user) {
|
||||
console.log(`Attempting to login as ${credentials.username}...`);
|
||||
|
||||
// Go to home page
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if already logged in
|
||||
const usernameInput = await this.page.locator('input[name="username"]').isVisible().catch(() => false);
|
||||
|
||||
if (!usernameInput) {
|
||||
console.log('Already logged in or no login form found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill login form
|
||||
await this.page.fill('input[name="username"]', credentials.username);
|
||||
await this.page.fill('input[name="password"]', credentials.password);
|
||||
|
||||
// Wait for login API response
|
||||
const loginPromise = this.page.waitForResponse(response =>
|
||||
response.url().includes('/auth/login') && response.status() === 200,
|
||||
{ timeout: TIMEOUTS.login }
|
||||
);
|
||||
|
||||
await this.page.click('button[type="submit"]');
|
||||
|
||||
try {
|
||||
await loginPromise;
|
||||
console.log(`Login as ${credentials.username} successful`);
|
||||
|
||||
// Wait for navigation away from login page
|
||||
await this.page.waitForFunction(() =>
|
||||
!window.location.pathname.includes('/login'),
|
||||
{ timeout: TIMEOUTS.navigation }
|
||||
);
|
||||
|
||||
console.log('Navigation completed to:', this.page.url());
|
||||
} catch (error) {
|
||||
console.error(`Login as ${credentials.username} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
// Look for logout button/link and click it
|
||||
const logoutButton = this.page.locator('[data-testid="logout"], button:has-text("Logout"), a:has-text("Logout")').first();
|
||||
|
||||
if (await logoutButton.isVisible()) {
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for redirect to login page
|
||||
await this.page.waitForFunction(() =>
|
||||
window.location.pathname.includes('/login') || window.location.pathname === '/',
|
||||
{ timeout: TIMEOUTS.navigation }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoggedOut() {
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
|
||||
// If we see a login form, we're already logged out
|
||||
const usernameInput = await this.page.locator('input[name="username"]').isVisible().catch(() => false);
|
||||
if (usernameInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to logout
|
||||
await this.logout();
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<AuthFixture>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait a bit for the page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check if already logged in by looking for username input (login page)
|
||||
const usernameInput = await page.locator('input[name="username"]').isVisible().catch(() => false);
|
||||
|
||||
if (usernameInput) {
|
||||
console.log('Found login form, attempting to login...');
|
||||
|
||||
// Fill login form with demo credentials
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'readur2024');
|
||||
|
||||
// Wait for the login API call response
|
||||
const loginPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/auth/login') && response.status() === 200,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
try {
|
||||
await loginPromise;
|
||||
console.log('Login API call successful');
|
||||
|
||||
// Wait for redirect or URL change
|
||||
await page.waitForFunction(() =>
|
||||
!window.location.pathname.includes('/login'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
console.log('Redirected to:', page.url());
|
||||
} catch (error) {
|
||||
console.log('Login failed or timeout:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('Already logged in or no login form found');
|
||||
}
|
||||
|
||||
const auth = new AuthHelper(page);
|
||||
await auth.loginAs(TEST_CREDENTIALS.admin);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
adminPage: async ({ page }, use) => {
|
||||
const auth = new AuthHelper(page);
|
||||
await auth.loginAs(TEST_CREDENTIALS.admin);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
userPage: async ({ page }, use) => {
|
||||
const auth = new AuthHelper(page);
|
||||
await auth.loginAs(TEST_CREDENTIALS.user);
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
import { test, expect } from './fixtures/auth';
|
||||
import { TIMEOUTS, API_ENDPOINTS } from './utils/test-data';
|
||||
import { TestHelpers } from './utils/test-helpers';
|
||||
|
||||
test.describe('OCR Retry Workflow', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ adminPage }) => {
|
||||
helpers = new TestHelpers(adminPage);
|
||||
await helpers.navigateToPage('/documents');
|
||||
});
|
||||
|
||||
test('should display failed OCR documents', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Look for failed documents filter or section
|
||||
const failedFilter = page.locator('button:has-text("Failed"), [data-testid="failed-filter"], .filter-failed').first();
|
||||
|
||||
if (await failedFilter.isVisible()) {
|
||||
await failedFilter.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
} else {
|
||||
// Alternative: look for a dedicated failed documents page
|
||||
const failedTab = page.locator('tab:has-text("Failed"), [role="tab"]:has-text("Failed")').first();
|
||||
if (await failedTab.isVisible()) {
|
||||
await failedTab.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if failed documents are displayed
|
||||
const documentList = page.locator('[data-testid="document-list"], .document-list, .documents-grid');
|
||||
if (await documentList.isVisible({ timeout: 5000 })) {
|
||||
const documents = page.locator('.document-item, .document-card, [data-testid="document-item"]');
|
||||
const documentCount = await documents.count();
|
||||
console.log(`Found ${documentCount} documents in failed OCR view`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should retry individual failed OCR document', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Navigate to failed documents
|
||||
const failedFilter = page.locator('button:has-text("Failed"), [data-testid="failed-filter"]').first();
|
||||
if (await failedFilter.isVisible()) {
|
||||
await failedFilter.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
}
|
||||
|
||||
// Find a failed document and its retry button
|
||||
const retryButton = page.locator('button:has-text("Retry"), [data-testid="retry-ocr"], .retry-button').first();
|
||||
|
||||
if (await retryButton.isVisible()) {
|
||||
// Wait for retry API call
|
||||
const retryPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/retry') && response.status() === 200,
|
||||
{ timeout: TIMEOUTS.medium }
|
||||
);
|
||||
|
||||
await retryButton.click();
|
||||
|
||||
try {
|
||||
await retryPromise;
|
||||
console.log('OCR retry initiated successfully');
|
||||
|
||||
// Look for success message or status change
|
||||
const successMessage = page.locator('.success, [data-testid="success-message"], .notification');
|
||||
if (await successMessage.isVisible({ timeout: 5000 })) {
|
||||
console.log('Retry success message displayed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('OCR retry may have failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should bulk retry multiple failed OCR documents', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Navigate to failed documents
|
||||
const failedFilter = page.locator('button:has-text("Failed"), [data-testid="failed-filter"]').first();
|
||||
if (await failedFilter.isVisible()) {
|
||||
await failedFilter.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
}
|
||||
|
||||
// Select multiple documents
|
||||
const selectAllCheckbox = page.locator('input[type="checkbox"]:has-text("Select All"), [data-testid="select-all"]').first();
|
||||
if (await selectAllCheckbox.isVisible()) {
|
||||
await selectAllCheckbox.click();
|
||||
} else {
|
||||
// Alternative: select individual checkboxes
|
||||
const documentCheckboxes = page.locator('.document-item input[type="checkbox"], [data-testid="document-checkbox"]');
|
||||
const checkboxCount = await documentCheckboxes.count();
|
||||
if (checkboxCount > 0) {
|
||||
// Select first 3 documents
|
||||
for (let i = 0; i < Math.min(3, checkboxCount); i++) {
|
||||
await documentCheckboxes.nth(i).click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find bulk retry button
|
||||
const bulkRetryButton = page.locator('button:has-text("Retry Selected"), button:has-text("Bulk Retry"), [data-testid="bulk-retry"]').first();
|
||||
|
||||
if (await bulkRetryButton.isVisible()) {
|
||||
// Wait for bulk retry API call
|
||||
const bulkRetryPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/bulk-retry') || response.url().includes('/retry'),
|
||||
{ timeout: TIMEOUTS.long }
|
||||
);
|
||||
|
||||
await bulkRetryButton.click();
|
||||
|
||||
try {
|
||||
await bulkRetryPromise;
|
||||
console.log('Bulk OCR retry initiated successfully');
|
||||
|
||||
// Look for progress indicator or success message
|
||||
const progressIndicator = page.locator('.progress, [data-testid="retry-progress"], .bulk-retry-progress');
|
||||
if (await progressIndicator.isVisible({ timeout: 5000 })) {
|
||||
console.log('Bulk retry progress indicator visible');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Bulk OCR retry may have failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show OCR retry history', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Look for retry history or logs
|
||||
const historyButton = page.locator('button:has-text("Retry History"), [data-testid="retry-history"], .history-button').first();
|
||||
|
||||
if (await historyButton.isVisible()) {
|
||||
await historyButton.click();
|
||||
|
||||
// Check if history modal or panel opens
|
||||
const historyContainer = page.locator('.retry-history, [data-testid="retry-history-panel"], .history-container');
|
||||
await expect(historyContainer.first()).toBeVisible({ timeout: TIMEOUTS.short });
|
||||
|
||||
// Check for history entries
|
||||
const historyEntries = page.locator('.history-item, .retry-entry, tr');
|
||||
if (await historyEntries.first().isVisible({ timeout: 5000 })) {
|
||||
const entryCount = await historyEntries.count();
|
||||
console.log(`Found ${entryCount} retry history entries`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should display OCR failure reasons', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Navigate to failed documents
|
||||
const failedFilter = page.locator('button:has-text("Failed"), [data-testid="failed-filter"]').first();
|
||||
if (await failedFilter.isVisible()) {
|
||||
await failedFilter.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
}
|
||||
|
||||
// Click on a failed document to view details
|
||||
const failedDocument = page.locator('.document-item, .document-card, [data-testid="document-item"]').first();
|
||||
|
||||
if (await failedDocument.isVisible()) {
|
||||
await failedDocument.click();
|
||||
|
||||
// Look for failure reason or error details
|
||||
const errorDetails = page.locator('.error-details, [data-testid="failure-reason"], .ocr-error');
|
||||
if (await errorDetails.isVisible({ timeout: 5000 })) {
|
||||
const errorText = await errorDetails.textContent();
|
||||
console.log('OCR failure reason:', errorText);
|
||||
}
|
||||
|
||||
// Look for retry recommendations
|
||||
const recommendations = page.locator('.retry-recommendations, [data-testid="retry-suggestions"], .recommendations');
|
||||
if (await recommendations.isVisible({ timeout: 5000 })) {
|
||||
console.log('Retry recommendations displayed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should filter failed documents by failure type', async ({ adminPage: page }) => {
|
||||
await page.goto('/documents');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Navigate to failed documents
|
||||
const failedFilter = page.locator('button:has-text("Failed"), [data-testid="failed-filter"]').first();
|
||||
if (await failedFilter.isVisible()) {
|
||||
await failedFilter.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
}
|
||||
|
||||
// Look for failure type filters
|
||||
const filterDropdown = page.locator('select[name="failure-type"], [data-testid="failure-filter"]').first();
|
||||
|
||||
if (await filterDropdown.isVisible()) {
|
||||
await filterDropdown.click();
|
||||
|
||||
// Select a specific failure type
|
||||
const timeoutOption = page.locator('option:has-text("Timeout"), [value="timeout"]').first();
|
||||
if (await timeoutOption.isVisible()) {
|
||||
await timeoutOption.click();
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Verify filtered results
|
||||
const filteredDocuments = page.locator('.document-item, .document-card');
|
||||
const documentCount = await filteredDocuments.count();
|
||||
console.log(`Found ${documentCount} documents with timeout failures`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { test, expect } from './fixtures/auth';
|
||||
import { TIMEOUTS, API_ENDPOINTS } from './utils/test-data';
|
||||
import { TestHelpers } from './utils/test-helpers';
|
||||
|
||||
test.describe('WebDAV Workflow', () => {
|
||||
let helpers: TestHelpers;
|
||||
|
||||
test.beforeEach(async ({ adminPage }) => {
|
||||
helpers = new TestHelpers(adminPage);
|
||||
await helpers.navigateToPage('/sources');
|
||||
});
|
||||
|
||||
test('should create and configure WebDAV source', async ({ adminPage: page }) => {
|
||||
// Navigate to sources page
|
||||
await page.goto('/sources');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Look for add source button (try multiple selectors)
|
||||
const addSourceButton = page.locator('button:has-text("Add"), button:has-text("New"), [data-testid="add-source"]').first();
|
||||
|
||||
if (await addSourceButton.isVisible()) {
|
||||
await addSourceButton.click();
|
||||
} else {
|
||||
// Alternative: look for floating action button or plus button
|
||||
const fabButton = page.locator('button[aria-label*="add"], button[title*="add"], .fab, .add-button').first();
|
||||
if (await fabButton.isVisible()) {
|
||||
await fabButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for source creation form/modal
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select WebDAV source type if source type selection exists
|
||||
const webdavOption = page.locator('input[value="webdav"], [data-value="webdav"], option[value="webdav"]').first();
|
||||
if (await webdavOption.isVisible()) {
|
||||
await webdavOption.click();
|
||||
}
|
||||
|
||||
// Fill WebDAV configuration form
|
||||
const nameInput = page.locator('input[name="name"], input[placeholder*="name"], input[label*="Name"]').first();
|
||||
if (await nameInput.isVisible()) {
|
||||
await nameInput.fill('Test WebDAV Source');
|
||||
}
|
||||
|
||||
const urlInput = page.locator('input[name="url"], input[placeholder*="url"], input[type="url"]').first();
|
||||
if (await urlInput.isVisible()) {
|
||||
await urlInput.fill('https://demo.webdav.server/');
|
||||
}
|
||||
|
||||
const usernameInput = page.locator('input[name="username"], input[placeholder*="username"]').first();
|
||||
if (await usernameInput.isVisible()) {
|
||||
await usernameInput.fill('webdav_user');
|
||||
}
|
||||
|
||||
const passwordInput = page.locator('input[name="password"], input[type="password"]').first();
|
||||
if (await passwordInput.isVisible()) {
|
||||
await passwordInput.fill('webdav_pass');
|
||||
}
|
||||
|
||||
// Save the source configuration
|
||||
const saveButton = page.locator('button:has-text("Save"), button:has-text("Create"), button[type="submit"]').first();
|
||||
if (await saveButton.isVisible()) {
|
||||
// Wait for save API call
|
||||
const savePromise = page.waitForResponse(response =>
|
||||
response.url().includes('/sources') && (response.status() === 200 || response.status() === 201),
|
||||
{ timeout: TIMEOUTS.medium }
|
||||
);
|
||||
|
||||
await saveButton.click();
|
||||
|
||||
try {
|
||||
await savePromise;
|
||||
console.log('WebDAV source created successfully');
|
||||
} catch (error) {
|
||||
console.log('Source creation may have failed or timed out:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify source appears in the list
|
||||
await helpers.waitForLoadingToComplete();
|
||||
const sourceList = page.locator('[data-testid="sources-list"], .sources-list, .source-item');
|
||||
await expect(sourceList.first()).toBeVisible({ timeout: TIMEOUTS.medium });
|
||||
});
|
||||
|
||||
test('should test WebDAV connection', async ({ adminPage: page }) => {
|
||||
// This test assumes a WebDAV source exists from the previous test or setup
|
||||
await page.goto('/sources');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Find WebDAV source and test connection
|
||||
const testConnectionButton = page.locator('button:has-text("Test"), [data-testid="test-connection"]').first();
|
||||
|
||||
if (await testConnectionButton.isVisible()) {
|
||||
// Wait for connection test API call
|
||||
const testPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/test') || response.url().includes('/connection'),
|
||||
{ timeout: TIMEOUTS.medium }
|
||||
);
|
||||
|
||||
await testConnectionButton.click();
|
||||
|
||||
try {
|
||||
const response = await testPromise;
|
||||
console.log('Connection test completed with status:', response.status());
|
||||
} catch (error) {
|
||||
console.log('Connection test may have failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for connection status indicator
|
||||
const statusIndicator = page.locator('.status, [data-testid="connection-status"], .connection-result');
|
||||
if (await statusIndicator.isVisible()) {
|
||||
const statusText = await statusIndicator.textContent();
|
||||
console.log('Connection status:', statusText);
|
||||
}
|
||||
});
|
||||
|
||||
test('should initiate WebDAV sync', async ({ adminPage: page }) => {
|
||||
await page.goto('/sources');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Find and click sync button
|
||||
const syncButton = page.locator('button:has-text("Sync"), [data-testid="sync-source"]').first();
|
||||
|
||||
if (await syncButton.isVisible()) {
|
||||
// Wait for sync API call
|
||||
const syncPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/sync') && response.status() === 200,
|
||||
{ timeout: TIMEOUTS.medium }
|
||||
);
|
||||
|
||||
await syncButton.click();
|
||||
|
||||
try {
|
||||
await syncPromise;
|
||||
console.log('WebDAV sync initiated successfully');
|
||||
|
||||
// Look for sync progress indicators
|
||||
const progressIndicator = page.locator('.progress, [data-testid="sync-progress"], .syncing');
|
||||
if (await progressIndicator.isVisible({ timeout: 5000 })) {
|
||||
console.log('Sync progress indicator visible');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Sync may have failed or timed out:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should show WebDAV sync history', async ({ adminPage: page }) => {
|
||||
await page.goto('/sources');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Look for sync history or logs
|
||||
const historyButton = page.locator('button:has-text("History"), button:has-text("Logs"), [data-testid="sync-history"]').first();
|
||||
|
||||
if (await historyButton.isVisible()) {
|
||||
await historyButton.click();
|
||||
|
||||
// Check if history modal or page opens
|
||||
const historyContainer = page.locator('.history, [data-testid="sync-history"], .logs-container');
|
||||
await expect(historyContainer.first()).toBeVisible({ timeout: TIMEOUTS.short });
|
||||
|
||||
// Check for history entries
|
||||
const historyEntries = page.locator('.history-item, .log-entry, tr');
|
||||
if (await historyEntries.first().isVisible({ timeout: 5000 })) {
|
||||
const entryCount = await historyEntries.count();
|
||||
console.log(`Found ${entryCount} sync history entries`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle WebDAV source deletion', async ({ adminPage: page }) => {
|
||||
await page.goto('/sources');
|
||||
await helpers.waitForLoadingToComplete();
|
||||
|
||||
// Find delete button for WebDAV source
|
||||
const deleteButton = page.locator('button:has-text("Delete"), [data-testid="delete-source"], .delete-button').first();
|
||||
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
// Handle confirmation dialog if it appears
|
||||
const confirmButton = page.locator('button:has-text("Confirm"), button:has-text("Delete"), button:has-text("Yes")').first();
|
||||
if (await confirmButton.isVisible({ timeout: 3000 })) {
|
||||
// Wait for delete API call
|
||||
const deletePromise = page.waitForResponse(response =>
|
||||
response.url().includes('/sources') && response.status() === 200,
|
||||
{ timeout: TIMEOUTS.medium }
|
||||
);
|
||||
|
||||
await confirmButton.click();
|
||||
|
||||
try {
|
||||
await deletePromise;
|
||||
console.log('WebDAV source deleted successfully');
|
||||
} catch (error) {
|
||||
console.log('Source deletion may have failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { vi } from 'vitest';
|
||||
import GlobalSearchBar from '../GlobalSearchBar';
|
||||
import { renderWithProviders, createMockApiServices } from '../../../test/test-utils';
|
||||
|
||||
// Mock the API service
|
||||
const mockDocumentService = {
|
||||
enhancedSearch: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../../services/api', () => ({
|
||||
documentService: mockDocumentService,
|
||||
}));
|
||||
// Use centralized API mocking
|
||||
const mockServices = createMockApiServices();
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
|
|
@ -24,15 +18,6 @@ vi.mock('react-router-dom', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock data
|
||||
const mockSearchResponse = {
|
||||
data: {
|
||||
|
|
@ -53,14 +38,7 @@ const mockSearchResponse = {
|
|||
}
|
||||
};
|
||||
|
||||
// Helper to render component with router
|
||||
const renderWithRouter = (component) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
{component}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
// Using centralized render utility (no custom helper needed)
|
||||
|
||||
describe('GlobalSearchBar', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -70,7 +48,7 @@ describe('GlobalSearchBar', () => {
|
|||
});
|
||||
|
||||
test('renders search input with placeholder', () => {
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search documents...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
|
@ -78,7 +56,7 @@ describe('GlobalSearchBar', () => {
|
|||
|
||||
test('accepts user input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||
await user.type(searchInput, 'test');
|
||||
|
|
@ -88,7 +66,7 @@ describe('GlobalSearchBar', () => {
|
|||
|
||||
test('clears input when clear button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||
await user.type(searchInput, 'test');
|
||||
|
|
@ -101,7 +79,7 @@ describe('GlobalSearchBar', () => {
|
|||
});
|
||||
|
||||
test('shows popular searches when focused', async () => {
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||
fireEvent.focus(searchInput);
|
||||
|
|
@ -112,7 +90,7 @@ describe('GlobalSearchBar', () => {
|
|||
});
|
||||
|
||||
test('handles empty search gracefully', () => {
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
|
|
@ -122,7 +100,7 @@ describe('GlobalSearchBar', () => {
|
|||
|
||||
test('handles keyboard navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(<GlobalSearchBar />);
|
||||
renderWithProviders(<GlobalSearchBar />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||
await user.type(searchInput, 'test query');
|
||||
|
|
|
|||
|
|
@ -1,37 +1,26 @@
|
|||
import { expect, afterEach, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
||||
// Global test setup file for Vitest
|
||||
// This file is automatically loaded before all tests
|
||||
|
||||
expect.extend(matchers)
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
import { setupTestEnvironment } from './test-utils'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
// Setup global test environment
|
||||
setupTestEnvironment()
|
||||
|
||||
// Additional global setup can be added here
|
||||
// For example:
|
||||
// - Global error handlers
|
||||
// - Test timeouts
|
||||
// - Common test data
|
||||
// - Global test utilities
|
||||
|
||||
// Increase test timeout for async operations
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
// Global axios mock
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
put: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
defaults: { headers: { common: {} } },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ interface User {
|
|||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface MockAuthContextType {
|
||||
|
|
@ -17,6 +18,85 @@ interface MockAuthContextType {
|
|||
logout: () => void
|
||||
}
|
||||
|
||||
// Test data factories for consistent mock data across tests
|
||||
export const createMockUser = (overrides: Partial<User> = {}): User => ({
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
...overrides
|
||||
})
|
||||
|
||||
export const createMockAdminUser = (overrides: Partial<User> = {}): User => ({
|
||||
id: '2',
|
||||
username: 'adminuser',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Centralized API mocking to eliminate per-file duplication
|
||||
export const createMockApiServices = () => {
|
||||
const mockDocumentService = {
|
||||
enhancedSearch: vi.fn().mockResolvedValue({ documents: [], total: 0 }),
|
||||
bulkRetryOcr: vi.fn().mockResolvedValue({ success: true }),
|
||||
getDocument: vi.fn().mockResolvedValue({}),
|
||||
uploadDocument: vi.fn().mockResolvedValue({}),
|
||||
deleteDocument: vi.fn().mockResolvedValue({}),
|
||||
updateDocument: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
const mockAuthService = {
|
||||
login: vi.fn().mockResolvedValue({ token: 'mock-token', user: createMockUser() }),
|
||||
register: vi.fn().mockResolvedValue({ token: 'mock-token', user: createMockUser() }),
|
||||
logout: vi.fn().mockResolvedValue({}),
|
||||
getCurrentUser: vi.fn().mockResolvedValue(createMockUser()),
|
||||
}
|
||||
|
||||
const mockSourceService = {
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
createSource: vi.fn().mockResolvedValue({}),
|
||||
updateSource: vi.fn().mockResolvedValue({}),
|
||||
deleteSource: vi.fn().mockResolvedValue({}),
|
||||
syncSource: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
const mockLabelService = {
|
||||
getLabels: vi.fn().mockResolvedValue([]),
|
||||
createLabel: vi.fn().mockResolvedValue({}),
|
||||
updateLabel: vi.fn().mockResolvedValue({}),
|
||||
deleteLabel: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
return {
|
||||
documentService: mockDocumentService,
|
||||
authService: mockAuthService,
|
||||
sourceService: mockSourceService,
|
||||
labelService: mockLabelService,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup global API mocks (call this in setup files)
|
||||
export const setupApiMocks = () => {
|
||||
const mockServices = createMockApiServices()
|
||||
|
||||
vi.mock('../../services/api', () => ({
|
||||
documentService: mockServices.documentService,
|
||||
authService: mockServices.authService,
|
||||
sourceService: mockServices.sourceService,
|
||||
labelService: mockServices.labelService,
|
||||
api: {
|
||||
defaults: {
|
||||
headers: {
|
||||
common: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return mockServices
|
||||
}
|
||||
|
||||
// Create a mock AuthProvider for testing
|
||||
export const MockAuthProvider = ({
|
||||
children,
|
||||
|
|
@ -44,36 +124,122 @@ export const MockAuthProvider = ({
|
|||
)
|
||||
}
|
||||
|
||||
// Create a custom render function that includes providers
|
||||
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
// Enhanced provider wrapper with theme and notification contexts
|
||||
const AllTheProviders = ({
|
||||
children,
|
||||
authValues,
|
||||
routerProps = {}
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
authValues?: Partial<MockAuthContextType>
|
||||
routerProps?: any
|
||||
}) => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<MockAuthProvider>
|
||||
<BrowserRouter {...routerProps}>
|
||||
<MockAuthProvider mockValues={authValues}>
|
||||
{children}
|
||||
</MockAuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
// Enhanced render functions with better provider configuration
|
||||
export const renderWithProviders = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllTheProviders, ...options })
|
||||
options?: Omit<RenderOptions, 'wrapper'> & {
|
||||
authValues?: Partial<MockAuthContextType>
|
||||
routerProps?: any
|
||||
}
|
||||
) => {
|
||||
const { authValues, routerProps, ...renderOptions } = options || {}
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AllTheProviders authValues={authValues} routerProps={routerProps}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
)
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions })
|
||||
}
|
||||
|
||||
export const renderWithMockAuth = (
|
||||
ui: React.ReactElement,
|
||||
mockAuthValues?: Partial<MockAuthContextType>,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>
|
||||
<MockAuthProvider mockValues={mockAuthValues}>
|
||||
{children}
|
||||
</MockAuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
return renderWithProviders(ui, { ...options, authValues: mockAuthValues })
|
||||
}
|
||||
|
||||
// Render with authenticated user (commonly used pattern)
|
||||
export const renderWithAuthenticatedUser = (
|
||||
ui: React.ReactElement,
|
||||
user: User = createMockUser(),
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => {
|
||||
return renderWithProviders(ui, {
|
||||
...options,
|
||||
authValues: {
|
||||
user,
|
||||
loading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Render with admin user (commonly used pattern)
|
||||
export const renderWithAdminUser = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => {
|
||||
return renderWithAuthenticatedUser(ui, createMockAdminUser(), options)
|
||||
}
|
||||
|
||||
// Mock localStorage consistently across tests
|
||||
export const createMockLocalStorage = () => {
|
||||
const storage: Record<string, string> = {}
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...options })
|
||||
return {
|
||||
getItem: vi.fn((key: string) => storage[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { storage[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete storage[key] }),
|
||||
clear: vi.fn(() => Object.keys(storage).forEach(key => delete storage[key])),
|
||||
key: vi.fn((index: number) => Object.keys(storage)[index] || null),
|
||||
length: Object.keys(storage).length,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup function to be called in test setup files
|
||||
export const setupTestEnvironment = () => {
|
||||
// Mock localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: createMockLocalStorage(),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Mock sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: createMockLocalStorage(),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
return setupApiMocks()
|
||||
}
|
||||
|
||||
// re-export everything
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt};
|
|||
use testcontainers_modules::postgres::Postgres;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
use tower::util::ServiceExt;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
use uuid;
|
||||
|
||||
/// Test image information with expected OCR content
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -152,163 +150,331 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper functions for integration tests
|
||||
/// Unified test context that eliminates duplication across integration tests
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
||||
let postgres_image = Postgres::default()
|
||||
.with_env_var("POSTGRES_USER", "test")
|
||||
.with_env_var("POSTGRES_PASSWORD", "test")
|
||||
.with_env_var("POSTGRES_DB", "test");
|
||||
|
||||
let container = postgres_image.start().await.expect("Failed to start postgres container");
|
||||
let port = container.get_host_port_ipv4(5432).await.expect("Failed to get postgres port");
|
||||
|
||||
// Use TEST_DATABASE_URL if available, otherwise use the container
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| format!("postgresql://test:test@localhost:{}/test", port));
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
|
||||
let config = crate::config::Config {
|
||||
database_url: database_url.clone(),
|
||||
server_address: "127.0.0.1:0".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string(), "txt".to_string(), "png".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
|
||||
// OCR Configuration
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2, // Lower for tests
|
||||
ocr_timeout_seconds: 60, // Shorter for tests
|
||||
max_file_size_mb: 10, // Smaller for tests
|
||||
|
||||
// Performance
|
||||
memory_limit_mb: 256, // Lower for tests
|
||||
cpu_priority: "normal".to_string(),
|
||||
|
||||
// OIDC Configuration
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let queue_service = Arc::new(crate::ocr::queue::OcrQueueService::new(db.clone(), db.pool.clone(), 2));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
config,
|
||||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api/auth", crate::routes::auth::router())
|
||||
.nest("/api/documents", crate::routes::documents::router())
|
||||
.nest("/api/search", crate::routes::search::router())
|
||||
.nest("/api/settings", crate::routes::settings::router())
|
||||
.nest("/api/users", crate::routes::users::router())
|
||||
.nest("/api/ignored-files", crate::routes::ignored_files::ignored_files_routes())
|
||||
.with_state(state);
|
||||
|
||||
(app, container)
|
||||
pub struct TestContext {
|
||||
pub app: Router,
|
||||
pub container: ContainerAsync<Postgres>,
|
||||
pub state: Arc<AppState>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl TestContext {
|
||||
/// Create a new test context with default test configuration
|
||||
pub async fn new() -> Self {
|
||||
Self::with_config(TestConfigBuilder::default()).await
|
||||
}
|
||||
|
||||
/// Create a test context with custom configuration
|
||||
pub async fn with_config(config_builder: TestConfigBuilder) -> Self {
|
||||
let postgres_image = Postgres::default()
|
||||
.with_env_var("POSTGRES_USER", "test")
|
||||
.with_env_var("POSTGRES_PASSWORD", "test")
|
||||
.with_env_var("POSTGRES_DB", "test");
|
||||
|
||||
let container = postgres_image.start().await.expect("Failed to start postgres container");
|
||||
let port = container.get_host_port_ipv4(5432).await.expect("Failed to get postgres port");
|
||||
|
||||
let database_url = std::env::var("TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| format!("postgresql://test:test@localhost:{}/test", port));
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
|
||||
let config = config_builder.build(database_url);
|
||||
let queue_service = Arc::new(crate::ocr::queue::OcrQueueService::new(db.clone(), db.pool.clone(), 2));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
config,
|
||||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api/auth", crate::routes::auth::router())
|
||||
.nest("/api/documents", crate::routes::documents::router())
|
||||
.nest("/api/search", crate::routes::search::router())
|
||||
.nest("/api/settings", crate::routes::settings::router())
|
||||
.nest("/api/users", crate::routes::users::router())
|
||||
.nest("/api/ignored-files", crate::routes::ignored_files::ignored_files_routes())
|
||||
.with_state(state.clone());
|
||||
|
||||
Self { app, container, state }
|
||||
}
|
||||
|
||||
/// Get the app router for making requests
|
||||
pub fn app(&self) -> &Router {
|
||||
&self.app
|
||||
}
|
||||
|
||||
/// Get the application state
|
||||
pub fn state(&self) -> &Arc<AppState> {
|
||||
&self.state
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder pattern for test configuration to eliminate config duplication
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct TestConfigBuilder {
|
||||
upload_path: String,
|
||||
watch_folder: String,
|
||||
jwt_secret: String,
|
||||
concurrent_ocr_jobs: usize,
|
||||
ocr_timeout_seconds: u64,
|
||||
max_file_size_mb: u64,
|
||||
memory_limit_mb: u64,
|
||||
oidc_enabled: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl Default for TestConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
concurrent_ocr_jobs: 2,
|
||||
ocr_timeout_seconds: 60,
|
||||
max_file_size_mb: 10,
|
||||
memory_limit_mb: 256,
|
||||
oidc_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl TestConfigBuilder {
|
||||
pub fn with_upload_path(mut self, path: &str) -> Self {
|
||||
self.upload_path = path.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_watch_folder(mut self, folder: &str) -> Self {
|
||||
self.watch_folder = folder.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_concurrent_ocr_jobs(mut self, jobs: usize) -> Self {
|
||||
self.concurrent_ocr_jobs = jobs;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_oidc_enabled(mut self, enabled: bool) -> Self {
|
||||
self.oidc_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn build(self, database_url: String) -> crate::config::Config {
|
||||
crate::config::Config {
|
||||
database_url,
|
||||
server_address: "127.0.0.1:0".to_string(),
|
||||
jwt_secret: self.jwt_secret,
|
||||
upload_path: self.upload_path,
|
||||
watch_folder: self.watch_folder,
|
||||
allowed_file_types: vec!["pdf".to_string(), "txt".to_string(), "png".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
|
||||
// OCR Configuration
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: self.concurrent_ocr_jobs,
|
||||
ocr_timeout_seconds: self.ocr_timeout_seconds,
|
||||
max_file_size_mb: self.max_file_size_mb,
|
||||
|
||||
// Performance
|
||||
memory_limit_mb: self.memory_limit_mb as usize,
|
||||
cpu_priority: "normal".to_string(),
|
||||
|
||||
// OIDC Configuration
|
||||
oidc_enabled: self.oidc_enabled,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility - will be deprecated
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
||||
let ctx = TestContext::new().await;
|
||||
(ctx.app, ctx.container)
|
||||
}
|
||||
|
||||
/// Unified test authentication helper that replaces TestClient/AdminTestClient patterns
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct TestAuthHelper {
|
||||
app: Router,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl TestAuthHelper {
|
||||
pub fn new(app: Router) -> Self {
|
||||
Self { app }
|
||||
}
|
||||
|
||||
/// Create a regular test user with unique credentials
|
||||
pub async fn create_test_user(&self) -> TestUser {
|
||||
let test_id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
.to_string()[..8]
|
||||
.to_string();
|
||||
let username = format!("testuser_{}", test_id);
|
||||
let email = format!("test_{}@example.com", test_id);
|
||||
let password = "password123";
|
||||
|
||||
let user_data = json!({
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
|
||||
let response = self.make_request("POST", "/api/auth/register", Some(user_data), None).await;
|
||||
let user_response: UserResponse = serde_json::from_slice(&response).unwrap();
|
||||
|
||||
TestUser {
|
||||
user_response,
|
||||
username,
|
||||
password: password.to_string(),
|
||||
token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an admin test user with unique credentials
|
||||
pub async fn create_admin_user(&self) -> TestUser {
|
||||
let test_id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
.to_string()[..8]
|
||||
.to_string();
|
||||
let username = format!("adminuser_{}", test_id);
|
||||
let email = format!("admin_{}@example.com", test_id);
|
||||
let password = "adminpass123";
|
||||
|
||||
let admin_data = json!({
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"role": "admin"
|
||||
});
|
||||
|
||||
let response = self.make_request("POST", "/api/auth/register", Some(admin_data), None).await;
|
||||
let user_response: UserResponse = serde_json::from_slice(&response).unwrap();
|
||||
|
||||
TestUser {
|
||||
user_response,
|
||||
username,
|
||||
password: password.to_string(),
|
||||
token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Login a user and return their authentication token
|
||||
pub async fn login_user(&self, username: &str, password: &str) -> String {
|
||||
let login_data = json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
|
||||
let response = self.make_request("POST", "/api/auth/login", Some(login_data), None).await;
|
||||
let login_response: serde_json::Value = serde_json::from_slice(&response).unwrap();
|
||||
login_response["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Make an authenticated HTTP request
|
||||
pub async fn make_authenticated_request(&self, method: &str, uri: &str, body: Option<serde_json::Value>, token: &str) -> Vec<u8> {
|
||||
self.make_request(method, uri, body, Some(token)).await
|
||||
}
|
||||
|
||||
/// Make an HTTP request (internal helper)
|
||||
async fn make_request(&self, method: &str, uri: &str, body: Option<serde_json::Value>, token: Option<&str>) -> Vec<u8> {
|
||||
let mut builder = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
let request_body = if let Some(body) = body {
|
||||
axum::body::Body::from(serde_json::to_vec(&body).unwrap())
|
||||
} else {
|
||||
axum::body::Body::empty()
|
||||
};
|
||||
|
||||
let response = self.app
|
||||
.clone()
|
||||
.oneshot(builder.body(request_body).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test user with authentication capabilities
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub struct TestUser {
|
||||
pub user_response: UserResponse,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
impl TestUser {
|
||||
/// Login this user and store the authentication token
|
||||
pub async fn login(&mut self, auth_helper: &TestAuthHelper) -> Result<&str, Box<dyn std::error::Error>> {
|
||||
let token = auth_helper.login_user(&self.username, &self.password).await;
|
||||
self.token = Some(token);
|
||||
Ok(self.token.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Make an authenticated request as this user
|
||||
pub async fn make_request(&self, auth_helper: &TestAuthHelper, method: &str, uri: &str, body: Option<serde_json::Value>) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let token = self.token.as_ref().ok_or("User not logged in")?;
|
||||
Ok(auth_helper.make_authenticated_request(method, uri, body, token).await)
|
||||
}
|
||||
|
||||
/// Get user ID
|
||||
pub fn id(&self) -> String {
|
||||
self.user_response.id.to_string()
|
||||
}
|
||||
|
||||
/// Check if user is admin
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self.user_response.role, crate::models::UserRole::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy functions for backward compatibility - will be deprecated
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_test_user(app: &Router) -> UserResponse {
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let test_username = format!("testuser_{}", test_id);
|
||||
let test_email = format!("test_{}@example.com", test_id);
|
||||
|
||||
let user_data = json!({
|
||||
"username": test_username,
|
||||
"email": test_email,
|
||||
"password": "password123"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&user_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
let auth_helper = TestAuthHelper::new(app.clone());
|
||||
let test_user = auth_helper.create_test_user().await;
|
||||
test_user.user_response
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn create_admin_user(app: &Router) -> UserResponse {
|
||||
// Generate random identifiers to avoid test interference
|
||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let admin_username = format!("adminuser_{}", test_id);
|
||||
let admin_email = format!("admin_{}@example.com", test_id);
|
||||
|
||||
let admin_data = json!({
|
||||
"username": admin_username,
|
||||
"email": admin_email,
|
||||
"password": "adminpass123",
|
||||
"role": "admin"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&admin_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
let auth_helper = TestAuthHelper::new(app.clone());
|
||||
let admin_user = auth_helper.create_admin_user().await;
|
||||
admin_user.user_response
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub async fn login_user(app: &Router, username: &str, password: &str) -> String {
|
||||
let login_data = json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/login")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&login_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let login_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
login_response["token"].as_str().unwrap().to_string()
|
||||
let auth_helper = TestAuthHelper::new(app.clone());
|
||||
auth_helper.login_user(username, password).await
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
use crate::{AppState, models::UserResponse};
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use testcontainers::{core::WaitFor, runners::AsyncRunner, ContainerAsync, GenericImage, ImageExt};
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
||||
let postgres_image = Postgres::default()
|
||||
.with_tag("15-alpine")
|
||||
.with_env_var("POSTGRES_USER", "test")
|
||||
.with_env_var("POSTGRES_PASSWORD", "test")
|
||||
.with_env_var("POSTGRES_DB", "test");
|
||||
|
||||
let container = postgres_image.start().await.expect("Failed to start postgres container");
|
||||
let port = container.get_host_port_ipv4(5432).await.expect("Failed to get postgres port");
|
||||
|
||||
let database_url = format!("postgresql://test:test@localhost:{}/test", port);
|
||||
let db = crate::db::Database::new(&database_url).await.unwrap();
|
||||
db.migrate().await.unwrap();
|
||||
|
||||
let config = crate::config::Config {
|
||||
database_url: database_url.clone(),
|
||||
server_address: "127.0.0.1:0".to_string(),
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
upload_path: "./test-uploads".to_string(),
|
||||
watch_folder: "./test-watch".to_string(),
|
||||
allowed_file_types: vec!["pdf".to_string(), "txt".to_string(), "png".to_string()],
|
||||
watch_interval_seconds: Some(30),
|
||||
file_stability_check_ms: Some(500),
|
||||
max_file_age_hours: None,
|
||||
|
||||
// OCR Configuration
|
||||
ocr_language: "eng".to_string(),
|
||||
concurrent_ocr_jobs: 2, // Lower for tests
|
||||
ocr_timeout_seconds: 60, // Shorter for tests
|
||||
max_file_size_mb: 10, // Smaller for tests
|
||||
|
||||
// Performance
|
||||
memory_limit_mb: 256, // Lower for tests
|
||||
cpu_priority: "normal".to_string(),
|
||||
|
||||
// OIDC Configuration (disabled for tests)
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: None,
|
||||
oidc_client_secret: None,
|
||||
oidc_issuer_url: None,
|
||||
oidc_redirect_uri: None,
|
||||
};
|
||||
|
||||
let queue_service = Arc::new(crate::ocr::queue::OcrQueueService::new(db.clone(), db.pool.clone(), 2));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
config,
|
||||
webdav_scheduler: None,
|
||||
source_scheduler: None,
|
||||
queue_service,
|
||||
oidc_client: None,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api/auth", crate::routes::auth::router())
|
||||
.nest("/api/documents", crate::routes::documents::router())
|
||||
.nest("/api/search", crate::routes::search::router())
|
||||
.nest("/api/settings", crate::routes::settings::router())
|
||||
.nest("/api/users", crate::routes::users::router())
|
||||
.with_state(state);
|
||||
|
||||
(app, container)
|
||||
}
|
||||
|
||||
pub async fn create_test_user(app: &Router) -> UserResponse {
|
||||
let user_data = json!({
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&user_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
}
|
||||
|
||||
pub async fn create_admin_user(app: &Router) -> UserResponse {
|
||||
let admin_data = json!({
|
||||
"username": "adminuser",
|
||||
"email": "admin@example.com",
|
||||
"password": "adminpass123",
|
||||
"role": "admin"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/register")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&admin_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
}
|
||||
|
||||
pub async fn login_user(app: &Router, username: &str, password: &str) -> String {
|
||||
let login_data = json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/auth/login")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(axum::body::Body::from(serde_json::to_vec(&login_data).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let login_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
login_response["token"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
mod helpers;
|
||||
mod auth_tests;
|
||||
mod config_oidc_tests;
|
||||
mod db_tests;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::UpdateSettings;
|
||||
use super::super::helpers::{create_test_app, create_test_user, login_user};
|
||||
use crate::test_utils::{create_test_app, create_test_user, login_user};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::util::ServiceExt;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::{CreateUser, UpdateUser, UserResponse, AuthProvider, UserRole};
|
||||
use super::super::helpers::{create_test_app, create_test_user, create_admin_user, login_user};
|
||||
use crate::test_utils::{create_test_app, create_test_user, create_admin_user, login_user};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tower::util::ServiceExt;
|
||||
|
|
|
|||
Loading…
Reference in New Issue