campaign version refinment, user locks, db repair

This commit is contained in:
2026-06-13 19:25:23 +02:00
parent fe5ac084b7
commit ffbddfc773
18 changed files with 896 additions and 39 deletions

View File

@@ -160,6 +160,67 @@ class ApiSmokeTests(unittest.TestCase):
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 = {
@@ -377,10 +438,59 @@ class ApiSmokeTests(unittest.TestCase):
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",

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic import command
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from app.db.base import Base
from app.db.migrations import (
REVISION_AUTH_RBAC,
REVISION_FILE_FOLDERS,
alembic_config,
migrate_database,
)
class DatabaseMigrationTests(unittest.TestCase):
def test_repairs_create_all_schema_drift_and_upgrades_to_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="msm-migration-test-") as directory:
database = Path(directory) / "legacy.db"
url = f"sqlite:///{database}"
# Reproduce the historical development database: Alembic was run
# through auth/RBAC, then create_all() created later file tables
# without advancing alembic_version and without altering the
# already-existing campaign_versions table.
command.upgrade(alembic_config(database_url=url), REVISION_AUTH_RBAC)
engine = create_engine(url)
try:
Base.metadata.create_all(bind=engine)
with engine.connect() as connection:
self.assertEqual(
MigrationContext.configure(connection).get_current_revision(),
REVISION_AUTH_RBAC,
)
self.assertIn("file_blobs", inspect(connection).get_table_names())
self.assertNotIn(
"user_lock_state",
{column["name"] for column in inspect(connection).get_columns("campaign_versions")},
)
finally:
engine.dispose()
result = migrate_database(database_url=url)
self.assertEqual(result.previous_revision, REVISION_AUTH_RBAC)
self.assertEqual(result.reconciled_revision, REVISION_FILE_FOLDERS)
engine = create_engine(url)
try:
with engine.connect() as connection:
current = MigrationContext.configure(connection).get_current_revision()
columns = {
column["name"]
for column in inspect(connection).get_columns("campaign_versions")
}
self.assertEqual(current, result.current_revision)
self.assertIn("user_lock_state", columns)
self.assertIn("user_locked_at", columns)
self.assertIn("user_locked_by_user_id", columns)
finally:
engine.dispose()