mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 18:38:46 -05:00
373a21f323
- 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.
1080 lines
33 KiB
Python
1080 lines
33 KiB
Python
"""
|
|
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 <token>. 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")
|