Release v0.1.4

This commit is contained in:
2026-07-02 15:01:18 +02:00
parent 6d391d13bd
commit a2053518d1
13 changed files with 788 additions and 67 deletions

View File

@@ -71,6 +71,123 @@ class ApiSmokeTests(unittest.TestCase):
payload = response.json()
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
"name": "ERP export",
"columnCount": 3,
"headers": ["email", "name", "field.customer_id"],
"normalizedHeaders": ["email", "name", "field_customer_id"],
"orderedHeaderFingerprint": "ordered-123",
"unorderedHeaderFingerprint": "unordered-123",
"delimiter": ";",
"headerRows": 1,
"quoted": True,
"valueSeparators": ",;|",
"mappings": [
{"columnIndex": 0, "kind": "to"},
{"columnIndex": 1, "kind": "name"},
{"columnIndex": 2, "kind": "new_field", "newFieldName": "customer_id"},
],
}
created = self.client.post("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers, json=payload)
self.assertEqual(created.status_code, 201, created.text)
created_body = created.json()
self.assertEqual(created_body["name"], "ERP export")
self.assertEqual(created_body["columnCount"], 3)
self.assertEqual(created_body["mappings"][2]["newFieldName"], "customer_id")
profile_id = created_body["id"]
listed = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed.status_code, 200, listed.text)
self.assertEqual([profile["id"] for profile in listed.json()["profiles"]], [profile_id])
updated_payload = {**payload, "name": "ERP export updated", "valueSeparators": "|"}
updated = self.client.put(
f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}",
headers=headers,
json=updated_payload,
)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["name"], "ERP export updated")
self.assertEqual(updated.json()["valueSeparators"], "|")
deleted = self.client.delete(f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 204, deleted.text)
listed_after_delete = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed_after_delete.status_code, 200, listed_after_delete.text)
self.assertEqual(listed_after_delete.json()["profiles"], [])
def test_campaign_entries_store_recipient_import_provenance(self) -> None:
headers, _ = self._login()
campaign_json = {
"version": "1.0",
"campaign": {"id": "import-provenance", "name": "Import provenance", "mode": "test"},
"fields": [{"name": "department", "type": "string", "required": False}],
"global_values": {},
"server": {},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Hello", "text": "Hello"},
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
"fields": {"department": "Finance"},
}
],
"imports": [
{
"id": "recipient-import-test",
"imported_at": "2026-06-26T12:00:00Z",
"mode": "replace",
"source_type": "xlsx",
"filename": "recipients.xlsx",
"sheet_name": "Recipients",
"encoding": None,
"delimiter": None,
"header_rows": 1,
"quoted": None,
"value_separators": ",;|",
"rows_total": 1,
"valid_rows": 1,
"invalid_rows": 0,
"imported_rows": 1,
"field_names_created": [],
"attachment_patterns": 0,
"mapping": [
{"column_index": 0, "header": "email", "kind": "to"},
{"column_index": 1, "header": "field.department", "kind": "field", "field_name": "department"},
],
}
],
},
"validation_policy": {"missing_email": "block", "template_error": "block"},
"delivery": {"rate_limit": {"messages_per_minute": 60}},
"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"]
detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers)
self.assertEqual(detail.status_code, 200, detail.text)
stored_import = detail.json()["raw_json"]["entries"]["imports"][0]
self.assertEqual(stored_import["source_type"], "xlsx")
self.assertEqual(stored_import["sheet_name"], "Recipients")
self.assertEqual(stored_import["mapping"][1]["field_name"], "department")
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"], validated.text)
def _create_built_delivery_campaign(
self,
headers: dict[str, str],
@@ -587,6 +704,7 @@ class ApiSmokeTests(unittest.TestCase):
campaign_files = self.client.get("/api/v1/files", headers=headers, params={"campaign_id": campaign_id})
self.assertEqual(campaign_files.status_code, 200, campaign_files.text)
self.assertEqual({item["id"] for item in campaign_files.json()["files"]}, {first_file["id"], second_file["id"]})
self.assertTrue(all(item["shares"] for item in campaign_files.json()["files"]))
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
@@ -1744,14 +1862,40 @@ class ApiSmokeTests(unittest.TestCase):
set_failures(smtp_reject_recipients_containing=None)
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "smtp_accepted")
self.assertIn("reject-me@example.org", job.last_error or "")
job_id = job.id
attempt_count = job.attempt_count
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
retry_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "include_permanent": True, "enqueue_celery": False},
)
self.assertEqual(retry_accepted.status_code, 200, retry_accepted.text)
self.assertEqual(retry_accepted.json()["result"]["selected_count"], 0)
self.assertIn("not an explicit retry candidate", retry_accepted.json()["result"]["skipped"][0]["reason"])
unattempted_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/send-unattempted",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "enqueue_celery": False},
)
self.assertEqual(unattempted_accepted.status_code, 200, unattempted_accepted.text)
self.assertEqual(unattempted_accepted.json()["result"]["selected_count"], 0)
with SessionLocal() as session:
direct_result = send_campaign_job(session, job_id=job_id, use_rate_limit=False)
self.assertEqual(direct_result.status, "already_accepted")
job = session.get(CampaignJob, job_id)
self.assertEqual(job.attempt_count, attempt_count)
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
headers, _ = self._login()
campaign_id, version_id = self._create_built_delivery_campaign(
@@ -1767,6 +1911,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(queued.status_code, 200, queued.text)
self.assertEqual(queued.json()["queued_count"], 2)
queued_summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(queued_summary.status_code, 200, queued_summary.text)
self.assertEqual(queued_summary.json()["cards"]["queued_or_active"], 2)
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
@@ -1839,6 +1988,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(summary.status_code, 200, summary.text)
self.assertEqual(summary.json()["cards"]["outcome_unknown"], 1)
self.assertEqual(summary.json()["status_counts"]["send"]["outcome_unknown"], 1)
detail = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
headers=headers,
@@ -1855,6 +2009,12 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(resolved.status_code, 200, resolved.text)
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
reconciled_report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers, params={"version_id": version_id})
self.assertEqual(reconciled_report.status_code, 200, reconciled_report.text)
self.assertEqual(reconciled_report.json()["cards"]["outcome_unknown"], 0)
self.assertEqual(reconciled_report.json()["cards"]["failed"], 1)
self.assertEqual(reconciled_report.json()["status_counts"]["send"]["failed_temporary"], 1)
retry = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,

View File

@@ -80,6 +80,8 @@ class DatabaseMigrationTests(unittest.TestCase):
}
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
import_profile_columns = {column["name"] for column in inspector.get_columns("campaign_recipient_import_mapping_profiles")}
import_profile_indexes = {index["name"] for index in inspector.get_indexes("campaign_recipient_import_mapping_profiles")}
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
system_owner_count = connection.execute(text(
@@ -114,8 +116,13 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("governance_templates", tables)
self.assertIn("governance_template_assignments", tables)
self.assertIn("campaign_shares", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("owner_user_id", campaign_columns)
self.assertIn("owner_group_id", campaign_columns)
self.assertIn("ordered_header_fingerprint", import_profile_columns)
self.assertIn("unordered_header_fingerprint", import_profile_columns)
self.assertIn("mappings", import_profile_columns)
self.assertIn("ix_recipient_import_profiles_ordered_fp", import_profile_indexes)
self.assertIn("scope", audit_columns)
self.assertIn("ix_audit_log_scope", audit_indexes)
self.assertIn("ix_audit_log_scope_created_at", audit_indexes)
@@ -409,4 +416,3 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("uq_file_folders_active_group_path", folder_indexes)
finally:
engine.dispose()