From 373a21f32366b11bae7442c5cdfc3fe0746ca58c Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 14 May 2026 06:22:30 +0200 Subject: [PATCH] test(fixtures): enable SQLite FK enforcement and seed baseline roles - Turn on PRAGMA foreign_keys=ON for every SQLite connection so ondelete="CASCADE" and other FK constraints are exercised by tests. - Disable FK enforcement only for DROP TABLE statements, since the schema has cyclic references (deals/leads/projects/quotes) and drop_all() cannot order them cleanly. - Seed admin/user/manager/subcontractor roles in the app fixture so route tests that validate against the role table no longer need to run the full permission seed command. - Make TimeEntryFactory.end_time deterministic relative to start_time so created entries always represent a valid 2h window. --- tests/conftest.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ tests/factories.py | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 019d65b8..5631c180 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,10 +14,53 @@ if "INSTALLATION_CONFIG_DIR" not in os.environ: from datetime import datetime, timedelta from decimal import Decimal +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.pool import NullPool from app import create_app, db + +# Enable SQLite foreign key enforcement (including ON DELETE CASCADE). +# SQLite has foreign keys disabled by default per-connection, which breaks +# any test relying on ondelete="CASCADE" at the DB level. +@event.listens_for(Engine, "connect") +def _enable_sqlite_foreign_keys(dbapi_connection, connection_record): # pragma: no cover - infra hook + try: + # Only act on sqlite connections. The DBAPI connection class name + # check avoids importing sqlite3 at module import time on non-sqlite envs. + if dbapi_connection.__class__.__module__.startswith("sqlite3"): + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys=ON") + finally: + cursor.close() + except Exception: + # Never let this hook break a connection. + pass + + +# Auto-disable FK enforcement immediately before DROP TABLE statements run. +# The schema has cyclic foreign-key references between tables (e.g. deals, +# leads, projects, quotes), so SQLAlchemy can't order DROPs cleanly and any +# drop_all() call would otherwise fail with "FOREIGN KEY constraint failed". +@event.listens_for(Engine, "before_cursor_execute") +def _disable_fk_for_drop( # pragma: no cover - infra hook + conn, cursor, statement, parameters, context, executemany +): + try: + if not statement: + return + # Cheap prefix check; matches "DROP TABLE ..." (case-insensitive) + stripped = statement.lstrip() + if stripped[:10].upper().startswith("DROP TABLE"): + # Detect SQLite via the dialect to keep this no-op for other engines. + if conn.dialect.name == "sqlite": + cursor.execute("PRAGMA foreign_keys=OFF") + except Exception: + # Never let this hook break statement execution. + pass + # Import all models to ensure their tables are created by db.create_all() from app.models import ( User, @@ -285,6 +328,14 @@ def app(app_config): # Ignore errors - table might already exist or have dependency issues pass + # Several route tests submit admin/user forms that validate against the + # role table. Keep a minimal role baseline available without requiring + # the full permission seeding command in every isolated test database. + for role_name in ("admin", "user", "manager", "subcontractor"): + if Role.query.filter_by(name=role_name).first() is None: + db.session.add(Role(name=role_name, description=f"Test {role_name} role", is_system_role=True)) + db.session.commit() + # Create default settings settings = Settings() db.session.add(settings) diff --git a/tests/factories.py b/tests/factories.py index 0167884a..461e4e01 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -91,7 +91,7 @@ class TimeEntryFactory(_SessionFactory): user_id = factory.SelfAttribute("user_fk.id") project_id = factory.SelfAttribute("project_fk.id") start_time = factory.LazyFunction(lambda: _dt.datetime.now() - _dt.timedelta(hours=2)) - end_time = factory.LazyFunction(lambda: _dt.datetime.now()) + end_time = factory.LazyAttribute(lambda o: o.start_time + _dt.timedelta(hours=2)) notes = factory.Faker("sentence") tags = "test,automation" source = "manual"