Refusing outright was too blunt. A failed connection test leaves a campaign in the ad account; the reconciler adopts it as "imported"; and from that moment nobody can remove it from MaskanX at all — not even the account owner, and not even though MaskanX created it. The only route left was Ads Manager, which is the thing this whole feature set exists to avoid. The guard was protecting against an accidental click, so that is all it does now. delete_in_meta=true is required for an imported campaign, carries an operator identity, and is logged with the Meta id. The 409 that refuses without it names the flag, so the refusal points somewhere instead of stranding the caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205 lines
6.6 KiB
Python
205 lines
6.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Deleting a campaign must not leave objects spending on Meta.
|
|
|
|
The dangerous case is a campaign forgotten in MaskanX but still live on
|
|
Meta: it keeps drawing budget with nothing left here to show it exists.
|
|
So Meta is deleted first, and a failure there keeps the local row.
|
|
|
|
The opposite mistake matters too: deleting an imported campaign here must
|
|
not destroy work the operator did in Ads Manager.
|
|
"""
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from adclaw.app.routers import campaigns as campaigns_router
|
|
from adclaw.campaigns.models import CampaignSpec
|
|
from adclaw.campaigns.sync import unsync_campaign
|
|
from adclaw.meta.client import MetaClient, MetaError
|
|
|
|
|
|
class FakeRepo:
|
|
def __init__(self):
|
|
self.items: dict[str, CampaignSpec] = {}
|
|
self.deleted: list[str] = []
|
|
|
|
async def get_campaign(self, campaign_id):
|
|
return self.items.get(campaign_id)
|
|
|
|
async def delete_campaign(self, campaign_id):
|
|
self.deleted.append(campaign_id)
|
|
self.items.pop(campaign_id, None)
|
|
|
|
|
|
class FakeMeta:
|
|
def __init__(self, error: MetaError | None = None):
|
|
self.error = error
|
|
self.deleted: list[str] = []
|
|
|
|
async def delete_object(self, object_id):
|
|
if self.error:
|
|
raise self.error
|
|
self.deleted.append(object_id)
|
|
|
|
|
|
def _campaign(**overrides) -> CampaignSpec:
|
|
data = {
|
|
"id": "camp_1",
|
|
"name": "Q3 lead gen",
|
|
"status": "live",
|
|
"origin": "maskanx",
|
|
"ad_account_id": "act_1",
|
|
"meta_campaign_id": "meta_camp_1",
|
|
"budget": {"daily_budget": 10000},
|
|
}
|
|
data.update(overrides)
|
|
return CampaignSpec(**data)
|
|
|
|
|
|
@pytest.fixture()
|
|
def env(monkeypatch):
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo)
|
|
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: meta)
|
|
app = FastAPI()
|
|
app.include_router(campaigns_router.router, prefix="/api")
|
|
return TestClient(app), repo, meta
|
|
|
|
|
|
def test_delete_removes_the_campaign_on_meta_as_well(env):
|
|
client, repo, meta = env
|
|
repo.items["camp_1"] = _campaign()
|
|
|
|
response = client.delete("/api/campaigns/camp_1")
|
|
|
|
assert response.status_code == 204
|
|
assert meta.deleted == ["meta_camp_1"]
|
|
assert repo.deleted == ["camp_1"]
|
|
|
|
|
|
def test_delete_keeps_the_local_row_when_meta_delete_fails(env):
|
|
"""Forgetting it here while it still spends there is the worst outcome."""
|
|
client, repo, meta = env
|
|
meta.error = MetaError("Permission denied", code=200)
|
|
repo.items["camp_1"] = _campaign()
|
|
|
|
response = client.delete("/api/campaigns/camp_1")
|
|
|
|
assert response.status_code == 502
|
|
assert "Ads Manager" in response.json()["detail"]
|
|
assert repo.deleted == []
|
|
assert "camp_1" in repo.items
|
|
|
|
|
|
def test_delete_of_an_imported_campaign_is_refused(env):
|
|
"""Deleting it on Meta would destroy Ads Manager work; deleting only the
|
|
local row would achieve nothing, since the reconciler re-imports it."""
|
|
client, repo, meta = env
|
|
repo.items["camp_1"] = _campaign(origin="imported")
|
|
|
|
response = client.delete("/api/campaigns/camp_1")
|
|
|
|
assert response.status_code == 409
|
|
assert "Ads Manager" in response.json()["detail"]
|
|
assert meta.deleted == []
|
|
assert repo.deleted == []
|
|
|
|
|
|
def test_an_imported_campaign_can_be_deleted_when_explicitly_asked(env):
|
|
"""The default guards against a stray click, not against the account
|
|
owner. Refusing outright left no way to clear a campaign MaskanX itself
|
|
created and then re-imported after a half-finished sync — which is
|
|
exactly what a failed connection test produces."""
|
|
client, repo, meta = env
|
|
repo.items["camp_1"] = _campaign(origin="imported")
|
|
|
|
response = client.delete("/api/campaigns/camp_1?delete_in_meta=true")
|
|
|
|
assert response.status_code == 204
|
|
assert meta.deleted == ["meta_camp_1"]
|
|
assert repo.deleted == ["camp_1"]
|
|
|
|
|
|
def test_the_refusal_names_the_way_out(env):
|
|
"""A 409 that does not say how to proceed just strands the operator."""
|
|
client, repo, meta = env
|
|
repo.items["camp_1"] = _campaign(origin="imported")
|
|
|
|
detail = client.delete("/api/campaigns/camp_1").json()["detail"]
|
|
|
|
assert "delete_in_meta=true" in detail
|
|
|
|
|
|
def test_delete_of_an_unsynced_campaign_does_not_call_meta(env):
|
|
client, repo, meta = env
|
|
repo.items["camp_1"] = _campaign(status="draft", meta_campaign_id=None)
|
|
|
|
response = client.delete("/api/campaigns/camp_1")
|
|
|
|
assert response.status_code == 204
|
|
assert meta.deleted == []
|
|
assert repo.deleted == ["camp_1"]
|
|
|
|
|
|
def test_delete_of_a_missing_campaign_is_404(env):
|
|
client, repo, meta = env
|
|
|
|
response = client.delete("/api/campaigns/nope")
|
|
|
|
assert response.status_code == 404
|
|
assert meta.deleted == []
|
|
|
|
|
|
async def test_unsync_reports_whether_it_deleted():
|
|
meta = FakeMeta()
|
|
|
|
assert await unsync_campaign(meta, _campaign()) is True
|
|
assert await unsync_campaign(meta, _campaign(origin="imported")) is False
|
|
assert await unsync_campaign(meta, _campaign(meta_campaign_id=None)) is False
|
|
assert meta.deleted == ["meta_camp_1"]
|
|
|
|
|
|
async def test_unsync_deletes_an_imported_campaign_only_when_told_to():
|
|
meta = FakeMeta()
|
|
|
|
imported = _campaign(origin="imported")
|
|
assert await unsync_campaign(meta, imported, include_imported=True) is True
|
|
assert meta.deleted == ["meta_camp_1"]
|
|
|
|
# No campaign id still wins over the override: there is nothing to delete.
|
|
never_synced = _campaign(origin="imported", meta_campaign_id=None)
|
|
assert await unsync_campaign(meta, never_synced, include_imported=True) is False
|
|
assert meta.deleted == ["meta_camp_1"]
|
|
|
|
|
|
# --- client transport ---
|
|
|
|
|
|
async def test_delete_object_issues_a_graph_delete():
|
|
calls = []
|
|
|
|
async def transport(method, url, params):
|
|
calls.append((method, url))
|
|
return {"success": True}
|
|
|
|
await MetaClient("tok", transport=transport).delete_object("meta_camp_1")
|
|
|
|
assert calls == [("DELETE", "https://graph.facebook.com/v23.0/meta_camp_1")]
|
|
|
|
|
|
async def test_delete_object_treats_an_absent_object_as_deleted():
|
|
"""Retrying a partially-failed cleanup must not fail on the done part."""
|
|
|
|
async def transport(method, url, params):
|
|
return {"error": {"message": "Unsupported get request.", "code": 100}}
|
|
|
|
await MetaClient("tok", transport=transport).delete_object("gone")
|
|
|
|
|
|
async def test_delete_object_raises_on_any_other_meta_error():
|
|
async def transport(method, url, params):
|
|
return {"error": {"message": "Permission denied", "code": 200}}
|
|
|
|
with pytest.raises(MetaError):
|
|
await MetaClient("tok", transport=transport).delete_object("meta_camp_1")
|