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 { test as base, expect } from '@playwright/test';
|
||||||
import type { Page } 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 {
|
export interface AuthFixture {
|
||||||
authenticatedPage: Page;
|
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>({
|
export const test = base.extend<AuthFixture>({
|
||||||
authenticatedPage: async ({ page }, use) => {
|
authenticatedPage: async ({ page }, use) => {
|
||||||
await page.goto('/');
|
const auth = new AuthHelper(page);
|
||||||
|
await auth.loginAs(TEST_CREDENTIALS.admin);
|
||||||
// Wait a bit for the page to load
|
await use(page);
|
||||||
await page.waitForLoadState('networkidle');
|
},
|
||||||
|
|
||||||
// Check if already logged in by looking for username input (login page)
|
adminPage: async ({ page }, use) => {
|
||||||
const usernameInput = await page.locator('input[name="username"]').isVisible().catch(() => false);
|
const auth = new AuthHelper(page);
|
||||||
|
await auth.loginAs(TEST_CREDENTIALS.admin);
|
||||||
if (usernameInput) {
|
await use(page);
|
||||||
console.log('Found login form, attempting to login...');
|
},
|
||||||
|
|
||||||
// Fill login form with demo credentials
|
userPage: async ({ page }, use) => {
|
||||||
await page.fill('input[name="username"]', 'admin');
|
const auth = new AuthHelper(page);
|
||||||
await page.fill('input[name="password"]', 'readur2024');
|
await auth.loginAs(TEST_CREDENTIALS.user);
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
await use(page);
|
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 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 userEvent from '@testing-library/user-event';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import GlobalSearchBar from '../GlobalSearchBar';
|
import GlobalSearchBar from '../GlobalSearchBar';
|
||||||
|
import { renderWithProviders, createMockApiServices } from '../../../test/test-utils';
|
||||||
|
|
||||||
// Mock the API service
|
// Use centralized API mocking
|
||||||
const mockDocumentService = {
|
const mockServices = createMockApiServices();
|
||||||
enhancedSearch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('../../../services/api', () => ({
|
|
||||||
documentService: mockDocumentService,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useNavigate
|
// Mock useNavigate
|
||||||
const mockNavigate = vi.fn();
|
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
|
// Mock data
|
||||||
const mockSearchResponse = {
|
const mockSearchResponse = {
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -53,14 +38,7 @@ const mockSearchResponse = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to render component with router
|
// Using centralized render utility (no custom helper needed)
|
||||||
const renderWithRouter = (component) => {
|
|
||||||
return render(
|
|
||||||
<BrowserRouter>
|
|
||||||
{component}
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GlobalSearchBar', () => {
|
describe('GlobalSearchBar', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -70,7 +48,7 @@ describe('GlobalSearchBar', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders search input with placeholder', () => {
|
test('renders search input with placeholder', () => {
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText('Search documents...')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('Search documents...')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
|
@ -78,7 +56,7 @@ describe('GlobalSearchBar', () => {
|
||||||
|
|
||||||
test('accepts user input', async () => {
|
test('accepts user input', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||||
await user.type(searchInput, 'test');
|
await user.type(searchInput, 'test');
|
||||||
|
|
@ -88,7 +66,7 @@ describe('GlobalSearchBar', () => {
|
||||||
|
|
||||||
test('clears input when clear button is clicked', async () => {
|
test('clears input when clear button is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||||
await user.type(searchInput, 'test');
|
await user.type(searchInput, 'test');
|
||||||
|
|
@ -101,7 +79,7 @@ describe('GlobalSearchBar', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows popular searches when focused', async () => {
|
test('shows popular searches when focused', async () => {
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||||
fireEvent.focus(searchInput);
|
fireEvent.focus(searchInput);
|
||||||
|
|
@ -112,7 +90,7 @@ describe('GlobalSearchBar', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles empty search gracefully', () => {
|
test('handles empty search gracefully', () => {
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||||
fireEvent.change(searchInput, { target: { value: '' } });
|
fireEvent.change(searchInput, { target: { value: '' } });
|
||||||
|
|
@ -122,7 +100,7 @@ describe('GlobalSearchBar', () => {
|
||||||
|
|
||||||
test('handles keyboard navigation', async () => {
|
test('handles keyboard navigation', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithRouter(<GlobalSearchBar />);
|
renderWithProviders(<GlobalSearchBar />);
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search documents...');
|
const searchInput = screen.getByPlaceholderText('Search documents...');
|
||||||
await user.type(searchInput, 'test query');
|
await user.type(searchInput, 'test query');
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,26 @@
|
||||||
import { expect, afterEach, vi } from 'vitest'
|
// Global test setup file for Vitest
|
||||||
import { cleanup } from '@testing-library/react'
|
// This file is automatically loaded before all tests
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
|
||||||
|
|
||||||
expect.extend(matchers)
|
import '@testing-library/jest-dom'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { setupTestEnvironment } from './test-utils'
|
||||||
|
|
||||||
afterEach(() => {
|
// Setup global test environment
|
||||||
cleanup()
|
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
|
// Clean up after each test
|
||||||
vi.mock('axios', () => ({
|
afterEach(() => {
|
||||||
default: {
|
vi.clearAllMocks()
|
||||||
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(),
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
|
|
@ -7,6 +7,7 @@ interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockAuthContextType {
|
interface MockAuthContextType {
|
||||||
|
|
@ -17,6 +18,85 @@ interface MockAuthContextType {
|
||||||
logout: () => void
|
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
|
// Create a mock AuthProvider for testing
|
||||||
export const MockAuthProvider = ({
|
export const MockAuthProvider = ({
|
||||||
children,
|
children,
|
||||||
|
|
@ -44,36 +124,122 @@ export const MockAuthProvider = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a custom render function that includes providers
|
// Enhanced provider wrapper with theme and notification contexts
|
||||||
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
const AllTheProviders = ({
|
||||||
|
children,
|
||||||
|
authValues,
|
||||||
|
routerProps = {}
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
authValues?: Partial<MockAuthContextType>
|
||||||
|
routerProps?: any
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter {...routerProps}>
|
||||||
<MockAuthProvider>
|
<MockAuthProvider mockValues={authValues}>
|
||||||
{children}
|
{children}
|
||||||
</MockAuthProvider>
|
</MockAuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced render functions with better provider configuration
|
||||||
export const renderWithProviders = (
|
export const renderWithProviders = (
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
options?: Omit<RenderOptions, 'wrapper'>
|
options?: Omit<RenderOptions, 'wrapper'> & {
|
||||||
) => render(ui, { wrapper: AllTheProviders, ...options })
|
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 = (
|
export const renderWithMockAuth = (
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
mockAuthValues?: Partial<MockAuthContextType>,
|
mockAuthValues?: Partial<MockAuthContextType>,
|
||||||
options?: Omit<RenderOptions, 'wrapper'>
|
options?: Omit<RenderOptions, 'wrapper'>
|
||||||
) => {
|
) => {
|
||||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
return renderWithProviders(ui, { ...options, authValues: mockAuthValues })
|
||||||
<BrowserRouter>
|
}
|
||||||
<MockAuthProvider mockValues={mockAuthValues}>
|
|
||||||
{children}
|
// Render with authenticated user (commonly used pattern)
|
||||||
</MockAuthProvider>
|
export const renderWithAuthenticatedUser = (
|
||||||
</BrowserRouter>
|
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
|
// re-export everything
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt};
|
||||||
use testcontainers_modules::postgres::Postgres;
|
use testcontainers_modules::postgres::Postgres;
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
|
||||||
use uuid;
|
|
||||||
|
|
||||||
/// Test image information with expected OCR content
|
/// Test image information with expected OCR content
|
||||||
#[derive(Debug, Clone)]
|
#[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"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub async fn create_test_app() -> (Router, ContainerAsync<Postgres>) {
|
pub struct TestContext {
|
||||||
let postgres_image = Postgres::default()
|
pub app: Router,
|
||||||
.with_env_var("POSTGRES_USER", "test")
|
pub container: ContainerAsync<Postgres>,
|
||||||
.with_env_var("POSTGRES_PASSWORD", "test")
|
pub state: Arc<AppState>,
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub async fn create_test_user(app: &Router) -> UserResponse {
|
pub async fn create_test_user(app: &Router) -> UserResponse {
|
||||||
// Generate random identifiers to avoid test interference
|
let auth_helper = TestAuthHelper::new(app.clone());
|
||||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
let test_user = auth_helper.create_test_user().await;
|
||||||
let test_username = format!("testuser_{}", test_id);
|
test_user.user_response
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub async fn create_admin_user(app: &Router) -> UserResponse {
|
pub async fn create_admin_user(app: &Router) -> UserResponse {
|
||||||
// Generate random identifiers to avoid test interference
|
let auth_helper = TestAuthHelper::new(app.clone());
|
||||||
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
let admin_user = auth_helper.create_admin_user().await;
|
||||||
let admin_username = format!("adminuser_{}", test_id);
|
admin_user.user_response
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub async fn login_user(app: &Router, username: &str, password: &str) -> String {
|
pub async fn login_user(app: &Router, username: &str, password: &str) -> String {
|
||||||
let login_data = json!({
|
let auth_helper = TestAuthHelper::new(app.clone());
|
||||||
"username": username,
|
auth_helper.login_user(username, password).await
|
||||||
"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,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 auth_tests;
|
||||||
mod config_oidc_tests;
|
mod config_oidc_tests;
|
||||||
mod db_tests;
|
mod db_tests;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::UpdateSettings;
|
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 axum::http::StatusCode;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::{CreateUser, UpdateUser, UserResponse, AuthProvider, UserRole};
|
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 axum::http::StatusCode;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue