fix: migrations, auth, providers health check, E2E tests, remove neko-sama
- Add proper Alembic initial migration (0001_initial_schema.py) - Migrate refresh tokens from JSON file to SQLite (RefreshTokenTable) - Remove Neko-Sama provider entirely (redirects to Gupy, not a host) - Fix provider health check always showing UNKNOWN - Run check_all_health() on startup - Fix POST /providers/health/check background task bug - Add HTMX refresh after manual health check trigger - Fix anime search relevance scoring with MIN_RELEVANCE_THRESHOLD=0.5 - Replace bare 'except:' with 'except Exception:' across codebase - Add Playwright E2E test suite (12 tests, auth setup, helpers) - Fix toast container blocking clicks via pointer-events: none - Remove obsolete Jest/Vite test files and config - Clean up obsolete test_watchlist scripts - Update sonarr model comment for active providers
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Create user if not exists (global setup should have done it, but be safe)
|
||||
const resp = await page.request.post('/api/auth/register', {
|
||||
data: {
|
||||
username: 'e2e_testuser',
|
||||
password: 'TestPassword123!',
|
||||
email: 'e2e@example.com',
|
||||
full_name: 'E2E Test User',
|
||||
},
|
||||
});
|
||||
if (!resp.ok() && resp.status() !== 400) {
|
||||
console.warn('Register failed:', await resp.text());
|
||||
}
|
||||
|
||||
// Login via UI
|
||||
await page.goto('/login');
|
||||
await page.fill('#loginUsername', 'e2e_testuser');
|
||||
await page.fill('#loginPassword', 'TestPassword123!');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||
page.click('#loginSubmit'),
|
||||
]);
|
||||
|
||||
await page.waitForURL('**/web**', { timeout: 10000 });
|
||||
|
||||
// Save storage state (localStorage + cookies)
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
+59
-85
@@ -1,119 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_USER, login } from './helpers';
|
||||
|
||||
test.describe('Auth Flow', () => {
|
||||
test('login success - redirects to home and stores token', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill login form
|
||||
await page.fill('#loginUsername', 'testuser');
|
||||
await page.fill('#loginPassword', 'password123');
|
||||
|
||||
// Click login button
|
||||
await page.click('#loginSubmit');
|
||||
|
||||
// Wait for redirect or success message
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if redirected or success message shown
|
||||
const currentUrl = page.url();
|
||||
const successMessage = await page.locator('#authSuccess').textContent().catch(() => '');
|
||||
|
||||
// Either redirect happened or success message shown
|
||||
expect(currentUrl.includes('/web') || successMessage.includes('réussie')).toBeTruthy();
|
||||
await login(page, TEST_USER.username, TEST_USER.password);
|
||||
|
||||
// Verify redirect to /web
|
||||
await expect(page).toHaveURL(/\/web/);
|
||||
|
||||
// Verify token stored
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login with wrong credentials shows error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill login form with wrong credentials
|
||||
await page.fill('#loginUsername', 'nonexistentuser');
|
||||
await page.fill('#loginUsername', 'nonexistentuser_xyz');
|
||||
await page.fill('#loginPassword', 'wrongpassword');
|
||||
|
||||
// Click login button
|
||||
await page.click('#loginSubmit');
|
||||
|
||||
// Wait for error
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check error message is displayed
|
||||
const errorVisible = await page.locator('#authError').isVisible().catch(() => false);
|
||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
||||
|
||||
// Error should be shown (and NOT be "[object Object]")
|
||||
expect(errorVisible || errorText.length > 0).toBeTruthy();
|
||||
expect(errorText).not.toContain('[object Object]');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||
page.click('#loginSubmit'),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
|
||||
// Error message should be visible
|
||||
const errorLocator = page.locator('#authError');
|
||||
await expect(errorLocator).toBeVisible();
|
||||
await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i);
|
||||
});
|
||||
|
||||
test('register new user shows success', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Switch to register tab
|
||||
await page.click('text=Inscription');
|
||||
|
||||
// Fill register form with unique username
|
||||
const uniqueUsername = 'testuser_' + Date.now();
|
||||
|
||||
const uniqueUsername = `testuser_${Date.now()}`;
|
||||
await page.fill('#registerUsername', uniqueUsername);
|
||||
await page.fill('#registerPassword', 'password123');
|
||||
await page.fill('#registerPasswordConfirm', 'password123');
|
||||
|
||||
// Click register button
|
||||
await page.click('#registerSubmit');
|
||||
|
||||
// Wait for success
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check success message
|
||||
const successVisible = await page.locator('#authSuccess').isVisible().catch(() => false);
|
||||
const successText = await page.locator('#authSuccess').textContent().catch(() => '');
|
||||
|
||||
// Success should be shown
|
||||
expect(successVisible || successText.includes('réussie')).toBeTruthy();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
|
||||
page.click('#registerSubmit'),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
await expect(page.locator('#authSuccess')).toBeVisible();
|
||||
await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i);
|
||||
});
|
||||
|
||||
test('password mismatch shows validation error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Switch to register tab
|
||||
await page.click('text=Inscription');
|
||||
|
||||
// Fill register form with mismatching passwords
|
||||
|
||||
await page.fill('#registerUsername', 'testuser');
|
||||
await page.fill('#registerPassword', 'password123');
|
||||
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
||||
|
||||
// Click register button
|
||||
await page.click('#registerSubmit');
|
||||
|
||||
// Wait for error
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check error message
|
||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
||||
|
||||
// Should show password mismatch error
|
||||
expect(errorText).toContain('correspondent');
|
||||
|
||||
await expect(page.locator('#authError')).toBeVisible();
|
||||
await expect(page.locator('#authError')).toContainText(/correspondent|match/i);
|
||||
});
|
||||
|
||||
test('login button shows loading state during request', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Get button and check initial state
|
||||
const button = page.locator('#loginSubmit');
|
||||
const initialText = await button.textContent();
|
||||
|
||||
// Fill form and click
|
||||
await page.fill('#loginUsername', 'testuser');
|
||||
await page.fill('#loginPassword', 'password123');
|
||||
|
||||
// Click and immediately check loading state
|
||||
await button.click();
|
||||
|
||||
// Check loading state (should change text or be disabled)
|
||||
await page.waitForTimeout(100);
|
||||
const buttonText = await button.textContent();
|
||||
const isDisabled = await button.isDisabled().catch(() => false);
|
||||
|
||||
// Button should either show loading text or be disabled
|
||||
expect(buttonText !== initialText || isDisabled).toBeTruthy();
|
||||
|
||||
await page.fill('#loginUsername', TEST_USER.username);
|
||||
await page.fill('#loginPassword', TEST_USER.password);
|
||||
|
||||
// Start the click but don't await it fully — we want to observe the loading state
|
||||
const clickPromise = button.click();
|
||||
|
||||
// Poll briefly for loading state
|
||||
let sawLoading = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const text = await button.textContent();
|
||||
const disabled = await button.isDisabled();
|
||||
if (text !== initialText || disabled) {
|
||||
sawLoading = true;
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
await clickPromise;
|
||||
expect(sawLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { switchTab, waitForHtmx } from './helpers';
|
||||
|
||||
test.describe('Downloads', () => {
|
||||
test('should display downloads tab', async ({ page }) => {
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Téléchargements');
|
||||
await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(page.locator('#tab-downloads')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
* Creates a predictable test user so auth tests don't fail on missing accounts.
|
||||
* Uses native fetch to avoid conflicts with vitest.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
const testUser = {
|
||||
username: 'e2e_testuser',
|
||||
password: 'TestPassword123!',
|
||||
email: 'e2e@example.com',
|
||||
full_name: 'E2E Test User',
|
||||
};
|
||||
|
||||
// Try to register the test user (ignore 400 if already exists)
|
||||
const resp = await fetch(`${baseURL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testUser),
|
||||
});
|
||||
|
||||
if (resp.ok || resp.status === 400) {
|
||||
console.log(`[global-setup] Test user "${testUser.username}" ready`);
|
||||
} else {
|
||||
const body = await resp.text().catch(() => '');
|
||||
console.warn(`[global-setup] Register returned ${resp.status}: ${body}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const TEST_USER = {
|
||||
username: 'e2e_testuser',
|
||||
password: 'TestPassword123!',
|
||||
};
|
||||
|
||||
/**
|
||||
* Log in via the UI login form.
|
||||
*/
|
||||
export async function login(page: Page, username: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.fill('#loginUsername', username);
|
||||
await page.fill('#loginPassword', password);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||
page.click('#loginSubmit'),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Wait for success message or redirect
|
||||
await Promise.race([
|
||||
page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 }),
|
||||
page.waitForURL('**/web**', { timeout: 5000 }),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new unique user via the UI form.
|
||||
*/
|
||||
export async function register(page: Page, username: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.click('text=Inscription');
|
||||
|
||||
await page.fill('#registerUsername', username);
|
||||
await page.fill('#registerPassword', password);
|
||||
await page.fill('#registerPasswordConfirm', password);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
|
||||
page.click('#registerSubmit'),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a tab by name (Accueil, Anime, Série, Watchlist, etc.)
|
||||
*/
|
||||
export async function switchTab(page: Page, tabName: string) {
|
||||
// Wait for tabs to be rendered
|
||||
await page.locator('nav#mainTabs .tab').first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const tab = page.locator('nav#mainTabs .tab', { hasText: new RegExp(tabName, 'i') });
|
||||
await tab.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await tab.click();
|
||||
await expect(tab).toHaveClass(/active/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for HTMX content to settle (no more hx-request in flight).
|
||||
*/
|
||||
export async function waitForHtmx(page: Page, timeout = 10000) {
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('.htmx-request').length === 0,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that no unhandled JS errors occurred on the page.
|
||||
*/
|
||||
export function collectJsErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
+46
-108
@@ -1,152 +1,90 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { switchTab, waitForHtmx, collectJsErrors } from './helpers';
|
||||
|
||||
/**
|
||||
* User Journey E2E Tests
|
||||
*
|
||||
* Simulates a complete user flow: register → login → browse → search → settings → logout.
|
||||
* All tests are serial because they share browser state (auth token, navigation).
|
||||
*
|
||||
* FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector()
|
||||
* Tests authenticated user flows. Auth is handled by auth.setup.ts + storageState.
|
||||
*/
|
||||
|
||||
test.describe('User Journey E2E', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test('should browse homepage without JS errors', async ({ page }) => {
|
||||
const jsErrors = collectJsErrors(page);
|
||||
await page.goto('/web');
|
||||
|
||||
const testData = {
|
||||
username: `e2e_user_${Date.now()}`,
|
||||
password: 'TestPass123!',
|
||||
};
|
||||
|
||||
// Register a new user account via the UI form
|
||||
test('should register a new user', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Switch to the register tab
|
||||
await page.click('text=Inscription');
|
||||
|
||||
// Fill out the registration form
|
||||
await page.fill('#registerUsername', testData.username);
|
||||
await page.fill('#registerPassword', testData.password);
|
||||
await page.fill('#registerPasswordConfirm', testData.password);
|
||||
|
||||
// Submit and wait for the API response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
|
||||
page.click('#registerSubmit'),
|
||||
]);
|
||||
|
||||
// Registration should succeed (201 or 200)
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Verify the success message appears
|
||||
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
|
||||
const successText = await page.locator('#authSuccess').textContent();
|
||||
expect(successText).toMatch(/réussie|inscription/i);
|
||||
});
|
||||
|
||||
// Login with the credentials registered in the previous test
|
||||
test('should login with registered credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.fill('#loginUsername', testData.username);
|
||||
await page.fill('#loginPassword', testData.password);
|
||||
|
||||
// Submit and wait for the login API response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||
page.click('#loginSubmit'),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Verify success message
|
||||
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
|
||||
const successText = await page.locator('#authSuccess').textContent();
|
||||
expect(successText).toMatch(/réussie/i);
|
||||
|
||||
// Wait for redirect to /web
|
||||
await page.waitForURL('**/web**', { timeout: 10000 });
|
||||
|
||||
// Verify the auth token is stored in localStorage
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token).toBeTruthy();
|
||||
});
|
||||
|
||||
// Browse the homepage — verify layout loads without JS errors
|
||||
test('should browse homepage without errors', async ({ page }) => {
|
||||
// Collect JS page errors
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
|
||||
// Ensure we are on /web (carried over from login)
|
||||
if (!page.url().includes('/web')) {
|
||||
await page.goto('/web');
|
||||
}
|
||||
|
||||
// Wait for main content area to be visible
|
||||
await page.locator('#main-content').waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Verify the header heading
|
||||
// Main content should be visible
|
||||
await expect(page.locator('#main-content')).toBeVisible();
|
||||
await expect(page.locator('header h1')).toContainText('Ohm Stream');
|
||||
|
||||
// Verify at least one navigation tab is visible
|
||||
// At least one tab visible
|
||||
await expect(page.locator('.tab').first()).toBeVisible();
|
||||
|
||||
// Verify the user info panel (logged-in state indicator)
|
||||
// Authenticated user info should be visible
|
||||
await expect(page.locator('#userInfo')).toBeVisible();
|
||||
|
||||
// No JavaScript errors should have been thrown
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Search for an anime via the Anime tab — results may be empty but the UI must respond
|
||||
test('should search for anime', async ({ page }) => {
|
||||
// Navigate to the Anime tab
|
||||
await page.click('.tab:has-text("Anime")');
|
||||
// Mock the anime search API to return deterministic HTML
|
||||
await page.route('/api/anime/search?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div class="sr-card">
|
||||
<h3>Naruto Shippuden</h3>
|
||||
<p>Anime-Sama</p>
|
||||
</div>
|
||||
<div class="sr-card">
|
||||
<h3>Boruto: Naruto Next Generations</h3>
|
||||
<p>Neko-Sama</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Anime');
|
||||
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Fill the search input — HTMX debounce triggers the request automatically
|
||||
await page.fill('#animeSearchInput', 'Naruto');
|
||||
|
||||
// Wait for either results, an empty-state message, or the loading spinner to disappear
|
||||
await Promise.race([
|
||||
page.locator('#animeSearchResults .sr-card').first().waitFor({ timeout: 15000 }),
|
||||
page.locator('#animeSearchResults .sr-empty').first().waitFor({ timeout: 15000 }),
|
||||
page.locator('#search-loading').waitFor({ state: 'detached', timeout: 15000 }),
|
||||
]);
|
||||
// Click search button to trigger submit
|
||||
await page.click('#tab-anime button[type="submit"]');
|
||||
|
||||
// The search results container must be present regardless of result count
|
||||
// Wait for results to appear
|
||||
await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Results container should be visible and contain mocked data
|
||||
await expect(page.locator('#animeSearchResults')).toBeVisible();
|
||||
await expect(page.locator('#animeSearchResults')).toContainText('Naruto Shippuden');
|
||||
});
|
||||
|
||||
// Change a setting (language) and verify the PATCH response and toast notification
|
||||
test('should update settings', async ({ page }) => {
|
||||
// Open the settings tab
|
||||
await page.click('.tab:has-text("Paramètres")');
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Paramètres');
|
||||
|
||||
// Settings panel is loaded dynamically via HTMX — wait for the form
|
||||
// Wait for settings form loaded via HTMX
|
||||
await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
// Change the default language
|
||||
await page.selectOption('#default_lang', 'vf');
|
||||
|
||||
// Submit the settings form and capture the PATCH response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH'
|
||||
),
|
||||
page.locator('form[hx-patch="/api/settings"] button[type="submit"]').click(),
|
||||
page.locator('button:has-text("Enregistrer les preferences")').click(),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Verify a toast notification appears confirming the save
|
||||
await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Verify the setting was updated in the UI
|
||||
await expect(page.locator('#default_lang')).toHaveValue('vf');
|
||||
});
|
||||
|
||||
// Logout — verify the API call succeeds, redirect happens, and token is cleared
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
// Click the logout button and wait for the API response
|
||||
await page.goto('/web');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
|
||||
page.locator('#userInfo button:has-text("Déconnexion")').click(),
|
||||
@@ -154,7 +92,7 @@ test.describe('User Journey E2E', () => {
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Should be redirected back to the login page
|
||||
// Should redirect to login
|
||||
await page.waitForURL('**/login**', { timeout: 10000 });
|
||||
|
||||
// The auth token must be cleared from localStorage
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { switchTab, waitForHtmx } from './helpers';
|
||||
|
||||
test.describe('Watchlist', () => {
|
||||
test('should display watchlist tab', async ({ page }) => {
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Watchlist');
|
||||
await page.locator('#tab-watchlist').waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(page.locator('#tab-watchlist')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user