mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 07:40:51 -06:00
Update test files: test_client_portal, test_delete_actions, test_routes, test_uploads_persistence
This commit is contained in:
@@ -11,10 +11,62 @@ This module tests:
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.exc import PendingRollbackError
|
||||
from app.models import User, Client, Project, Invoice, InvoiceItem, TimeEntry
|
||||
from app import db
|
||||
|
||||
|
||||
def safe_commit_with_retry(max_retries=3):
|
||||
"""Safely commit with retry logic for database locks
|
||||
|
||||
This is needed because audit logging can cause database locks during parallel
|
||||
test execution. If commit fails, we rollback and retry.
|
||||
|
||||
Note: If the commit fails due to audit logging, the transaction is rolled back,
|
||||
so the data changes are lost. This function will retry the commit, but if it
|
||||
continues to fail, the data may not be saved. The caller should verify the data
|
||||
was actually saved.
|
||||
"""
|
||||
import time
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
# If commit fails, rollback and retry after a short delay
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wait a bit before retrying (exponential backoff)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(0.1 * (2 ** attempt))
|
||||
else:
|
||||
# On final attempt, just rollback and return False
|
||||
# The caller should verify if data was actually saved
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def safe_get_user(user_id):
|
||||
"""Safely get a user, handling rollback errors from database locks
|
||||
|
||||
This is needed because audit logging can cause database locks during parallel
|
||||
test execution, which leaves the session in a rolled-back state.
|
||||
"""
|
||||
try:
|
||||
return User.query.get(user_id)
|
||||
except PendingRollbackError:
|
||||
# If session was rolled back due to database lock, rollback and retry
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
# If rollback fails, create a new session context
|
||||
pass
|
||||
return User.query.get(user_id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model Tests
|
||||
# ============================================================================
|
||||
@@ -91,6 +143,7 @@ class TestClientPortalUserModel:
|
||||
def test_get_client_portal_data_with_invoices(self, app, user, test_client):
|
||||
"""Test get_client_portal_data includes invoices"""
|
||||
with app.app_context():
|
||||
user_id = user.id
|
||||
# Use no_autoflush to prevent audit logging from interfering
|
||||
with db.session.no_autoflush:
|
||||
user.client_portal_enabled = True
|
||||
@@ -99,10 +152,18 @@ class TestClientPortalUserModel:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
commit_success = safe_commit_with_retry()
|
||||
|
||||
# Verify user was actually updated (commit might have failed)
|
||||
user = safe_get_user(user_id)
|
||||
if not commit_success or not user.client_portal_enabled or user.client_id != test_client.id:
|
||||
# Re-apply changes if commit failed
|
||||
user.client_portal_enabled = True
|
||||
user.client_id = test_client.id
|
||||
db.session.merge(user)
|
||||
safe_commit_with_retry()
|
||||
user = safe_get_user(user_id)
|
||||
|
||||
project = Project(name="Test Project", client_id=test_client.id)
|
||||
db.session.add(project)
|
||||
@@ -129,8 +190,11 @@ class TestClientPortalUserModel:
|
||||
total_amount=Decimal("200.00"),
|
||||
)
|
||||
db.session.add_all([invoice1, invoice2])
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Get fresh user to avoid session attachment issues
|
||||
user = safe_get_user(user.id)
|
||||
data = user.get_client_portal_data()
|
||||
assert len(data["invoices"]) == 2
|
||||
assert invoice1 in data["invoices"]
|
||||
@@ -139,6 +203,7 @@ class TestClientPortalUserModel:
|
||||
def test_get_client_portal_data_with_time_entries(self, app, user, test_client):
|
||||
"""Test get_client_portal_data includes time entries"""
|
||||
with app.app_context():
|
||||
user_id = user.id
|
||||
# Use no_autoflush to prevent audit logging from interfering
|
||||
with db.session.no_autoflush:
|
||||
user.client_portal_enabled = True
|
||||
@@ -147,14 +212,22 @@ class TestClientPortalUserModel:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
commit_success = safe_commit_with_retry()
|
||||
|
||||
# Verify user was actually updated (commit might have failed)
|
||||
user = safe_get_user(user_id)
|
||||
if not commit_success or not user.client_portal_enabled or user.client_id != test_client.id:
|
||||
# Re-apply changes if commit failed
|
||||
user.client_portal_enabled = True
|
||||
user.client_id = test_client.id
|
||||
db.session.merge(user)
|
||||
safe_commit_with_retry()
|
||||
user = safe_get_user(user_id)
|
||||
|
||||
project = Project(name="Test Project", client_id=test_client.id)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Create time entries
|
||||
entry1 = TimeEntry(
|
||||
@@ -172,8 +245,11 @@ class TestClientPortalUserModel:
|
||||
duration_seconds=3600,
|
||||
)
|
||||
db.session.add_all([entry1, entry2])
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Get fresh user to avoid session attachment issues
|
||||
user = safe_get_user(user.id)
|
||||
data = user.get_client_portal_data()
|
||||
assert len(data["time_entries"]) == 2
|
||||
assert entry1 in data["time_entries"]
|
||||
@@ -211,10 +287,12 @@ class TestClientPortalRoutes:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(user.id)
|
||||
@@ -234,10 +312,12 @@ class TestClientPortalRoutes:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(user.id)
|
||||
@@ -256,10 +336,12 @@ class TestClientPortalRoutes:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(user.id)
|
||||
@@ -278,10 +360,12 @@ class TestClientPortalRoutes:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(user.id)
|
||||
@@ -300,10 +384,12 @@ class TestClientPortalRoutes:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
# Create another client
|
||||
other_client = Client(name="Other Client")
|
||||
@@ -348,7 +434,7 @@ class TestAdminClientPortalManagement:
|
||||
"""Test admin can enable client portal for user"""
|
||||
with app.app_context():
|
||||
# Get the edit form page first to get CSRF token
|
||||
get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit")
|
||||
get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit", follow_redirects=True)
|
||||
assert get_response.status_code == 200
|
||||
|
||||
# Extract CSRF token from the form if available
|
||||
@@ -374,7 +460,7 @@ class TestAdminClientPortalManagement:
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify user was updated
|
||||
updated_user = User.query.get(user.id)
|
||||
updated_user = safe_get_user(user.id)
|
||||
assert updated_user.client_portal_enabled is True
|
||||
assert updated_user.client_id == test_client.id
|
||||
|
||||
@@ -390,13 +476,15 @@ class TestAdminClientPortalManagement:
|
||||
db.session.flush()
|
||||
|
||||
# Commit outside no_autoflush block
|
||||
db.session.commit()
|
||||
# Use safe_commit_with_retry to handle database locks from audit logging
|
||||
safe_commit_with_retry()
|
||||
|
||||
# Query for user fresh in current session to avoid session attachment issues
|
||||
user = User.query.get(user.id)
|
||||
# This handles PendingRollbackError if session was rolled back due to audit log lock
|
||||
user = safe_get_user(user.id)
|
||||
|
||||
# Get the edit form page first to get CSRF token
|
||||
get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit")
|
||||
get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit", follow_redirects=True)
|
||||
assert get_response.status_code == 200
|
||||
|
||||
# Extract CSRF token from the form if available
|
||||
@@ -420,7 +508,7 @@ class TestAdminClientPortalManagement:
|
||||
)
|
||||
|
||||
# Verify user was updated
|
||||
updated_user = User.query.get(user.id)
|
||||
updated_user = safe_get_user(user.id)
|
||||
assert updated_user.client_portal_enabled is False
|
||||
assert updated_user.client_id is None
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def test_task_view_shows_delete_button(authenticated_client, task, app):
|
||||
@pytest.mark.routes
|
||||
def test_client_view_shows_delete_button(admin_authenticated_client, test_client, app):
|
||||
with app.app_context():
|
||||
resp = admin_authenticated_client.get(f"/clients/{test_client.id}")
|
||||
resp = admin_authenticated_client.get(f"/clients/{test_client.id}", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "Delete Client" in html
|
||||
@@ -25,7 +25,7 @@ def test_client_view_shows_delete_button(admin_authenticated_client, test_client
|
||||
@pytest.mark.routes
|
||||
def test_project_view_shows_delete_button(admin_authenticated_client, project, app):
|
||||
with app.app_context():
|
||||
resp = admin_authenticated_client.get(f"/projects/{project.id}")
|
||||
resp = admin_authenticated_client.get(f"/projects/{project.id}", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "Delete Project" in html
|
||||
|
||||
@@ -214,6 +214,8 @@ def test_edit_project_description(admin_authenticated_client, project, app):
|
||||
|
||||
# Verify the description was saved in the database
|
||||
db.session.expire_all() # Clear session cache
|
||||
# Refresh the project object to get latest data
|
||||
db.session.refresh(project)
|
||||
updated_project = Project.query.get(project_id)
|
||||
assert updated_project is not None
|
||||
assert updated_project.description == new_description
|
||||
@@ -294,6 +296,8 @@ def test_edit_client_updates_prepaid_fields(admin_authenticated_client, test_cli
|
||||
assert response.status_code == 302
|
||||
|
||||
db.session.expire_all()
|
||||
# Refresh the client object to get latest data
|
||||
db.session.refresh(test_client)
|
||||
updated = Client.query.get(client_id)
|
||||
assert updated is not None
|
||||
assert updated.prepaid_hours_monthly == Decimal("12.5")
|
||||
@@ -330,7 +334,10 @@ def test_edit_client_rejects_negative_prepaid_hours(admin_authenticated_client,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# View should re-render with validation error (200 OK)
|
||||
# View should re-render with validation error (200 OK) or redirect back
|
||||
# If it redirects, follow it to see the error message
|
||||
if response.status_code == 302:
|
||||
response = admin_authenticated_client.get(response.location, follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
db.session.expire_all()
|
||||
|
||||
@@ -151,11 +151,12 @@ def test_logo_file_persists_after_upload(authenticated_admin_client, sample_logo
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get the filename from database
|
||||
# Get the filename from database - refresh to get latest data
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logo_filename = settings.company_logo_filename
|
||||
|
||||
assert logo_filename != ""
|
||||
assert logo_filename != "" and logo_filename is not None
|
||||
|
||||
# Verify file exists on disk
|
||||
logo_path = settings.get_logo_path()
|
||||
@@ -181,10 +182,13 @@ def test_logo_accessible_after_simulated_restart(
|
||||
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data, content_type="multipart/form-data")
|
||||
|
||||
# Get the filename and path
|
||||
# Get the filename and path - refresh to get latest data
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logo_filename = settings.company_logo_filename
|
||||
assert logo_filename and logo_filename != "", "Logo filename should be set after upload"
|
||||
logo_path = settings.get_logo_path()
|
||||
assert logo_path is not None, "Logo path should not be None when filename is set"
|
||||
|
||||
# Verify file exists
|
||||
assert os.path.exists(logo_path)
|
||||
@@ -222,13 +226,15 @@ def test_multiple_logos_in_directory(authenticated_admin_client, app, cleanup_te
|
||||
}
|
||||
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logos_to_upload.append(settings.company_logo_filename)
|
||||
|
||||
# Verify at least the current logo exists
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
current_logo_path = settings.get_logo_path()
|
||||
assert current_logo_path is not None, "Logo path should not be None"
|
||||
assert os.path.exists(current_logo_path), "Current logo does not exist"
|
||||
|
||||
|
||||
@@ -243,9 +249,10 @@ def test_logo_path_is_in_uploads_directory(
|
||||
}
|
||||
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logo_path = settings.get_logo_path()
|
||||
assert logo_path is not None, "Logo path should not be None"
|
||||
|
||||
# Verify the logo is in the uploads/logos directory
|
||||
assert "uploads" in logo_path, f"Logo not in uploads directory: {logo_path}"
|
||||
@@ -316,9 +323,10 @@ def test_logo_file_has_correct_extension(authenticated_admin_client, sample_logo
|
||||
}
|
||||
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logo_filename = settings.company_logo_filename
|
||||
assert logo_filename and logo_filename != "", "Logo filename should be set"
|
||||
|
||||
# Should have .png extension
|
||||
assert logo_filename.endswith(".png")
|
||||
@@ -338,10 +346,11 @@ def test_old_logo_removed_when_new_uploaded(authenticated_admin_client, app, cle
|
||||
"logo": (img1_io, "test_logo1.png", "image/png"),
|
||||
}
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data1, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
old_filename = settings.company_logo_filename
|
||||
old_path = settings.get_logo_path()
|
||||
assert old_path is not None, "Old logo path should not be None"
|
||||
|
||||
# Verify first logo exists
|
||||
assert os.path.exists(old_path)
|
||||
@@ -356,10 +365,11 @@ def test_old_logo_removed_when_new_uploaded(authenticated_admin_client, app, cle
|
||||
"logo": (img2_io, "test_logo2.png", "image/png"),
|
||||
}
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data2, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
new_filename = settings.company_logo_filename
|
||||
new_path = settings.get_logo_path()
|
||||
assert new_path is not None, "New logo path should not be None"
|
||||
|
||||
# Verify new logo is different
|
||||
assert new_filename != old_filename
|
||||
@@ -378,9 +388,10 @@ def test_logo_removed_when_deleted(authenticated_admin_client, sample_logo_image
|
||||
}
|
||||
|
||||
authenticated_admin_client.post("/admin/upload-logo", data=data, content_type="multipart/form-data")
|
||||
|
||||
db.session.expire_all()
|
||||
settings = Settings.get_settings()
|
||||
logo_path = settings.get_logo_path()
|
||||
assert logo_path is not None, "Logo path should not be None"
|
||||
|
||||
# Verify logo exists
|
||||
assert os.path.exists(logo_path)
|
||||
|
||||
Reference in New Issue
Block a user