fix(campaigns): preserve modification-request audit row after test deletion

Approving a remove_test modification request deletes the underlying Test
row, which was cascading through test_id's ON DELETE CASCADE and wiping
out the request row itself before it could be read back. Changed to
ON DELETE SET NULL so the audit record (justification, reviewer,
decision) survives. Adds regression coverage plus double-approve/reject
idempotency tests.
This commit is contained in:
kitos
2026-07-03 10:48:42 +02:00
parent dc5f206bf5
commit 12a5484003
3 changed files with 101 additions and 4 deletions
+45 -2
View File
@@ -4,7 +4,7 @@ import uuid
import pytest
from app.models.campaign import Campaign, CampaignTest
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
from app.models.enums import TestState
@@ -275,12 +275,41 @@ class TestModificationRequests:
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
request_id = request.id
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
approve_modification_request(db, str(request_id), reviewer_id=admin_user.id)
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 0
# The request row must survive the cascade-delete of the underlying Test
# (ondelete="CASCADE" on CampaignModificationRequest.test_id) — this locks
# in the fix where the request's status is persisted BEFORE the test is
# removed, not after.
reloaded = (
db.query(CampaignModificationRequest)
.filter(CampaignModificationRequest.id == request_id)
.first()
)
assert reloaded is not None
assert reloaded.status == "approved"
assert reloaded.reviewed_by == admin_user.id
assert reloaded.reviewed_at is not None
def test_approve_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
db.commit()
with pytest.raises(BusinessRuleViolation, match="Only pending"):
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
def test_reject_request_leaves_campaign_untouched(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
@@ -310,6 +339,20 @@ class TestModificationRequests:
with pytest.raises(BusinessRuleViolation, match="Review notes are required"):
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="")
def test_reject_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Not needed")
db.commit()
with pytest.raises(BusinessRuleViolation, match="Only pending"):
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Again")
def test_list_modification_requests_filters_by_status(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
create_modification_request(