intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -364,12 +364,79 @@ class ApiSmokeTests(unittest.TestCase):
json={"email": "admin@example.local", "password": "test-admin"},
)
self.assertEqual(response.status_code, 200, response.text)
csrf = response.cookies.get("msm_csrf")
csrf = response.cookies.get("govoplan_csrf")
self.assertTrue(csrf)
session_summary = self.client.get("/api/v1/auth/session")
self.assertEqual(session_summary.status_code, 200, session_summary.text)
session_payload = session_summary.json()
self.assertTrue(session_payload["authenticated"])
self.assertEqual(session_payload["auth_method"], "session")
self.assertNotIn("scopes", session_payload)
self.assertNotIn("roles", session_payload)
self.assertNotIn("groups", session_payload)
shell_summary = self.client.get("/api/v1/auth/shell")
self.assertEqual(shell_summary.status_code, 200, shell_summary.text)
shell_payload = shell_summary.json()
self.assertEqual(shell_payload["principal"]["auth_method"], "session")
self.assertFalse(shell_payload["profile_loaded"])
self.assertFalse(shell_payload["roles_loaded"])
self.assertFalse(shell_payload["groups_loaded"])
self.assertIsInstance(shell_payload["scopes"], list)
self.assertTrue(shell_payload["scopes"])
self.assertTrue(shell_payload["tenants"])
self.assertTrue(all(membership["roles"] == [] for membership in shell_payload["tenants"]))
self.assertNotIn("roles", shell_payload)
self.assertNotIn("groups", shell_payload)
self.assertNotIn("available_languages", shell_payload)
self.assertNotIn("default_language", shell_payload)
profile_summary = self.client.get("/api/v1/auth/profile")
self.assertEqual(profile_summary.status_code, 200, profile_summary.text)
profile_payload = profile_summary.json()
self.assertTrue(profile_payload["profile_loaded"])
self.assertEqual(profile_payload["user"]["id"], session_payload["user"]["id"])
self.assertIn("available_languages", profile_payload)
self.assertIn("default_language", profile_payload)
self.assertNotIn("roles", profile_payload)
self.assertNotIn("groups", profile_payload)
roles_summary = self.client.get("/api/v1/auth/roles")
self.assertEqual(roles_summary.status_code, 200, roles_summary.text)
roles_payload = roles_summary.json()
self.assertTrue(roles_payload["roles_loaded"])
self.assertTrue(roles_payload["roles"])
self.assertNotIn("groups", roles_payload)
groups_summary = self.client.get("/api/v1/auth/groups")
self.assertEqual(groups_summary.status_code, 200, groups_summary.text)
groups_payload = groups_summary.json()
self.assertTrue(groups_payload["groups_loaded"])
self.assertIsInstance(groups_payload["groups"], list)
self.assertNotIn("roles", groups_payload)
api_key_session = self.client.get("/api/v1/auth/session", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_session.status_code, 200, api_key_session.text)
self.assertEqual(api_key_session.json()["auth_method"], "api_key")
api_key_shell = self.client.get("/api/v1/auth/shell", headers={"X-API-Key": "test-api-key"})
self.assertEqual(api_key_shell.status_code, 200, api_key_shell.text)
api_key_shell_payload = api_key_shell.json()
self.assertEqual(api_key_shell_payload["principal"]["auth_method"], "api_key")
self.assertFalse(api_key_shell_payload["profile_loaded"])
self.assertFalse(api_key_shell_payload["roles_loaded"])
self.assertFalse(api_key_shell_payload["groups_loaded"])
self.assertIsInstance(api_key_shell_payload["scopes"], list)
me = self.client.get("/api/v1/auth/me")
self.assertEqual(me.status_code, 200, me.text)
me_payload = me.json()
self.assertEqual(session_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(session_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertEqual(shell_payload["user"]["id"], me_payload["user"]["id"])
self.assertEqual(shell_payload["tenant"]["id"], me_payload["tenant"]["id"])
self.assertTrue(me_payload["profile_loaded"])
self.assertEqual(me_payload["principal"]["account_id"], me_payload["user"]["account_id"])
self.assertEqual(me_payload["principal"]["membership_id"], me_payload["user"]["id"])
self.assertEqual(me_payload["principal"]["tenant_id"], me_payload["tenant"]["id"])
@@ -423,6 +490,20 @@ class ApiSmokeTests(unittest.TestCase):
imap_host="mock.imap",
)
bootstrapped = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/bootstrap",
headers=headers,
params={"folder": "INBOX", "limit": 2},
)
self.assertEqual(bootstrapped.status_code, 200, bootstrapped.text)
bootstrap_payload = bootstrapped.json()
self.assertEqual(bootstrap_payload["folder"], "INBOX")
self.assertTrue(bootstrap_payload["folders"]["ok"])
self.assertFalse(bootstrap_payload["folders"]["from_cache"])
self.assertFalse(bootstrap_payload["messages"]["from_cache"])
self.assertEqual(bootstrap_payload["messages"]["total_count"], 3)
self.assertEqual(len(bootstrap_payload["messages"]["messages"]), 2)
listed = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers,
@@ -433,6 +514,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(payload["folder"], "INBOX")
self.assertEqual(payload["total_count"], 3)
self.assertEqual(len(payload["messages"]), 2)
self.assertTrue(payload["from_cache"])
self.assertTrue(all(message["folder"] == "INBOX" for message in payload["messages"]))
self.assertTrue(payload["cursor_stable"])
self.assertFalse(payload["full"])
@@ -449,6 +531,14 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(len(next_payload["messages"]), 1)
self.assertFalse(next_payload["next_cursor"])
cached_next_page = self.client.get(
f"/api/v1/mail/profiles/{profile_id}/mailbox/messages",
headers=headers,
params={"folder": "INBOX", "cursor": payload["next_cursor"]},
)
self.assertEqual(cached_next_page.status_code, 200, cached_next_page.text)
self.assertTrue(cached_next_page.json()["from_cache"])
stale_cursor = encode_keyset_cursor(
"mail.mailbox.messages.v1",
fingerprint=keyset_query_fingerprint(
@@ -739,7 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
self.assertEqual(schema.status_code, 200, schema.text)
self.assertEqual(schema.json()["title"], "MultiMailer Campaign")
self.assertEqual(schema.json()["title"], "GovOPlaN Campaign")
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
@@ -3269,7 +3359,7 @@ class ApiSmokeTests(unittest.TestCase):
"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))
self.assertFalse(any("govoplan-managed-build" in value for value in resolved_paths))
jobs = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs",

27
tests/test_http_fetch.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
class HttpFetchTests(unittest.TestCase):
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
self.assertTrue(is_http_url("http://example.test/catalog.json"))
def test_validate_http_url_rejects_non_http_urls_and_embedded_credentials(self) -> None:
for value in (
"file:///etc/passwd",
"/relative/catalog.json",
"https://user@example.test/catalog.json",
"https://user:secret@example.test/catalog.json",
):
with self.subTest(value=value):
self.assertFalse(is_http_url(value))
with self.assertRaises(ValueError):
validate_http_url(value)
if __name__ == "__main__":
unittest.main()

32
tests/test_mail_config.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from govoplan_core.mail.config import normalize_split_transport_credentials
class MailConfigTests(unittest.TestCase):
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
payload = normalize_split_transport_credentials(
{
"smtp": {"host": "smtp.example.test", "username": "smtp-user", "password": "smtp-secret"},
"imap": {"host": "imap.example.test", "enabled": True, "username": "imap-user"},
"credentials": {"smtp": {"username": "existing"}},
}
)
self.assertEqual(
{
"smtp": {"host": "smtp.example.test"},
"imap": {"host": "imap.example.test"},
"credentials": {
"smtp": {"username": "existing", "password": "smtp-secret"},
"imap": {"username": "imap-user"},
},
},
payload,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -14,7 +14,6 @@ import tempfile
import textwrap
import tomllib
import unittest
import urllib.error
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
@@ -35,6 +34,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.events import event_context
from govoplan_core.core import module_installer as module_installer_module
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.core.module_management import (
ModuleInstallPlan,
@@ -227,7 +227,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
@@ -272,14 +272,16 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
legacy_admin_import_pattern = re.compile(r"api/admin(?=[\"'#?]|$)")
self.assertFalse((webui_src / "api" / "admin.ts").exists())
for path in webui_src.rglob("*.ts*"):
with self.subTest(path=path.relative_to(webui_src)):
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
self.assertIsNone(legacy_admin_import_pattern.search(path.read_text(encoding="utf-8")))
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate
@@ -2474,7 +2476,8 @@ finally:
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
def fake_run(*_args, **kwargs):
if kwargs.get("shell"):
argv = tuple(_args[0]) if _args else ()
if kwargs.get("shell") or argv == ("restart-fails",):
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -2562,6 +2565,43 @@ finally:
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_command = f"{sys.executable} -c {shlex.quote('print(1)')} && {sys.executable} -c {shlex.quote('print(2)')}"
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Database backup command failed"):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=backup_command,
database_restore_command="restore-database",
dry_run=True,
)
rejected = module_installer_module._run_operator_command("DATABASE_URL=postgres://db.example.invalid/govoplan pg_dump")
self.assertEqual(2, rejected.returncode)
self.assertIn("Environment assignments", rejected.stderr)
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -2654,11 +2694,13 @@ finally:
queued = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="user-1",
tenant_id="tenant-1",
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
)
request_id = str(queued["request_id"])
self.assertEqual("queued", queued["status"])
self.assertEqual("tenant-1", queued["tenant_id"])
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
@@ -2675,7 +2717,7 @@ finally:
self.assertEqual("completed", updated["status"])
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1")
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1", tenant_id="tenant-1")
cancelled = cancel_module_installer_request(
runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]),
@@ -2691,6 +2733,7 @@ finally:
)
self.assertEqual("queued", retry["status"])
self.assertEqual(cancellable["request_id"], retry["retry_of"])
self.assertEqual("tenant-1", retry["tenant_id"])
settings = _settings(root)
configure_database(settings.database_url)
@@ -3030,11 +3073,11 @@ finally:
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.7", modules["files"]["version"])
self.assertIn("@v0.1.7", modules["files"]["python_ref"])
self.assertEqual("0.1.8", modules["files"]["version"])
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
@@ -3067,16 +3110,6 @@ finally:
)
body = signed_path.read_text(encoding="utf-8")
class _Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self) -> bytes:
return body.encode("utf-8")
env = {
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
@@ -3085,11 +3118,11 @@ finally:
}
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
with patch.dict(os.environ, env):
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()):
with patch("govoplan_core.core.module_package_catalog.fetch_http_text", return_value=body):
fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
with patch(
"govoplan_core.core.module_package_catalog.urllib.request.urlopen",
side_effect=urllib.error.URLError("offline"),
"govoplan_core.core.module_package_catalog.fetch_http_text",
side_effect=OSError("offline"),
):
cached = validate_module_package_catalog(trusted_keys=trusted_keys)
@@ -3800,12 +3833,14 @@ finally:
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
def test_module_route_factories_receive_runtime_settings(self) -> None:
app, settings = self._app_for_modules(("files", "mail"))
app, settings = self._app_for_modules(("calendar", "files", "mail"))
self.assertIsNotNone(app)
from govoplan_calendar.backend.runtime import get_settings as get_calendar_settings
from govoplan_files.backend.runtime import get_settings as get_files_settings
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
self.assertIs(settings, get_calendar_settings())
self.assertIs(settings, get_files_settings())
self.assertIs(settings, get_mail_settings())

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from govoplan_core.privacy.schemas import (
RETENTION_POLICY_FIELD_KEYS,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyPatchItem,
default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
class PrivacySchemaContractTests(unittest.TestCase):
def test_default_allow_lower_level_limits_cover_every_retention_field(self) -> None:
self.assertEqual({key: True for key in RETENTION_POLICY_FIELD_KEYS}, default_allow_lower_level_limits())
def test_policy_item_fills_missing_lower_level_limits(self) -> None:
item = PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertFalse(item.allow_lower_level_limits["audit_detail_level"])
self.assertTrue(item.allow_lower_level_limits["store_raw_campaign_json"])
def test_policy_patch_keeps_lower_level_limits_sparse(self) -> None:
patch = PrivacyRetentionPolicyPatchItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
self.assertIsNone(normalize_allow_lower_level_limits("", fill_defaults=False))
def test_unknown_lower_level_limit_field_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"unknown": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from unittest import TestCase
from unittest.mock import patch
from fastapi import APIRouter
from fastapi.testclient import TestClient
from sqlalchemy import text
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.db.session import create_database_engine
from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:
with collect_query_metrics() as metrics:
with engine.connect() as connection:
self.assertEqual(1, connection.execute(text("select 1")).scalar_one())
self.assertEqual(1, metrics.query_count)
self.assertGreaterEqual(metrics.total_ms, 0.0)
self.assertGreaterEqual(metrics.slowest_ms, 0.0)
self.assertEqual(0, metrics.error_count)
finally:
engine.dispose()
def test_slow_request_log_includes_query_metrics(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
router = APIRouter()
@router.get("/query")
def query_route() -> dict[str, bool]:
with engine.connect() as connection:
connection.execute(text("select 1")).scalar_one()
return {"ok": True}
try:
with patch.dict(os.environ, {"GOVOPLAN_SLOW_REQUEST_MS": "0.001"}):
app = create_govoplan_app(
title="query metrics test",
version="0",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client, self.assertLogs("govoplan.request", level="WARNING") as logs:
response = client.get("/query")
self.assertEqual(200, response.status_code, response.text)
output = "\n".join(logs.output)
self.assertIn("db_query_count=1", output)
self.assertIn("db_time_ms=", output)
self.assertIn("db_slowest_ms=", output)
self.assertIn("db_error_count=0", output)
finally:
engine.dispose()

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
operation_for_object,
operation_for_soft_deletable,
previous_value,
)
class Base(DeclarativeBase):
pass
class ExampleRecord(Base):
__tablename__ = "example_records"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class SqlalchemyChangeTrackingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine, expire_on_commit=False)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_pending_object_is_created_and_can_receive_id(self) -> None:
record = ExampleRecord(id="", name="Draft", deleted_at=None)
self.session.add(record)
self.assertEqual(ensure_object_id(record, lambda: "record-1"), "record-1")
self.assertEqual(record.id, "record-1")
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "created")
def test_changed_object_reports_update_and_previous_value(self) -> None:
record = ExampleRecord(id="record-1", name="Before", deleted_at=None)
self.session.add(record)
self.session.flush()
record.name = "After"
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "updated")
self.assertEqual(previous_value(record, "name"), "Before")
def test_soft_delete_and_restore_are_classified(self) -> None:
deleted_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
record = ExampleRecord(id="record-1", name="Record", deleted_at=None)
self.session.add(record)
self.session.flush()
record.deleted_at = deleted_at
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "deleted")
self.session.flush()
record.deleted_at = None
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "created")
if __name__ == "__main__":
unittest.main()