Files
copilot-swe-agent[bot] e518bce922 fix: merge main branch and renumber migration 037→040
Resolve all merge conflicts between our automation feature branch and
current main (v0.163.0, 920 commits ahead).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
  (classification_rules, qr_auth, sessions, system_reset)
- app/config.py: add main's new settings (dropbox_use_global_credentials,
  factory_reset_on_startup, enable_factory_reset)
- app/models.py: add main's new models (ClassificationRuleModel, UserSession,
  QRLoginChallenge, SharePoint integration type)
- app/utils/settings_service.py: merge automation_hooks_enabled with main's
  new metadata entries
- docs/API.md: merge automation API docs with main's classification rules docs
- docs/ConfigurationGuide.md: add factory reset settings
- tests/conftest.py: import both AutomationHook and new main models

Migration renumbered:
- 037_add_automation_hooks → 040_add_automation_hooks
- down_revision: 039_add_classification_rules (was 036_add_document_translation_fields)
- Chain: 036 → 037 → 038 → 039 → 040 (automation hooks)

For all non-automation files with conflicts, main's version was taken since
our branch did not modify those files (conflicts were from a stale prior merge).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
2026-03-20 23:54:04 +00:00

394 lines
15 KiB
Python

"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import (
DocumentMetadata,
FileProcessingStep,
FileRecord,
ProcessingLog,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_workdir():
"""Create a temporary workdir populated with sample user data."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create data subdirectories with dummy files
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
d = Path(tmpdir) / subdir
d.mkdir()
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
# Create cache files
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
(Path(tmpdir) / cache).write_text("{}")
# Create a per-user watch folder cache
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
# Create a loose PDF in workdir root
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
yield tmpdir
@pytest.fixture
def reset_db_session():
"""Fresh in-memory database with sample user data rows."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed with sample data
fr = FileRecord(
filehash="abc123",
original_filename="test.pdf",
local_filename="uuid.pdf",
file_size=1024,
mime_type="application/pdf",
)
session.add(fr)
session.flush()
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
session.commit()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------------------------------
# Unit tests for app/utils/system_reset.py
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWipeWorkdirData:
"""Tests for _wipe_workdir_data()."""
def test_removes_data_subdirs(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
# All data subdirectories should be gone
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
assert not (Path(reset_workdir) / subdir).exists()
assert result["deleted_dirs"] == 5
def test_removes_cache_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
assert result["deleted_files"] >= 3
def test_removes_loose_document_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "abc123.pdf").exists()
def test_preserves_workdir_directory(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert Path(reset_workdir).is_dir()
def test_handles_empty_workdir(self):
"""No errors when workdir has no data dirs or caches."""
from app.utils.system_reset import _wipe_workdir_data
with tempfile.TemporaryDirectory() as empty_dir:
result = _wipe_workdir_data(empty_dir)
assert result["deleted_dirs"] == 0
assert result["deleted_files"] == 0
@pytest.mark.unit
class TestWipeDatabase:
"""Tests for _wipe_database()."""
def test_deletes_all_user_data(self, reset_db_session):
from app.utils.system_reset import _wipe_database
result = _wipe_database(reset_db_session)
assert result.get("files", 0) >= 1
assert result.get("processing_logs", 0) >= 1
assert result.get("file_processing_steps", 0) >= 1
assert result.get("document_metadata", 0) >= 1
def test_tables_are_empty_after_wipe(self, reset_db_session):
from app.utils.system_reset import _wipe_database
_wipe_database(reset_db_session)
assert reset_db_session.query(FileRecord).count() == 0
assert reset_db_session.query(ProcessingLog).count() == 0
assert reset_db_session.query(FileProcessingStep).count() == 0
assert reset_db_session.query(DocumentMetadata).count() == 0
@pytest.mark.unit
class TestPerformFullReset:
"""Tests for perform_full_reset()."""
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_full_reset
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
result = perform_full_reset(reset_db_session)
assert "database" in result
assert "filesystem" in result
assert reset_db_session.query(FileRecord).count() == 0
assert not (Path(reset_workdir) / "original").exists()
@pytest.mark.unit
class TestPerformResetAndReimport:
"""Tests for perform_reset_and_reimport()."""
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = ""
mock_settings.watch_folder_delete_after_process = False
result = perform_reset_and_reimport(reset_db_session)
reimport_dir = Path(reset_workdir) / "reimport"
assert reimport_dir.is_dir()
assert result["reimport"]["files_moved"] >= 1
# DB should be wiped
assert reset_db_session.query(FileRecord).count() == 0
# Reimport folder should contain the original file
reimport_files = list(reimport_dir.iterdir())
assert len(reimport_files) >= 1
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = "/some/other/folder"
mock_settings.watch_folder_delete_after_process = False
perform_reset_and_reimport(reset_db_session)
reimport_path = str(Path(reset_workdir) / "reimport")
# watch_folders should now include the reimport path
assert reimport_path in mock_settings.watch_folders
@pytest.mark.unit
class TestStartupReset:
"""Tests for perform_startup_reset()."""
def test_startup_reset_calls_full_reset(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
perform_startup_reset()
mock_reset.assert_called_once_with(mock_db)
mock_db.close.assert_called_once()
def test_startup_reset_handles_errors(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
# Should not raise
perform_startup_reset()
mock_db.rollback.assert_called_once()
mock_db.close.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests for API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetApi:
"""Tests for the /api/admin/system-reset/ endpoints."""
def test_full_reset_requires_admin(self, client):
"""Non-admin users get 403."""
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
assert response.status_code == 403
def test_full_reset_requires_feature_flag(self, client):
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 404
def test_full_reset_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_reimport_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_status_endpoint(self, client):
"""The status endpoint returns feature-flag state."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
response = client.get("/api/admin/system-reset/status")
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
data = response.json()
assert "enabled" in data
assert "factory_reset_on_startup" in data
def test_full_reset_success(self, client):
"""Full reset succeeds with correct confirmation and feature flag."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
):
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_reimport_success(self, client):
"""Reimport succeeds with correct confirmation."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_reset_and_reimport",
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
):
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "REIMPORT"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Integration tests for the view
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetView:
"""Tests for the /admin/system-reset view."""
def test_view_redirects_when_disabled(self, client):
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
with client:
client.cookies.set("session", "test")
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
assert response.status_code in (302, 307)
def test_view_requires_auth(self, client):
"""Unauthenticated users are redirected away from the page."""
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
mock_s.factory_reset_on_startup = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Should redirect to login since there's no active session
assert response.status_code in (302, 307)