""" Pytest configuration and shared fixtures for TimeTracker tests. This file contains common fixtures and test configuration used across all test modules. """ import pytest import os import tempfile import uuid # Set before app is imported so InstallationConfig uses a writable dir in tests (avoids /data on CI) if "INSTALLATION_CONFIG_DIR" not in os.environ: os.environ["INSTALLATION_CONFIG_DIR"] = tempfile.mkdtemp(prefix="timetracker_install_") 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, Project, TimeEntry, Client, Settings, Invoice, InvoiceItem, Task, TaskActivity, Comment, ExpenseCategory, Mileage, PerDiem, PerDiemRate, ExtraGood, FocusSession, RecurringBlock, RateOverride, SavedFilter, ProjectCost, KanbanColumn, TimeEntryTemplate, Activity, UserFavoriteProject, UserSmartNotificationDismissal, UserClient, ClientNote, WeeklyTimeGoal, Expense, Permission, Role, ApiToken, CalendarEvent, BudgetAlert, DataImport, DataExport, InvoicePDFTemplate, ClientPrepaidConsumption, AuditLog, RecurringInvoice, InvoiceEmail, InvoicePeppolTransmission, Webhook, WebhookDelivery, InvoiceTemplate, Currency, ExchangeRate, TaxRule, Payment, CreditNote, InvoiceReminderSchedule, SavedReportView, ReportEmailSchedule, Warehouse, StockItem, WarehouseStock, StockMovement, StockReservation, ProjectStockAllocation, Quote, QuoteItem, ) # ---------------------------------------------------------------------------- # Time control helpers (freezegun) # ---------------------------------------------------------------------------- @pytest.fixture def time_freezer(): """ Utility fixture to freeze time during a test. Usage: freezer = time_freezer() # freezes at default "2024-01-01 09:00:00" # ... run code ... freezer.stop() # or with a custom timestamp: f = time_freezer("2024-06-15 12:30:00") # ... run code ... f.stop() """ from freezegun import freeze_time as _freeze_time _active = [] def _start(at: str = "2024-01-01 09:00:00"): f = _freeze_time(at) f.start() _active.append(f) return f try: yield _start finally: while _active: f = _active.pop() try: f.stop() except Exception: pass # ============================================================================ # Application Fixtures # ============================================================================ @pytest.fixture(scope="session") def app_config(): """Base test configuration.""" return { "TESTING": True, # Use file-based SQLite to ensure consistent connections across contexts/threads "SQLALCHEMY_DATABASE_URI": "sqlite:///pytest_main.sqlite", # Mitigate SQLite 'database is locked' by increasing busy timeout and enabling pre-ping "SQLALCHEMY_ENGINE_OPTIONS": { "pool_pre_ping": True, "connect_args": {"timeout": 30}, "poolclass": NullPool, }, "FLASK_ENV": "testing", "SQLALCHEMY_TRACK_MODIFICATIONS": False, "WTF_CSRF_ENABLED": False, "SECRET_KEY": "test-secret-key-do-not-use-in-production", "SERVER_NAME": "localhost:5000", "APPLICATION_ROOT": "/", "PREFERRED_URL_SCHEME": "http", "SESSION_COOKIE_HTTPONLY": True, # Ensure a stable locale for Babel-dependent formatting in tests "BABEL_DEFAULT_LOCALE": "en", } @pytest.fixture(scope="function") def app(app_config): """Create application for testing with function scope.""" # Use a unique SQLite file per test function to avoid Windows file locking unique_db_path = os.path.join(tempfile.gettempdir(), f"pytest_{uuid.uuid4().hex}.sqlite") config = dict(app_config) config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{unique_db_path}" app = create_app(config) with app.app_context(): # Import all models AFTER app creation but BEFORE db.create_all() # This ensures they're registered with SQLAlchemy's metadata # Import all models explicitly to ensure their tables are created from app.models import ( User, Project, TimeEntry, Client, Settings, Invoice, InvoiceItem, Task, TaskActivity, Comment, ExpenseCategory, Mileage, PerDiem, PerDiemRate, ExtraGood, FocusSession, RecurringBlock, RateOverride, SavedFilter, ProjectCost, KanbanColumn, TimeEntryTemplate, Activity, UserFavoriteProject, UserClient, ClientNote, WeeklyTimeGoal, Expense, Permission, Role, ApiToken, CalendarEvent, BudgetAlert, DataImport, DataExport, InvoicePDFTemplate, ClientPrepaidConsumption, AuditLog, RecurringInvoice, InvoiceEmail, InvoicePeppolTransmission, Webhook, WebhookDelivery, InvoiceTemplate, Currency, ExchangeRate, TaxRule, Payment, CreditNote, InvoiceReminderSchedule, SavedReportView, ReportEmailSchedule, ) # Ensure any lingering connections are closed to avoid SQLite file locks (Windows) try: db.engine.dispose() except Exception: pass # Drop all tables first to ensure clean state try: db.drop_all() except Exception: pass # Ignore errors if tables don't exist # Create all tables, handling index creation errors gracefully # We need to create tables even if some indexes already exist # SQLAlchemy's create_all() stops on first error, so we need to handle this carefully try: db.create_all() except Exception as e: # SQLite may raise OperationalError if indexes already exist # This can happen if db.create_all() is called multiple times error_msg = str(e).lower() if "index" in error_msg and ("already exists" in error_msg or "duplicate" in error_msg): # Index already exists - this is okay, but we need to ensure all tables are created # Create tables individually to work around the issue from sqlalchemy import inspect inspector = inspect(db.engine) existing_tables = set(inspector.get_table_names()) # Create missing tables explicitly for table_name, table in db.metadata.tables.items(): if table_name not in existing_tables: try: table.create(db.engine, checkfirst=True) except Exception as table_error: # Ignore errors for individual tables (might be index issues) pass else: # Log other errors but try to continue import logging import traceback logger = logging.getLogger(__name__) logger.warning(f"Error during db.create_all(): {e}") logger.warning(traceback.format_exc()) # Verify critical tables were created and create any missing ones from sqlalchemy import inspect inspector = inspect(db.engine) created_tables = set(inspector.get_table_names()) required_tables = ["time_entries", "tasks", "users", "projects"] missing_tables = [t for t in required_tables if t not in created_tables] if missing_tables: # Try to create missing tables explicitly for table_name in missing_tables: if table_name in db.metadata.tables: try: db.metadata.tables[table_name].create(db.engine, checkfirst=True) except Exception as e: # 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) db.session.commit() yield app db.session.remove() try: db.drop_all() except Exception: pass # Ignore errors during cleanup try: db.engine.dispose() except Exception: pass # Remove the per-test database file try: if os.path.exists(unique_db_path): os.remove(unique_db_path) except Exception: pass @pytest.fixture(scope="function") def client(app): """Create test client.""" return app.test_client() @pytest.fixture(scope="function") def runner(app): """Create test CLI runner.""" return app.test_cli_runner() # ============================================================================ # Database Fixtures # ============================================================================ @pytest.fixture(scope="function") def db_session(app): """Create a database session for tests.""" with app.app_context(): yield db.session # ============================================================================ # User Fixtures # ============================================================================ @pytest.fixture def user(app): """Create a regular test user.""" # Idempotent: return existing test user if already present (PostgreSQL CI) try: existing = User.query.filter_by(username="testuser").first() if existing: if not existing.is_active: existing.is_active = True db.session.commit() # Ensure password is set for login endpoint if not existing.check_password("password123"): existing.set_password("password123") db.session.commit() db.session.refresh(existing) return existing except Exception: # Tables don't exist yet or other DB error, rollback and proceed to create user db.session.rollback() try: user = User(username="testuser", role="user", email="testuser@example.com") user.is_active = True # Set after creation user.set_password("password123") # Set password for login endpoint db.session.add(user) db.session.commit() # Refresh to ensure all relationships are loaded and object stays in session db.session.refresh(user) return user except Exception: # If tables still don't exist, try to create them db.session.rollback() db.create_all() # Try again after creating tables user = User(username="testuser", role="user", email="testuser@example.com") user.is_active = True # Set after creation user.set_password("password123") # Set password for login endpoint db.session.add(user) db.session.commit() db.session.refresh(user) return user @pytest.fixture def admin_user(app): """Create an admin test user.""" # Idempotent: return existing admin user if already present (PostgreSQL CI) try: existing = User.query.filter_by(username="admin").first() if existing: if existing.role != "admin": existing.role = "admin" existing.is_active = True db.session.commit() # Ensure password is set for login endpoint if not existing.check_password("password123"): existing.set_password("password123") db.session.commit() db.session.refresh(existing) return existing except Exception: # Tables don't exist yet or other DB error, rollback and proceed to create admin db.session.rollback() try: admin = User(username="admin", role="admin", email="admin@example.com") admin.is_active = True # Set after creation admin.set_password("password123") # Set password for login endpoint db.session.add(admin) db.session.commit() # Refresh to ensure all relationships are loaded and object stays in session db.session.refresh(admin) return admin except Exception: # If tables still don't exist, try to create them db.session.rollback() db.create_all() # Try again after creating tables admin = User(username="admin", role="admin", email="admin@example.com") admin.is_active = True # Set after creation admin.set_password("password123") # Set password for login endpoint db.session.add(admin) db.session.commit() db.session.refresh(admin) return admin @pytest.fixture def auth_user(user): """Alias for user fixture (for backward compatibility with older tests).""" return user @pytest.fixture def multiple_users(app): """Create multiple test users.""" users = [] for i in range(1, 4): user = User(username=f"user{i}", role="user", email=f"user{i}@example.com") user.is_active = True # Set after creation users.append(user) db.session.add_all(users) db.session.commit() for user in users: db.session.refresh(user) return users # ============================================================================ # Client Fixtures # ============================================================================ @pytest.fixture def test_client(app, user): """Create a test client (business client, not test client).""" client_model = Client( name="Test Client Corp", description="Test client for integration tests", contact_person="John Doe", email="john@testclient.com", phone="+1 (555) 123-4567", address="123 Test Street, Test City, TC 12345", default_hourly_rate=Decimal("85.00"), ) client_model.status = "active" # Set after creation db.session.add(client_model) # Flush to assign primary key before commit to avoid expired attribute reloads db.session.flush() client_id = client_model.id db.session.commit() # Re-query to ensure we return a persistent instance without relying on refresh persisted_client = Client.query.get(client_id) or Client.query.filter_by(id=client_id).first() # Fallback to the original instance if re-query unexpectedly returns None return persisted_client or client_model @pytest.fixture def multiple_clients(app, user): """Create multiple test clients.""" clients = [] for i in range(1, 4): client = Client( name=f"Client {i}", email=f"client{i}@example.com", default_hourly_rate=Decimal("75.00") + Decimal(i * 10) ) client.status = "active" # Set after creation clients.append(client) db.session.add_all(clients) db.session.commit() for client in clients: db.session.refresh(client) return clients # ============================================================================ # Project Fixtures # ============================================================================ @pytest.fixture def project(app, test_client): """Create a test project.""" # Resolve client_id robustly to avoid issues with expired/detached instances try: cid = getattr(test_client, "id", None) except Exception: cid = None if not cid: existing = Client.query.filter_by(name="Test Client Corp").first() or Client.query.first() if existing: cid = existing.id else: fallback = Client( name="Test Client Corp", email="john@testclient.com", default_hourly_rate=Decimal("85.00") ) fallback.status = "active" db.session.add(fallback) db.session.flush() cid = fallback.id project = Project( name="Test Project", client_id=cid, description="Test project description", billable=True, hourly_rate=Decimal("75.00"), ) project.status = "active" # Set after creation db.session.add(project) # Flush to assign ID before commit and return the same instance to avoid re-query issues db.session.flush() db.session.commit() return project @pytest.fixture def multiple_projects(app, test_client): """Create multiple test projects.""" # Resolve client_id robustly try: cid = getattr(test_client, "id", None) except Exception: cid = None if not cid: existing = Client.query.filter_by(name="Test Client Corp").first() or Client.query.first() if existing: cid = existing.id else: fallback = Client( name="Test Client Corp", email="john@testclient.com", default_hourly_rate=Decimal("85.00") ) fallback.status = "active" db.session.add(fallback) db.session.flush() cid = fallback.id projects = [] for i in range(1, 4): project = Project( name=f"Project {i}", client_id=cid, description=f"Test project {i}", billable=True, hourly_rate=Decimal("75.00"), ) project.status = "active" # Set after creation projects.append(project) db.session.add_all(projects) db.session.commit() for proj in projects: db.session.refresh(proj) return projects # ============================================================================ # Time Entry Fixtures # ============================================================================ @pytest.fixture def time_entry(app, user, project): """Create a single time entry.""" start_time = datetime.utcnow() - timedelta(hours=2) end_time = datetime.utcnow() entry = TimeEntry( user_id=user.id, project_id=project.id, start_time=start_time, end_time=end_time, notes="Test time entry", tags="test,development", source="manual", billable=True, ) db.session.add(entry) db.session.commit() # Refresh entry, but handle case where related objects might be deleted try: db.session.refresh(entry) except Exception: # If refresh fails, just return the entry as-is # This can happen if user/project are deleted before this fixture is used pass return entry @pytest.fixture def multiple_time_entries(app, user, project): """Create multiple time entries.""" base_time = datetime.utcnow() - timedelta(days=7) entries = [] for i in range(5): start = base_time + timedelta(days=i, hours=9) end = base_time + timedelta(days=i, hours=17) entry = TimeEntry( user_id=user.id, project_id=project.id, start_time=start, end_time=end, notes=f"Work day {i+1}", tags="development,testing", source="manual", billable=True, ) entries.append(entry) db.session.add_all(entries) db.session.commit() for entry in entries: db.session.refresh(entry) return entries @pytest.fixture def active_timer(app, user, project): """Create an active timer (time entry without end time).""" timer = TimeEntry( user_id=user.id, project_id=project.id, start_time=datetime.utcnow(), notes="Active timer", source="auto", billable=True, ) db.session.add(timer) db.session.commit() db.session.refresh(timer) return timer # ============================================================================ # Task Fixtures # ============================================================================ @pytest.fixture def task(app, project, user): """Create a test task.""" task = Task( name="Test Task", description="Test task description", project_id=project.id, priority="medium", created_by=user.id, ) task.status = "todo" # Set after creation db.session.add(task) db.session.commit() db.session.refresh(task) return task # ============================================================================ # Invoice Fixtures # ============================================================================ @pytest.fixture def invoice(app, user, project, test_client): """Create a test invoice.""" from datetime import date from factories import InvoiceFactory invoice = InvoiceFactory( invoice_number=Invoice.generate_invoice_number(), project_id=project.id, client_id=test_client.id, client_name=test_client.name, due_date=date.today() + timedelta(days=30), created_by=user.id, tax_rate=Decimal("20.00"), status="draft", ) db.session.commit() db.session.refresh(invoice) return invoice @pytest.fixture def invoice_with_items(app, invoice): """Create an invoice with items.""" from factories import InvoiceItemFactory items = [ InvoiceItemFactory( invoice_id=invoice.id, description="Development work", quantity=Decimal("10.00"), unit_price=Decimal("75.00"), ), InvoiceItemFactory( invoice_id=invoice.id, description="Testing work", quantity=Decimal("5.00"), unit_price=Decimal("60.00") ), ] db.session.commit() invoice.calculate_totals() db.session.commit() db.session.refresh(invoice) for item in items: db.session.refresh(item) return invoice, items # ============================================================================ # Authentication Fixtures # ============================================================================ @pytest.fixture def authenticated_client(client, user): """Create an authenticated test client.""" # Use the actual login endpoint to properly authenticate # If CSRF is enabled, fetch a token and include it in the form submit try: from flask import current_app csrf_enabled = bool(current_app.config.get("WTF_CSRF_ENABLED")) except Exception: csrf_enabled = False login_data = {"username": user.username, "password": "password123"} headers = {} if csrf_enabled: try: resp = client.get("/auth/csrf-token") token = "" if resp.is_json: token = (resp.get_json() or {}).get("csrf_token") or "" login_data["csrf_token"] = token headers["X-CSRFToken"] = token except Exception: pass client.post("/login", data=login_data, headers=headers or None, follow_redirects=True) return client @pytest.fixture def admin_authenticated_client(client, admin_user): """Create an authenticated admin test client.""" # Use the actual login endpoint to properly authenticate (same as authenticated_client) # If CSRF is enabled, fetch a token and include it in the form submit try: from flask import current_app csrf_enabled = bool(current_app.config.get("WTF_CSRF_ENABLED")) except Exception: csrf_enabled = False login_data = {"username": admin_user.username, "password": "password123"} headers = {} if csrf_enabled: try: resp = client.get("/auth/csrf-token") token = "" if resp.is_json: token = (resp.get_json() or {}).get("csrf_token") or "" login_data["csrf_token"] = token headers["X-CSRFToken"] = token except Exception: pass client.post("/login", data=login_data, headers=headers or None, follow_redirects=True) return client @pytest.fixture def auth_headers(user): """Create authentication headers for API tests (session-based).""" # Note: For tests that use headers, they should use authenticated_client instead # This fixture is here for backward compatibility return {} # Default scopes for API token (full access to common resources) DEFAULT_API_TOKEN_SCOPES = ( "read:projects,write:projects,read:time_entries,write:time_entries," "read:tasks,write:tasks,read:clients,write:clients,read:reports,read:users" ) @pytest.fixture def api_token(app, user): """Create an API token for the given user with default full scopes. Returns (token_model, plain_token).""" with app.app_context(): token, plain_token = ApiToken.create_token( user_id=user.id, name="Test API Token", scopes=DEFAULT_API_TOKEN_SCOPES, expires_days=30, ) db.session.add(token) db.session.commit() return token, plain_token @pytest.fixture def client_with_token(app, api_token): """Test client with Authorization: Bearer . Use for API tests.""" token_model, plain_token = api_token test_client = app.test_client() test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client @pytest.fixture def scope_restricted_user(app, test_client): """ User with subcontractor role and one assigned client (scope-restricted). Use with project fixture that uses this client so user_can_access_project is True for that project only. """ role = Role.query.filter_by(name="subcontractor").first() if not role: role = Role(name="subcontractor", description="Restricted to assigned clients") db.session.add(role) db.session.flush() sub_user = User( username="scope_restricted_user", email="sub@example.com", role="user", ) sub_user.is_active = True sub_user.set_password("password123") db.session.add(sub_user) db.session.flush() if role not in sub_user.roles: sub_user.roles.append(role) db.session.flush() # Assign the single test client so this user can only access that client and its projects uc = UserClient(user_id=sub_user.id, client_id=test_client.id) db.session.add(uc) db.session.commit() db.session.refresh(sub_user) # Force load relationships so they are available when user is used in tests _ = list(sub_user.roles) _ = list(sub_user.assigned_clients.all()) return sub_user @pytest.fixture def scope_restricted_authenticated_client(client, scope_restricted_user): """Test client logged in as scope_restricted_user (subcontractor with one assigned client).""" login_data = {"username": scope_restricted_user.username, "password": "password123"} headers = {} try: from flask import current_app if current_app.config.get("WTF_CSRF_ENABLED"): resp = client.get("/auth/csrf-token") token = (resp.get_json() or {}).get("csrf_token", "") if resp.is_json else "" login_data["csrf_token"] = token headers["X-CSRFToken"] = token except Exception: pass client.post("/login", data=login_data, headers=headers or None, follow_redirects=True) return client @pytest.fixture def regular_user(user): """Alias for user fixture (regular non-admin user).""" return user # ============================================================================ # Utility Fixtures # ============================================================================ @pytest.fixture def temp_file(): """Create a temporary file for testing.""" fd, path = tempfile.mkstemp() yield path os.close(fd) os.unlink(path) @pytest.fixture def temp_dir(): """Create a temporary directory for testing.""" dirpath = tempfile.mkdtemp() yield dirpath import shutil shutil.rmtree(dirpath) # ============================================================================ # Alias Fixtures (for compatibility with different test naming conventions) # ============================================================================ @pytest.fixture def test_client_obj(test_client): """Alias for test_client to avoid naming conflicts""" return test_client @pytest.fixture def test_user(user): """Alias for user fixture""" return user @pytest.fixture def auth_user(user): """Alias for user fixture""" return user @pytest.fixture def test_project(project): """Alias for project fixture""" return project @pytest.fixture def test_task(task): """Alias for task fixture""" return task # ============================================================================ # Installation Config Fixture # ============================================================================ @pytest.fixture def installation_config(temp_dir): """Create a temporary installation config for testing""" from app.utils.installation import InstallationConfig # Override the config directory to use temp directory original_config_dir = InstallationConfig.CONFIG_DIR InstallationConfig.CONFIG_DIR = temp_dir # Create the config instance config = InstallationConfig() yield config # Restore original config directory InstallationConfig.CONFIG_DIR = original_config_dir # ============================================================================ # OpenTelemetry teardown (allows each test app to re-run init_opentelemetry) # ============================================================================ @pytest.fixture(autouse=True) def _reset_opentelemetry_after_test(): yield try: from app.telemetry.otel_setup import reset_for_testing reset_for_testing() except Exception: pass # ============================================================================ # Pytest Markers # ============================================================================ def pytest_configure(config): """Configure custom pytest markers.""" config.addinivalue_line("markers", "smoke: Quick smoke tests") config.addinivalue_line("markers", "unit: Unit tests") config.addinivalue_line("markers", "integration: Integration tests") config.addinivalue_line("markers", "api: API endpoint tests") config.addinivalue_line("markers", "database: Database-related tests") config.addinivalue_line("markers", "models: Model tests") config.addinivalue_line("markers", "routes: Route tests") config.addinivalue_line("markers", "security: Security tests") config.addinivalue_line("markers", "performance: Performance tests") config.addinivalue_line("markers", "slow: Slow running tests")