525 lines
21 KiB
Python
525 lines
21 KiB
Python
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()
|