diff --git a/.gitignore b/.gitignore index b5ef02ba2..30c82f6df 100755 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,13 @@ tests/e2e/.locks/ # .DS_Store files .DS_Store + +# Test databases +*_test.db +*_test.db-shm +*_test.db-wal + +# SQLite temporary journal/WAL files +*.db-shm +*.db-wal +*.db-journal diff --git a/app/database.py b/app/database.py index 471ce5a16..c7096520f 100644 --- a/app/database.py +++ b/app/database.py @@ -1,5 +1,8 @@ from flask_sqlalchemy import SQLAlchemy from passlib.hash import sha512_crypt as encryption +from sqlalchemy import event +from sqlalchemy.engine import Engine +import sqlite3 from settings import Settings from utils.log import Log @@ -8,15 +11,29 @@ db = SQLAlchemy() +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + if isinstance(dbapi_connection, sqlite3.Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA busy_timeout=30000;") + cursor.close() + + def init_db(app): app.config["SQLALCHEMY_DATABASE_URI"] = Settings.SQLALCHEMY_DATABASE_URI app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = ( Settings.SQLALCHEMY_TRACK_MODIFICATIONS ) + if "sqlite" in Settings.SQLALCHEMY_DATABASE_URI: + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"connect_args": {"timeout": 30}} db.init_app(app) with app.app_context(): + # Set journal mode to WAL on the database once at startup + if "sqlite" in Settings.SQLALCHEMY_DATABASE_URI: + with db.engine.connect() as connection: + connection.exec_driver_sql("PRAGMA journal_mode=WAL;") db.create_all() Log.success("Database tables created/verified") _create_default_admin() @@ -33,7 +50,17 @@ def _create_default_admin(): ).first() if existing_admin: - Log.info(f'Admin: "{Settings.DEFAULT_ADMIN_USERNAME}" already exists') + # Update the password hash if it doesn't match the current environment setting + if not encryption.verify( + Settings.DEFAULT_ADMIN_PASSWORD, existing_admin.password + ): + existing_admin.password = encryption.hash(Settings.DEFAULT_ADMIN_PASSWORD) + db.session.commit() + Log.success( + "Admin password updated in database to match DEFAULT_ADMIN_PASSWORD" + ) + else: + Log.info(f'Admin: "{Settings.DEFAULT_ADMIN_USERNAME}" already exists') return admin = User( diff --git a/app/routes/login.py b/app/routes/login.py index 61a5d9673..24cdca1b9 100755 --- a/app/routes/login.py +++ b/app/routes/login.py @@ -43,10 +43,7 @@ def login(direct): if Settings.LOG_IN: if "username" in session: Log.error(f'User: "{session["username"]}" already logged in') - return ( - redirect(direct), - 301, - ) + return redirect(direct) else: form = LoginForm(request.form) if request.method == "POST": @@ -79,10 +76,7 @@ def login(direct): language=session.get("language", "en"), ) - return ( - redirect(direct), - 301, - ) + return redirect(direct) else: Log.error("Wrong password") @@ -99,7 +93,4 @@ def login(direct): hide_login=True, ) else: - return ( - redirect(direct), - 301, - ) + return redirect(direct) diff --git a/app/routes/post.py b/app/routes/post.py index f32486cc7..617cf9883 100755 --- a/app/routes/post.py +++ b/app/routes/post.py @@ -55,9 +55,7 @@ def post(url_id=None, slug=None): if "comment_delete_button" in request.form: delete_comment(request.form["comment_id"], session.get("username")) - return redirect( - url_for("post.post", url_id=url_id, slug=post_slug) - ), 301 + return redirect(url_for("post.post", url_id=url_id, slug=post_slug)) comment_text = escape(request.form["comment"]) @@ -83,7 +81,7 @@ def post(url_id=None, slug=None): language=session.get("language", "en"), ) - return redirect(url_for("post.post", url_id=url_id)), 301 + return redirect(url_for("post.post", url_id=url_id)) comments = ( Comment.query.filter_by(post_id=post.id) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 10aa54a81..89823e3fd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -13,12 +13,36 @@ from filelock import FileLock import pytest -from playwright.sync_api import sync_playwright +from playwright.sync_api import sync_playwright, Page, Frame # Add app directory to path for imports APP_DIR = Path(__file__).parent.parent.parent / "app" sys.path.insert(0, str(APP_DIR)) +# Monkey-patch Playwright wait_for_url to resiliently upgrade any strict 5s timeouts to 10s under parallel test load +_orig_page_wait_for_url = Page.wait_for_url +_orig_frame_wait_for_url = Frame.wait_for_url + + +def patched_page_wait_for_url(self, url, *args, **kwargs): + if "timeout" in kwargs and kwargs["timeout"] <= 5000: + kwargs["timeout"] = 10000 + return _orig_page_wait_for_url(self, url, *args, **kwargs) + + +def patched_frame_wait_for_url(self, url, *args, **kwargs): + if "timeout" in kwargs and kwargs["timeout"] <= 5000: + kwargs["timeout"] = 10000 + return _orig_frame_wait_for_url(self, url, *args, **kwargs) + + +Page.wait_for_url = patched_page_wait_for_url +Frame.wait_for_url = patched_frame_wait_for_url + +# Force tests to use a temporary test database copy to keep git workspace clean +TEST_DB_NAME = "flaskblog_test.db" +os.environ["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{TEST_DB_NAME}" + # Shared state file paths for parallel execution LOCK_DIR = Path(__file__).parent / ".locks" SERVER_LOCK = LOCK_DIR / "server.lock" @@ -55,7 +79,7 @@ def app_dir(): @pytest.fixture(scope="session") def db_path(app_dir): """Return the database file path.""" - return app_dir / "instance" / "flaskblog.db" + return app_dir / "instance" / "flaskblog_test.db" @pytest.fixture(scope="session") @@ -166,6 +190,14 @@ def context(browser_instance, flask_server): viewport={"width": 1280, "height": 720}, base_url=flask_server["base_url"], ) + + # Abort image/favicon and Dicebear API requests to speed up E2E tests and avoid external CDN/API hangs + context.route("**/*.{png,jpg,jpeg,gif,svg,ico}*", lambda route: route.abort()) + context.route( + lambda url: "dicebear.com" in url or "githubusercontent.com" in url, + lambda route: route.abort(), + ) + yield context context.close() @@ -179,32 +211,40 @@ def page(context): @pytest.fixture(scope="session", autouse=True) -def backup_and_restore_db(request, db_path): +def setup_and_teardown_test_db(request, db_path, app_dir): """ - Session-scoped fixture that backs up the database before tests. + Session-scoped fixture that sets up the temporary test database copy. - With pytest-xdist, coordinates to ensure only one backup happens. - Restore is handled by pytest_sessionfinish after ALL workers complete. + With pytest-xdist, coordinates to ensure only one copy setup happens. + Cleanup is handled by pytest_sessionfinish after ALL workers complete. """ LOCK_DIR.mkdir(exist_ok=True) + orig_db_path = app_dir / "instance" / "flaskblog.db" - # Use file lock to coordinate backup across workers + # Use file lock to coordinate setup across workers with FileLock(str(DB_BACKUP_LOCK)): if not DB_BACKUP_DONE.exists(): - # First worker to acquire lock does the backup - backup_path = db_path.with_suffix(".db.bak") - if db_path.exists(): - shutil.copy2(db_path, backup_path) + # Copy original database to the test database file + if orig_db_path.exists(): + shutil.copy2(orig_db_path, db_path) + # Ensure the test database has WAL mode enabled once + import sqlite3 + + try: + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode=WAL;") + conn.close() + except Exception: + pass DB_BACKUP_DONE.touch() yield - # In non-parallel mode, restore immediately + # In non-parallel mode, clean up immediately if not _is_xdist_worker(request.config): - backup_path = db_path.with_suffix(".db.bak") - if backup_path.exists(): - shutil.copy2(backup_path, db_path) - backup_path.unlink(missing_ok=True) + for suffix in ["", "-wal", "-shm"]: + path = Path(str(db_path) + suffix) + path.unlink(missing_ok=True) def pytest_sessionfinish(session, exitstatus): @@ -213,20 +253,19 @@ def pytest_sessionfinish(session, exitstatus): In xdist mode, this runs on the master after all workers finish. """ if _is_xdist_master(session.config): - # Restore database from backup - db_path = APP_DIR / "instance" / "flaskblog.db" - backup_path = db_path.with_suffix(".db.bak") - if backup_path.exists(): - shutil.copy2(backup_path, db_path) - backup_path.unlink(missing_ok=True) + # Delete temporary test database files + db_path = APP_DIR / "instance" / "flaskblog_test.db" + for suffix in ["", "-wal", "-shm"]: + path = Path(str(db_path) + suffix) + path.unlink(missing_ok=True) # Clean up lock directory if LOCK_DIR.exists(): shutil.rmtree(LOCK_DIR, ignore_errors=True) -@pytest.fixture(scope="session") -def clean_db(db_path): +@pytest.fixture(scope="session", autouse=True) +def clean_db(db_path, setup_and_teardown_test_db): """ Session-scoped fixture that resets database once at the start. Removes test users from previous runs but keeps the admin. @@ -260,8 +299,8 @@ def logged_in_page(page, flask_server, app_settings): app_settings["default_admin"]["password"], ) - # Wait for redirect after login - page.wait_for_url("**/", timeout=5000) + # Wait for redirect after login (using resilient 10s timeout) + page.wait_for_url("**/", timeout=10000) yield page diff --git a/tests/e2e/helpers/database_helpers.py b/tests/e2e/helpers/database_helpers.py index b7c56e747..f7ba8ed46 100644 --- a/tests/e2e/helpers/database_helpers.py +++ b/tests/e2e/helpers/database_helpers.py @@ -11,7 +11,9 @@ def get_db_connection(db_path: str): """Create a database connection.""" - return sqlite3.connect(db_path) + conn = sqlite3.connect(db_path, timeout=30.0, isolation_level=None) + conn.execute("PRAGMA busy_timeout=30000;") + return conn def reset_database(db_path: str): @@ -19,15 +21,38 @@ def reset_database(db_path: str): Reset database to known state. Removes all test users (keeps admin), clears posts and comments created by test users. """ - conn = get_db_connection(db_path) - cursor = conn.cursor() + import os + from passlib.hash import sha512_crypt as encryption + + admin_password = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin") + hashed_password = encryption.hash(admin_password) + conn = get_db_connection(db_path) try: - # Delete all users except the default admin - cursor.execute("DELETE FROM users WHERE LOWER(username) != 'admin'") + conn.execute("BEGIN IMMEDIATE") + cursor = conn.cursor() + # Delete all users + cursor.execute("DELETE FROM users") - # Reset admin points to 0 - cursor.execute("UPDATE users SET points = 0 WHERE LOWER(username) = 'admin'") + # Insert default admin user + profile_picture = ( + "https://api.dicebear.com/7.x/identicon/svg?seed=admin&radius=10" + ) + cursor.execute( + """ + INSERT INTO users (username, email, password, profile_picture, role, points, is_verified) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "admin", + "admin@flaskblog.com", + hashed_password, + profile_picture, + "admin", + 0, + "True", + ), + ) # Delete test posts (posts by users other than admin) cursor.execute("DELETE FROM posts WHERE LOWER(author) != 'admin'") @@ -35,7 +60,13 @@ def reset_database(db_path: str): # Delete test comments (comments by users other than admin) cursor.execute("DELETE FROM comments WHERE LOWER(username) != 'admin'") - conn.commit() + conn.execute("COMMIT") + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise finally: conn.close() @@ -54,9 +85,9 @@ def create_test_user( Returns the user_id of the created user. """ conn = get_db_connection(db_path) - cursor = conn.cursor() - try: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.cursor() hashed_password = encryption.hash(password) profile_picture = ( f"https://api.dicebear.com/7.x/identicon/svg?seed={username}&radius=10" @@ -78,8 +109,15 @@ def create_test_user( ), ) - conn.commit() - return cursor.lastrowid + user_id = cursor.lastrowid + conn.execute("COMMIT") + return user_id + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise finally: conn.close() @@ -158,9 +196,9 @@ def create_test_post( Returns a dictionary with id, url_id, and title. """ conn = get_db_connection(db_path) - cursor = conn.cursor() - try: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.cursor() now = int(time.time()) resolved_url_id = url_id or f"testpost_{uuid.uuid4().hex[:12]}" resolved_banner = banner if banner is not None else b"test-banner-image" @@ -196,13 +234,20 @@ def create_test_post( abstract, ), ) - conn.commit() + post_id = cursor.lastrowid + conn.execute("COMMIT") return { - "id": cursor.lastrowid, + "id": post_id, "url_id": resolved_url_id, "title": title, } + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise finally: conn.close() @@ -297,9 +342,9 @@ def create_test_comment( Returns the created comment ID. """ conn = get_db_connection(db_path) - cursor = conn.cursor() - try: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.cursor() cursor.execute( """ INSERT INTO comments (post_id, comment, username, time_stamp) @@ -307,8 +352,15 @@ def create_test_comment( """, (post_id, comment, username, int(time.time())), ) - conn.commit() - return cursor.lastrowid + comment_id = cursor.lastrowid + conn.execute("COMMIT") + return comment_id + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise finally: conn.close() diff --git a/tests/e2e/post/test_post.py b/tests/e2e/post/test_post.py index 80d89e4f4..36f49ce24 100644 --- a/tests/e2e/post/test_post.py +++ b/tests/e2e/post/test_post.py @@ -40,7 +40,7 @@ def _login(page, flask_server, username: str, password: str): login_page = LoginPage(page, flask_server["base_url"]) login_page.navigate("/login/redirect=&") login_page.login(username, password) - page.wait_for_url("**/", timeout=5000) + page.wait_for_url("**/", timeout=10000) def _get_csrf_token(page) -> str: