from __future__ import annotations import io import json import os import shutil import tempfile import unittest import zipfile from pathlib import Path # Settings are instantiated while importing the application. Configure an # isolated test database and local storage before importing app modules. _TEST_ROOT = Path(tempfile.mkdtemp(prefix="multi-seal-mail-tests-")) os.environ["APP_ENV"] = "test" os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_ROOT / 'test.db'}" os.environ["FILE_STORAGE_BACKEND"] = "local" os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files") os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox") os.environ["DEV_BOOTSTRAP_ENABLED"] = "false" from fastapi.testclient import TestClient from app.db.base import Base from app.db.bootstrap import bootstrap_dev_data from app.db.session import SessionLocal, engine from app.main import app class ApiSmokeTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.client = TestClient(app) @classmethod def tearDownClass(cls) -> None: cls.client.close() engine.dispose() shutil.rmtree(_TEST_ROOT, ignore_errors=True) def setUp(self) -> None: Base.metadata.drop_all(bind=engine) Base.metadata.create_all(bind=engine) shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True) with SessionLocal() as session: bootstrap_dev_data( session, api_key_secret="test-api-key", user_password="test-admin", ) def _login(self) -> tuple[dict[str, str], dict[str, object]]: response = self.client.post( "/api/v1/auth/login", json={"email": "admin@example.local", "password": "test-admin"}, ) self.assertEqual(response.status_code, 200, response.text) payload = response.json() return {"Authorization": f"Bearer {payload['access_token']}"}, payload def test_managed_file_runtime_flow(self) -> None: headers, login = self._login() user_id = login["user"]["id"] spaces = self.client.get("/api/v1/files/spaces", headers=headers) self.assertEqual(spaces.status_code, 200, spaces.text) self.assertEqual(spaces.json()["spaces"][0]["owner_id"], user_id) folder = self.client.post( "/api/v1/files/folders", headers=headers, json={"owner_type": "user", "owner_id": user_id, "path": "inbox"}, ) self.assertEqual(folder.status_code, 200, folder.text) first = self.client.post( "/api/v1/files/upload", headers=headers, data={"owner_type": "user", "owner_id": user_id, "path": "inbox"}, files=[("files", ("report.txt", b"first report", ""))], ) self.assertEqual(first.status_code, 200, first.text) first_file = first.json()["files"][0] self.assertEqual(first_file["display_path"], "inbox/report.txt") self.assertEqual(first_file["content_type"], "text/plain") # Exercises request-model conversion to FileConflictResolution and the # explicit rename path rather than silently overwriting an asset. second = self.client.post( "/api/v1/files/upload", headers=headers, data={ "owner_type": "user", "owner_id": user_id, "path": "inbox", "conflict_resolutions_json": json.dumps( [ { "target_path": "inbox/report.txt", "action": "rename", "new_path": "inbox/report-copy.txt", } ] ), }, files=[("files", ("report.txt", b"second report", "text/plain"))], ) self.assertEqual(second.status_code, 200, second.text) second_file = second.json()["files"][0] self.assertEqual(second_file["display_path"], "inbox/report-copy.txt") listing = self.client.get( "/api/v1/files", headers=headers, params={"owner_type": "user", "owner_id": user_id, "path_prefix": "inbox"}, ) self.assertEqual(listing.status_code, 200, listing.text) self.assertEqual(len(listing.json()["files"]), 2) resolved = self.client.post( "/api/v1/files/resolve-patterns", headers=headers, json={ "patterns": ["**/*.txt"], "owner_type": "user", "owner_id": user_id, "include_unmatched": True, }, ) self.assertEqual(resolved.status_code, 200, resolved.text) self.assertEqual(len(resolved.json()["patterns"][0]["matches"]), 2) self.assertEqual(resolved.json()["unmatched"], []) # Exercises RenamePlanItem construction without mutating persisted paths. rename_preview = self.client.post( "/api/v1/files/bulk-rename", headers=headers, json={ "file_ids": [first_file["id"]], "mode": "prefix", "prefix": "archived-", "dry_run": True, }, ) self.assertEqual(rename_preview.status_code, 200, rename_preview.text) self.assertEqual(rename_preview.json()["items"][0]["new_path"], "inbox/archived-report.txt") download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers) self.assertEqual(download.status_code, 200, download.text) self.assertEqual(download.content, b"first report") archive = self.client.post( "/api/v1/files/archive.zip", headers=headers, json={"file_ids": [first_file["id"], second_file["id"]], "filename": "reports.zip"}, ) self.assertEqual(archive.status_code, 200, archive.text) with zipfile.ZipFile(io.BytesIO(archive.content)) as bundle: self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"]) self.assertEqual(bundle.read("inbox/report.txt"), b"first report") def test_temporary_and_permanent_user_lock_lifecycle(self) -> None: headers, _ = self._login() created = self.client.post( "/api/v1/campaigns/new", headers=headers, json={"external_id": "lock-lifecycle", "name": "Lock lifecycle"}, ) self.assertEqual(created.status_code, 200, created.text) campaign_id = created.json()["campaign"]["id"] version_id = created.json()["version"]["id"] temporary = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily", headers=headers, ) self.assertEqual(temporary.status_code, 200, temporary.text) self.assertEqual(temporary.json()["user_lock_state"], "temporary") self.assertTrue(temporary.json()["user_locked_at"]) blocked_update = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers, json={"current_step": "fields"}, ) self.assertEqual(blocked_update.status_code, 409, blocked_update.text) unlocked = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock", headers=headers, ) self.assertEqual(unlocked.status_code, 200, unlocked.text) self.assertIsNone(unlocked.json()["user_lock_state"]) updated = self.client.put( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers, json={"current_step": "fields"}, ) self.assertEqual(updated.status_code, 200, updated.text) self.assertEqual(updated.json()["current_step"], "fields") relocked = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily", headers=headers, ) self.assertEqual(relocked.status_code, 200, relocked.text) permanent = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-permanently", headers=headers, ) self.assertEqual(permanent.status_code, 200, permanent.text) self.assertEqual(permanent.json()["user_lock_state"], "permanent") self.assertTrue(permanent.json()["published_at"]) refused_unlock = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock", headers=headers, ) self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text) def test_campaign_create_validate_build_and_mock_send(self) -> None: headers, _ = self._login() campaign_json = { "version": "1.0", "campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"}, "fields": [{"name": "first_name", "type": "string", "required": True}], "global_values": {}, "server": { "smtp": { "host": "smtp.example.invalid", "port": 587, "username": "sender@example.org", "password": "test-secret", "security": "starttls", }, "imap": { "enabled": True, "host": "imap.example.invalid", "port": 993, "username": "sender@example.org", "password": "test-secret", "security": "tls", }, }, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, }, "template": { "subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}, this is a smoke test.", }, "attachments": {"base_path": ".", "global": [], "allow_individual": False}, "entries": { "inline": [ { "id": "recipient-1", "to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}], "fields": {"first_name": "Ada"}, } ] }, "validation_policy": {"missing_email": "block", "template_error": "block"}, "delivery": {"imap_append_sent": {"enabled": True, "folder": "Sent"}}, "status_tracking": {"enabled": True}, } created = self.client.post( "/api/v1/campaigns", headers=headers, json={"config": campaign_json}, ) self.assertEqual(created.status_code, 200, created.text) created_payload = created.json() campaign_id = created_payload["campaign"]["id"] version_id = created_payload["version"]["id"] validated = self.client.post( f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False}, ) self.assertEqual(validated.status_code, 200, validated.text) self.assertTrue(validated.json()["ok"]) built = self.client.post( f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False}, ) self.assertEqual(built.status_code, 200, built.text) self.assertEqual(built.json()["built_count"], 1) mocked = self.client.post( f"/api/v1/campaigns/{campaign_id}/mock-send", headers=headers, json={ "version_id": version_id, "send": True, "append_sent": True, "clear_mailbox": True, "check_files": False, }, ) self.assertEqual(mocked.status_code, 200, mocked.text) result = mocked.json()["result"] self.assertTrue(result["validation"]["ok"]) self.assertEqual(result["build"]["built_count"], 1) self.assertEqual(result["send"]["sent_count"], 1) self.assertEqual(result["send"]["imap_appended_count"], 1) self.assertEqual(result["send"]["imap_failed_count"], 0) def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None: headers, login = self._login() user_id = login["user"]["id"] campaign_json = { "version": "1.0", "campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"}, "fields": [{"name": "invoice_number", "type": "string", "required": True}], "global_values": {}, "server": { "smtp": { "host": "smtp.example.invalid", "port": 587, "username": "sender@example.org", "password": "test-secret", "security": "starttls", } }, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, }, "template": {"subject": "Invoice", "text": "Please see the attached files."}, "attachments": { "base_path": "invoices", "base_paths": [ { "id": "personal-invoices", "name": "My invoices", "source": f"managed:user:{user_id}", "path": "invoices", "allow_individual": True, } ], "global": [], "allow_individual": True, }, "entries": { "inline": [ { "id": "recipient-1", "to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}], "fields": {"invoice_number": "202605-010001"}, "attachments": [ { "id": "nested-pattern", "label": "Nested workbook", "base_path_id": "personal-invoices", "base_dir": "invoices", "file_filter": "**/{{local:invoice_number}}-report.XLSX", "required": True, }, { "id": "exact-pattern", "label": "Exact workbook", "base_path_id": "personal-invoices", "base_dir": "invoices", "file_filter": "{{local:invoice_number}}-90100010-9601741.XLSX", "required": True, }, ], } ] }, "validation_policy": { "missing_email": "block", "template_error": "block", "missing_required_attachment": "block", }, "delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True}, } created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) self.assertEqual(created.status_code, 200, created.text) campaign_id = created.json()["campaign"]["id"] version_id = created.json()["version"]["id"] for path, filename, content in [ ("invoices/archive", "202605-010001-report.XLSX", b"nested workbook"), ("invoices", "202605-010001-90100010-9601741.XLSX", b"exact workbook"), ]: uploaded = self.client.post( "/api/v1/files/upload", headers=headers, data={ "owner_type": "user", "owner_id": user_id, "path": path, "campaign_id": campaign_id, }, files=[("files", (filename, content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))], ) self.assertEqual(uploaded.status_code, 200, uploaded.text) preview = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview", headers=headers, json={"include_unmatched": True}, ) self.assertEqual(preview.status_code, 200, preview.text) preview_payload = preview.json() self.assertEqual(len(preview_payload["rules"]), 2) self.assertEqual([rule["match_count"] for rule in preview_payload["rules"]], [1, 1]) self.assertEqual( {rule["matches"][0]["filename"] for rule in preview_payload["rules"]}, {"202605-010001-report.XLSX", "202605-010001-90100010-9601741.XLSX"}, ) self.assertEqual(preview_payload["unused_shared_files"], []) validated = self.client.post( f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": True}, ) self.assertEqual(validated.status_code, 200, validated.text) self.assertTrue(validated.json()["ok"], validated.text) built = self.client.post( f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False}, ) self.assertEqual(built.status_code, 200, built.text) self.assertEqual(built.json()["built_count"], 1) self.assertEqual(built.json()["messages"][0]["attachment_count"], 2) self.assertTrue(built.json().get("built_at")) self.assertTrue(built.json().get("build_token")) self.assertEqual( sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]), 2, ) resolved_paths = { match for attachment in built.json()["messages"][0]["attachments"] for match in attachment["matches"] } self.assertEqual(resolved_paths, { "invoices/archive/202605-010001-report.XLSX", "invoices/202605-010001-90100010-9601741.XLSX", }) self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths)) jobs = self.client.get( f"/api/v1/campaigns/{campaign_id}/jobs", headers=headers, params={"version_id": version_id}, ) self.assertEqual(jobs.status_code, 200, jobs.text) self.assertEqual(len(jobs.json()["jobs"]), 1) job = jobs.json()["jobs"][0] self.assertEqual(job["campaign_version_id"], version_id) self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org") self.assertEqual( { match for attachment in job["attachments"] for match in attachment["matches"] }, resolved_paths, ) review_state = self.client.post( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state", headers=headers, json={"inspection_complete": True, "reviewed_message_keys": ["recipient-1"]}, ) self.assertEqual(review_state.status_code, 200, review_state.text) stored_review = review_state.json()["editor_state"]["review_send"] self.assertTrue(stored_review["inspection_complete"]) self.assertEqual(stored_review["reviewed_message_keys"], ["recipient-1"]) self.assertEqual(stored_review["build_token"], built.json()["build_token"]) reloaded_version = self.client.get( f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers, ) self.assertEqual(reloaded_version.status_code, 200, reloaded_version.text) self.assertTrue(reloaded_version.json()["editor_state"]["review_send"]["inspection_complete"]) mocked = self.client.post( f"/api/v1/campaigns/{campaign_id}/mock-send", headers=headers, json={ "version_id": version_id, "send": True, "append_sent": False, "clear_mailbox": True, "check_files": False, }, ) self.assertEqual(mocked.status_code, 200, mocked.text) result = mocked.json()["result"] self.assertTrue(result["validation"]["ok"], mocked.text) self.assertEqual(result["send"]["sent_count"], 1) self.assertEqual(result["send"]["results"][0]["attachments"][0]["status"], "ok") from app.db.models import CampaignAttachmentUse with SessionLocal() as session: uses = session.query(CampaignAttachmentUse).filter(CampaignAttachmentUse.campaign_id == campaign_id).all() self.assertEqual(len(uses), 2) self.assertEqual({use.filename_used for use in uses}, { "202605-010001-report.XLSX", "202605-010001-90100010-9601741.XLSX", }) if __name__ == "__main__": unittest.main()