From b23fd253379365ae3d73e70b4601a58b4ae657f2 Mon Sep 17 00:00:00 2001 From: retoor Date: Thu, 13 Nov 2025 11:47:50 +0100 Subject: [PATCH] Update. --- rbox/routers/users.py | 42 ++++- static/css/style.css | 44 +++++ static/js/components/cookie-consent.js | 146 ++++++++++++++++ static/js/components/login-view.js | 12 +- static/js/components/rbox-app.js | 25 +++ static/js/components/user-settings.js | 117 +++++++++++++ tests/e2e/test_admin.py | 181 ++++++++++++++++++++ tests/e2e/test_auth_flow.py | 170 ++++++++++++++++++ tests/e2e/test_file_management.py | 222 ++++++++++++++++++++++++ tests/e2e/test_photo_gallery.py | 227 +++++++++++++++++++++++++ tests/e2e/test_sharing.py | 207 ++++++++++++++++++++++ 11 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 static/js/components/cookie-consent.js create mode 100644 static/js/components/user-settings.js create mode 100644 tests/e2e/test_admin.py create mode 100644 tests/e2e/test_auth_flow.py create mode 100644 tests/e2e/test_file_management.py create mode 100644 tests/e2e/test_photo_gallery.py create mode 100644 tests/e2e/test_sharing.py diff --git a/rbox/routers/users.py b/rbox/routers/users.py index 3636115..9bb2010 100644 --- a/rbox/routers/users.py +++ b/rbox/routers/users.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends from ..auth import get_current_user -from ..models import User_Pydantic, User +from ..models import User_Pydantic, User, File, Folder +from typing import List, Dict, Any router = APIRouter( prefix="/users", @@ -10,3 +11,42 @@ router = APIRouter( @router.get("/me", response_model=User_Pydantic) async def read_users_me(current_user: User = Depends(get_current_user)): return await User_Pydantic.from_tortoise_orm(current_user) + +@router.get("/me/export", response_model=Dict[str, Any]) +async def export_my_data(current_user: User = Depends(get_current_user)): + """ + Exports all personal data associated with the current user. + Includes user profile, and metadata for all owned files and folders. + """ + user_data = await User_Pydantic.from_tortoise_orm(current_user) + + files = await File.filter(owner=current_user).values( + "id", "name", "size", "created_at", "modified_at", "file_type", "parent_id" + ) + folders = await Folder.filter(owner=current_user).values( + "id", "name", "created_at", "modified_at", "parent_id" + ) + + return { + "user_profile": user_data.dict(), + "files_metadata": files, + "folders_metadata": folders, + # In a more complete implementation, other data like activity logs, + # share information, etc., would also be included. + } + +@router.delete("/me", status_code=204) +async def delete_my_account(current_user: User = Depends(get_current_user)): + """ + Deletes the current user's account and all associated data. + This includes all files and folders owned by the user. + """ + # Delete all files and folders owned by the user + await File.filter(owner=current_user).delete() + await Folder.filter(owner=current_user).delete() + + # Finally, delete the user account + await current_user.delete() + return {} + + diff --git a/static/css/style.css b/static/css/style.css index 3bb2f73..6e1f741 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -998,3 +998,47 @@ body.dark-mode { padding-bottom: calc(var(--spacing-unit) * 2); margin-bottom: calc(var(--spacing-unit) * 2); } + +/* Footer Styles */ +.app-footer { + background-color: var(--accent-color); + border-top: 1px solid var(--border-color); + padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3); + text-align: center; + font-size: 0.875rem; + color: var(--text-color-light); + flex-shrink: 0; /* Prevent footer from shrinking */ +} + +.footer-nav { + margin-bottom: var(--spacing-unit); +} + +.footer-links { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: calc(var(--spacing-unit) * 2); +} + +.footer-links li { + display: inline; +} + +.footer-links a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-links a:hover { + color: var(--secondary-color); + text-decoration: underline; +} + +.footer-text { + margin: 0; +} diff --git a/static/js/components/cookie-consent.js b/static/js/components/cookie-consent.js new file mode 100644 index 0000000..0f436d6 --- /dev/null +++ b/static/js/components/cookie-consent.js @@ -0,0 +1,146 @@ +// static/js/components/cookie-consent.js +import app from '../app.js'; + +const COOKIE_CONSENT_KEY = 'rbox_cookie_consent'; + +export class CookieConsent extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.hasConsented = this.checkConsent(); + } + + connectedCallback() { + if (!this.hasConsented) { + this.render(); + } + } + + checkConsent() { + const consent = localStorage.getItem(COOKIE_CONSENT_KEY); + if (consent) { + // In a real application, you'd parse this and apply preferences + // For now, just checking if it exists + return true; + } + return false; + } + + setConsent(status) { + // In a real application, 'status' would be a detailed object + // For now, a simple string 'accepted' or 'declined' + localStorage.setItem(COOKIE_CONSENT_KEY, status); + this.hasConsented = true; + this.remove(); // Remove the banner after consent + app.getLogger().info(`Cookie consent: ${status}`); + // Trigger an event for other parts of the app to react to consent change + document.dispatchEvent(new CustomEvent('cookie-consent-changed', { detail: { status } })); + } + + render() { + this.shadowRoot.innerHTML = ` + + + `; + + this.shadowRoot.querySelector('.consent-button.accept').addEventListener('click', () => this.setConsent('accepted')); + this.shadowRoot.querySelector('.consent-button.decline').addEventListener('click', () => this.setConsent('declined')); + this.shadowRoot.querySelector('.consent-button.customize').addEventListener('click', () => { + // For now, customize acts like accept. In a real app, this would open a modal. + app.getLogger().info('Customize cookie consent clicked. (Placeholder: acting as accept)'); + this.setConsent('accepted'); + }); + } +} + +customElements.define('cookie-consent', CookieConsent); diff --git a/static/js/components/login-view.js b/static/js/components/login-view.js index 1a3e0fb..27613a2 100644 --- a/static/js/components/login-view.js +++ b/static/js/components/login-view.js @@ -83,12 +83,12 @@ export class LoginView extends HTMLElement { errorDiv.textContent = ''; try { - logger.info('Login attempt started', { username }); + logger.info('Login attempt started', { action: 'USER_LOGIN_ATTEMPT', username }); await api.login(username, password); - logger.info('Login successful, dispatching auth-success event'); + logger.info('Login successful', { action: 'USER_LOGIN_SUCCESS', username }); this.dispatchEvent(new CustomEvent('auth-success')); } catch (error) { - logger.error('Login failed', { username, error: error.message }); + logger.error('Login failed', { action: 'USER_LOGIN_FAILURE', username, error: error.message }); errorDiv.textContent = error.message; errorDiv.style.display = 'block'; } @@ -107,12 +107,12 @@ export class LoginView extends HTMLElement { errorDiv.textContent = ''; try { - logger.info('Registration attempt started', { username, email }); + logger.info('Registration attempt started', { action: 'USER_REGISTER_ATTEMPT', username, email }); await api.register(username, email, password); - logger.info('Registration successful, dispatching auth-success event'); + logger.info('Registration successful', { action: 'USER_REGISTER_SUCCESS', username, email }); this.dispatchEvent(new CustomEvent('auth-success')); } catch (error) { - logger.error('Registration failed', { username, email, error: error.message }); + logger.error('Registration failed', { action: 'USER_REGISTER_FAILURE', username, email, error: error.message }); errorDiv.textContent = error.message; errorDiv.style.display = 'block'; } diff --git a/static/js/components/rbox-app.js b/static/js/components/rbox-app.js index 86805e6..9520ae7 100644 --- a/static/js/components/rbox-app.js +++ b/static/js/components/rbox-app.js @@ -14,6 +14,8 @@ import './shared-items.js'; import './billing-dashboard.js'; import './admin-billing.js'; import './code-editor-view.js'; +import './cookie-consent.js'; +import './user-settings.js'; // Import the new user settings component import { shortcuts } from '../shortcuts.js'; const api = app.getAPI(); @@ -126,6 +128,7 @@ export class RBoxApp extends HTMLElement {
  • Shared Items
  • Deleted Files
  • Billing
  • +
  • User Settings
  • ${this.user && this.user.is_superuser ? `
  • Admin Dashboard
  • ` : ''} ${this.user && this.user.is_superuser ? `
  • Admin Billing
  • ` : ''} @@ -145,7 +148,24 @@ export class RBoxApp extends HTMLElement { + + + `; this.initializeNavigation(); @@ -358,6 +378,7 @@ export class RBoxApp extends HTMLElement { attachListeners() { this.querySelector('#logout-btn')?.addEventListener('click', () => { + logger.info('User logout initiated', { action: 'USER_LOGOUT' }); api.logout(); }); @@ -639,6 +660,10 @@ export class RBoxApp extends HTMLElement { mainContent.innerHTML = ''; this.attachListeners(); break; + case 'user-settings': + mainContent.innerHTML = ''; + this.attachListeners(); + break; case 'admin-billing': mainContent.innerHTML = ''; this.attachListeners(); diff --git a/static/js/components/user-settings.js b/static/js/components/user-settings.js new file mode 100644 index 0000000..76c599f --- /dev/null +++ b/static/js/components/user-settings.js @@ -0,0 +1,117 @@ +// static/js/components/user-settings.js +import app from '../app.js'; + +const api = app.getAPI(); +const logger = app.getLogger(); + +export class UserSettings extends HTMLElement { + constructor() { + super(); + this.boundHandleClick = this.handleClick.bind(this); + } + + connectedCallback() { + this.render(); + this.addEventListener('click', this.boundHandleClick); + } + + disconnectedCallback() { + this.removeEventListener('click', this.boundHandleClick); + } + + render() { + this.innerHTML = ` +
    +

    User Settings

    + +
    +

    Data Management

    +

    You can export a copy of your personal data or delete your account.

    + + +
    + + + +
    + `; + } + + async handleClick(event) { + if (event.target.id === 'exportDataBtn') { + await this.exportUserData(); + } else if (event.target.id === 'deleteAccountBtn') { + await this.deleteAccount(); + } + } + + async exportUserData() { + try { + logger.info('Initiating data export...', { action: 'USER_DATA_EXPORT_ATTEMPT' }); + const response = await fetch('/api/users/me/export', { + headers: { + 'Authorization': `Bearer ${api.getToken()}`, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const filename = `rbox_user_data_${new Date().toISOString().slice(0,10)}.json`; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + logger.info('User data exported successfully.', { action: 'USER_DATA_EXPORT_SUCCESS' }); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Your data has been exported successfully!', type: 'success' } + })); + } catch (error) { + logger.error('Failed to export user data:', { action: 'USER_DATA_EXPORT_FAILURE', error: error.message }); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: `Failed to export data: ${error.message}`, type: 'error' } + })); + } + } + + async deleteAccount() { + if (!confirm('Are you absolutely sure you want to delete your account? This action cannot be undone and all your data will be permanently lost.')) { + return; + } + + try { + logger.warn('Initiating account deletion...', { action: 'USER_ACCOUNT_DELETE_ATTEMPT' }); + const response = await fetch('/api/users/me', { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${api.getToken()}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + logger.info('Account deleted successfully. Logging out...', { action: 'USER_ACCOUNT_DELETE_SUCCESS' }); + api.logout(); // Clear token and redirect to login + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: 'Your account has been successfully deleted.', type: 'success' } + })); + } catch (error) { + logger.error('Failed to delete account:', { action: 'USER_ACCOUNT_DELETE_FAILURE', error: error.message }); + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: `Failed to delete account: ${error.message}`, type: 'error' } + })); + } + } +} + +customElements.define('user-settings', UserSettings); diff --git a/tests/e2e/test_admin.py b/tests/e2e/test_admin.py new file mode 100644 index 0000000..b7ff2fb --- /dev/null +++ b/tests/e2e/test_admin.py @@ -0,0 +1,181 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestAdmin: + + async def test_01_admin_login(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Admin should see admin nav links + await expect(page.locator('a.nav-link[data-view="admin"]')).to_be_visible() + await expect(page.locator('a.nav-link[data-view="admin-billing"]')).to_be_visible() + + async def test_02_navigate_to_admin_dashboard(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('admin-dashboard')).to_be_visible() + + async def test_03_view_user_management(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin"]') + await page.wait_for_timeout(1000) + + # Should show user list + await expect(page.locator('.user-list')).to_be_visible() + await expect(page.locator('text=e2etestuser')).to_be_visible() + + async def test_04_view_system_statistics(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin"]') + await page.wait_for_timeout(1000) + + # Should show system stats + await expect(page.locator('.system-stats')).to_be_visible() + await expect(page.locator('.stat-item')).to_be_visible() + + async def test_05_manage_user_permissions(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin"]') + await page.wait_for_timeout(1000) + + # Click on user to manage + await page.click('.user-item:has-text("e2etestuser")') + await page.wait_for_timeout(500) + + # Should show user details modal + await expect(page.locator('.user-details-modal')).to_be_visible() + + # Change permissions + await page.select_option('select[name="role"]', 'moderator') + await page.click('button:has-text("Save")') + await page.wait_for_timeout(1000) + + async def test_06_view_storage_usage(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin"]') + await page.wait_for_timeout(1000) + + # Should show storage usage + await expect(page.locator('.storage-usage')).to_be_visible() + + async def test_07_navigate_to_admin_billing(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin-billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('admin-billing')).to_be_visible() + + async def test_08_view_billing_statistics(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin-billing"]') + await page.wait_for_timeout(1000) + + # Should show billing stats + await expect(page.locator('.billing-stats')).to_be_visible() + + async def test_09_manage_pricing(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin-billing"]') + await page.wait_for_timeout(1000) + + # Click pricing management + await page.click('button:has-text("Manage Pricing")') + await page.wait_for_timeout(500) + + # Should show pricing form + await expect(page.locator('.pricing-form')).to_be_visible() + + # Update a price + await page.fill('input[name="storage_per_gb_month"]', '0.005') + await page.click('button:has-text("Update")') + await page.wait_for_timeout(1000) + + async def test_10_generate_invoice(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'adminuser') + await page.fill('#login-form input[name="password"]', 'adminpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="admin-billing"]') + await page.wait_for_timeout(1000) + + # Click generate invoice + await page.click('button:has-text("Generate Invoice")') + await page.wait_for_timeout(500) + + # Select user + await page.select_option('select[name="user"]', 'e2etestuser') + await page.click('button:has-text("Generate")') + await page.wait_for_timeout(1000) + + # Should show success message + await expect(page.locator('text=Invoice generated')).to_be_visible() \ No newline at end of file diff --git a/tests/e2e/test_auth_flow.py b/tests/e2e/test_auth_flow.py new file mode 100644 index 0000000..072dfe5 --- /dev/null +++ b/tests/e2e/test_auth_flow.py @@ -0,0 +1,170 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestAuthFlow: + + async def test_01_user_registration(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + # Click Sign Up + await page.click('text=Sign Up') + await page.wait_for_timeout(500) + + # Fill registration form + await page.fill('#register-form input[name="username"]', 'e2etestuser') + await page.fill('#register-form input[name="email"]', 'e2etestuser@example.com') + await page.fill('#register-form input[name="password"]', 'testpassword123') + + # Submit registration + await page.click('#register-form button[type="submit"]') + + await page.wait_for_timeout(2000) + await expect(page.locator('text=My Files')).to_be_visible() + + async def test_02_user_login(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + # Unregister service worker to avoid interference + await page.evaluate(""" + navigator.serviceWorker.getRegistrations().then(function(registrations) { + for(let registration of registrations) { + registration.unregister(); + } + }); + """) + + await page.reload() + await page.wait_for_load_state("networkidle") + + # Fill login form + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + + # Submit login + await page.click('#login-form button[type="submit"]') + + await page.wait_for_timeout(2000) + await expect(page.locator('text=My Files')).to_be_visible() + + async def test_03_navigate_to_files_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Files view should be active by default + await expect(page.locator('a.nav-link.active[data-view="files"]')).to_be_visible() + await expect(page.locator('file-list')).to_be_visible() + + async def test_04_navigate_to_photos_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="photos"]')).to_be_visible() + await expect(page.locator('photo-gallery')).to_be_visible() + + async def test_05_navigate_to_shared_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="shared"]')).to_be_visible() + await expect(page.locator('shared-items')).to_be_visible() + + async def test_06_navigate_to_starred_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="starred"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="starred"]')).to_be_visible() + await expect(page.locator('starred-items')).to_be_visible() + + async def test_07_navigate_to_recent_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="recent"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="recent"]')).to_be_visible() + await expect(page.locator('recent-files')).to_be_visible() + + async def test_08_navigate_to_deleted_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="deleted"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="deleted"]')).to_be_visible() + await expect(page.locator('deleted-files')).to_be_visible() + + async def test_09_navigate_to_billing_view(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="billing"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('a.nav-link.active[data-view="billing"]')).to_be_visible() + await expect(page.locator('billing-dashboard')).to_be_visible() + + async def test_10_user_logout(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Click logout + await page.click('#logout-btn') + await page.wait_for_timeout(1000) + + # Should be back to login + await expect(page.locator('#login-form')).to_be_visible() \ No newline at end of file diff --git a/tests/e2e/test_file_management.py b/tests/e2e/test_file_management.py new file mode 100644 index 0000000..b68a863 --- /dev/null +++ b/tests/e2e/test_file_management.py @@ -0,0 +1,222 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestFileManagement: + + async def test_01_upload_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Upload a file + await page.set_input_files('input[type="file"]', { + 'name': 'test-file.txt', + 'mimeType': 'text/plain', + 'buffer': b'This is a test file for e2e testing.' + }) + + await page.click('button:has-text("Upload")') + await page.wait_for_timeout(2000) + + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_02_create_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Click create folder button + await page.click('button:has-text("New Folder")') + await page.wait_for_timeout(500) + + # Fill folder name + await page.fill('input[placeholder="Folder name"]', 'Test Folder') + await page.click('button:has-text("Create")') + await page.wait_for_timeout(1000) + + await expect(page.locator('text=Test Folder')).to_be_visible() + + async def test_03_navigate_into_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Click on the folder + await page.click('text=Test Folder') + await page.wait_for_timeout(1000) + + # Should be in the folder, breadcrumb should show it + await expect(page.locator('.breadcrumb')).to_contain_text('Test Folder') + + async def test_04_upload_file_in_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Navigate to folder + await page.click('text=Test Folder') + await page.wait_for_timeout(1000) + + # Upload file in folder + await page.set_input_files('input[type="file"]', { + 'name': 'folder-file.txt', + 'mimeType': 'text/plain', + 'buffer': b'This file is in a folder.' + }) + + await page.click('button:has-text("Upload")') + await page.wait_for_timeout(2000) + + await expect(page.locator('text=folder-file.txt')).to_be_visible() + + async def test_05_navigate_back_from_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Navigate to folder + await page.click('text=Test Folder') + await page.wait_for_timeout(1000) + + # Click breadcrumb to go back + await page.click('.breadcrumb a:first-child') + await page.wait_for_timeout(1000) + + # Should be back in root + await expect(page.locator('text=Test Folder')).to_be_visible() + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_06_select_and_delete_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Select file + await page.click('.file-item:has-text("test-file.txt") .file-checkbox') + await page.wait_for_timeout(500) + + # Click delete + await page.click('button:has-text("Delete")') + await page.wait_for_timeout(500) + + # Confirm delete + await page.click('button:has-text("Confirm")') + await page.wait_for_timeout(1000) + + # File should be gone + await expect(page.locator('text=test-file.txt')).not_to_be_visible() + + async def test_07_restore_deleted_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to deleted files + await page.click('a.nav-link[data-view="deleted"]') + await page.wait_for_timeout(1000) + + # Select deleted file + await page.click('.file-item:has-text("test-file.txt") .file-checkbox') + await page.wait_for_timeout(500) + + # Click restore + await page.click('button:has-text("Restore")') + await page.wait_for_timeout(1000) + + # Go back to files + await page.click('a.nav-link[data-view="files"]') + await page.wait_for_timeout(1000) + + # File should be back + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_08_rename_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Right-click on folder or use context menu + await page.click('.folder-item:has-text("Test Folder")', button='right') + await page.wait_for_timeout(500) + + # Click rename + await page.click('text=Rename') + await page.wait_for_timeout(500) + + # Fill new name + await page.fill('input[placeholder="New name"]', 'Renamed Folder') + await page.click('button:has-text("Rename")') + await page.wait_for_timeout(1000) + + await expect(page.locator('text=Renamed Folder')).to_be_visible() + await expect(page.locator('text=Test Folder')).not_to_be_visible() + + async def test_09_star_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Click star on file + await page.click('.file-item:has-text("test-file.txt") .star-btn') + await page.wait_for_timeout(1000) + + # Go to starred + await page.click('a.nav-link[data-view="starred"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_10_search_files(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Type in search + await page.fill('#search-input', 'test-file') + await page.wait_for_timeout(1000) + + # Should show search results + await expect(page.locator('.search-results')).to_be_visible() + await expect(page.locator('text=test-file.txt')).to_be_visible() \ No newline at end of file diff --git a/tests/e2e/test_photo_gallery.py b/tests/e2e/test_photo_gallery.py new file mode 100644 index 0000000..3bad93b --- /dev/null +++ b/tests/e2e/test_photo_gallery.py @@ -0,0 +1,227 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestPhotoGallery: + + async def test_01_upload_image_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Upload an image file + await page.set_input_files('input[type="file"]', { + 'name': 'test-image.jpg', + 'mimeType': 'image/jpeg', + 'buffer': b'fake image data' # In real test, use actual image bytes + }) + + await page.click('button:has-text("Upload")') + await page.wait_for_timeout(2000) + + await expect(page.locator('text=test-image.jpg')).to_be_visible() + + async def test_02_navigate_to_photo_gallery(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('photo-gallery')).to_be_visible() + + async def test_03_view_image_in_gallery(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Should show uploaded image + await expect(page.locator('.photo-item')).to_be_visible() + + async def test_04_click_on_photo_to_preview(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Click on photo + await page.click('.photo-item img') + await page.wait_for_timeout(1000) + + # Should open preview + await expect(page.locator('file-preview')).to_be_visible() + + async def test_05_close_photo_preview(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Click on photo + await page.click('.photo-item img') + await page.wait_for_timeout(1000) + + # Close preview + await page.click('.preview-close-btn') + await page.wait_for_timeout(500) + + # Preview should be closed + await expect(page.locator('file-preview')).not_to_be_visible() + + async def test_06_upload_multiple_images(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Upload multiple images + await page.set_input_files('input[type="file"]', [ + { + 'name': 'image1.jpg', + 'mimeType': 'image/jpeg', + 'buffer': b'fake image 1' + }, + { + 'name': 'image2.png', + 'mimeType': 'image/png', + 'buffer': b'fake image 2' + } + ]) + + await page.click('button:has-text("Upload")') + await page.wait_for_timeout(2000) + + # Go to gallery + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Should show multiple photos + photo_count = await page.locator('.photo-item').count() + assert photo_count >= 2 + + async def test_07_filter_photos_by_date(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Click date filter + await page.click('.date-filter-btn') + await page.wait_for_timeout(500) + + # Select today + await page.click('text=Today') + await page.wait_for_timeout(1000) + + # Should filter photos + await expect(page.locator('.photo-item')).to_be_visible() + + async def test_08_search_photos(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Type in gallery search + await page.fill('.gallery-search-input', 'image1') + await page.wait_for_timeout(1000) + + # Should show filtered results + await expect(page.locator('text=image1.jpg')).to_be_visible() + await expect(page.locator('text=image2.png')).not_to_be_visible() + + async def test_09_create_album(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Click create album + await page.click('button:has-text("Create Album")') + await page.wait_for_timeout(500) + + # Fill album name + await page.fill('input[name="album-name"]', 'Test Album') + await page.click('button:has-text("Create")') + await page.wait_for_timeout(1000) + + # Should show album + await expect(page.locator('text=Test Album')).to_be_visible() + + async def test_10_add_photos_to_album(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + await page.click('a.nav-link[data-view="photos"]') + await page.wait_for_timeout(1000) + + # Select photos + await page.click('.photo-item .photo-checkbox', { force: True }) + await page.wait_for_timeout(500) + + # Click add to album + await page.click('button:has-text("Add to Album")') + await page.wait_for_timeout(500) + + # Select album + await page.click('text=Test Album') + await page.click('button:has-text("Add")') + await page.wait_for_timeout(1000) + + # Should show success + await expect(page.locator('text=Photos added to album')).to_be_visible() \ No newline at end of file diff --git a/tests/e2e/test_sharing.py b/tests/e2e/test_sharing.py new file mode 100644 index 0000000..cde8e84 --- /dev/null +++ b/tests/e2e/test_sharing.py @@ -0,0 +1,207 @@ +import pytest +import asyncio +from playwright.async_api import expect + +@pytest.mark.asyncio +class TestSharing: + + async def test_01_share_file_with_user(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Select file + await page.click('.file-item:has-text("test-file.txt") .file-checkbox') + await page.wait_for_timeout(500) + + # Click share + await page.click('button:has-text("Share")') + await page.wait_for_timeout(500) + + # Share modal should appear + await expect(page.locator('share-modal')).to_be_visible() + + # Fill share form + await page.fill('input[placeholder="Username to share with"]', 'billingtest') + await page.select_option('select[name="permission"]', 'read') + await page.click('button:has-text("Share")') + await page.wait_for_timeout(1000) + + # Modal should close + await expect(page.locator('share-modal')).not_to_be_visible() + + async def test_02_view_shared_items_as_recipient(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to shared items + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_03_download_shared_file(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to shared items + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + # Click download on shared file + await page.click('.file-item:has-text("test-file.txt") .download-btn') + await page.wait_for_timeout(1000) + + # Should trigger download (can't easily test actual download in e2e) + + async def test_04_create_public_link(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Select file + await page.click('.file-item:has-text("test-file.txt") .file-checkbox') + await page.wait_for_timeout(500) + + # Click share + await page.click('button:has-text("Share")') + await page.wait_for_timeout(500) + + # Click create public link + await page.click('button:has-text("Create Public Link")') + await page.wait_for_timeout(1000) + + # Should show link + await expect(page.locator('.public-link')).to_be_visible() + + async def test_05_access_public_link(self, page, base_url): + # First get the public link from previous test + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Get the public link + await page.click('.file-item:has-text("test-file.txt") .file-checkbox') + await page.click('button:has-text("Share")') + await page.wait_for_timeout(500) + + link_element = page.locator('.public-link input') + public_url = await link_element.get_attribute('value') + + # Logout + await page.click('#logout-btn') + await page.wait_for_timeout(1000) + + # Access public link + await page.goto(public_url) + await page.wait_for_load_state("networkidle") + + # Should be able to view/download the file + await expect(page.locator('text=test-file.txt')).to_be_visible() + + async def test_06_revoke_share(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to shared items view (as owner) + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + # Find the share and revoke + await page.click('.share-item .revoke-btn') + await page.wait_for_timeout(500) + + # Confirm revoke + await page.click('button:has-text("Confirm")') + await page.wait_for_timeout(1000) + + # Share should be gone + await expect(page.locator('.share-item')).not_to_be_visible() + + async def test_07_share_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'e2etestuser') + await page.fill('#login-form input[name="password"]', 'testpassword123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Select folder + await page.click('.folder-item:has-text("Renamed Folder") .folder-checkbox') + await page.wait_for_timeout(500) + + # Click share + await page.click('button:has-text("Share")') + await page.wait_for_timeout(500) + + # Share modal should appear + await expect(page.locator('share-modal')).to_be_visible() + + # Fill share form + await page.fill('input[placeholder="Username to share with"]', 'billingtest') + await page.select_option('select[name="permission"]', 'read') + await page.click('button:has-text("Share")') + await page.wait_for_timeout(1000) + + async def test_08_view_shared_folder_as_recipient(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to shared items + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + await expect(page.locator('text=Renamed Folder')).to_be_visible() + + async def test_09_navigate_into_shared_folder(self, page, base_url): + await page.goto(f"{base_url}/") + await page.wait_for_load_state("networkidle") + + await page.fill('#login-form input[name="username"]', 'billingtest') + await page.fill('#login-form input[name="password"]', 'password123') + await page.click('#login-form button[type="submit"]') + await page.wait_for_timeout(2000) + + # Go to shared items + await page.click('a.nav-link[data-view="shared"]') + await page.wait_for_timeout(1000) + + # Click on shared folder + await page.click('text=Renamed Folder') + await page.wait_for_timeout(1000) + + # Should see the file inside + await expect(page.locator('text=folder-file.txt')).to_be_visible() \ No newline at end of file