2026-08-03 17:33:32 +05:30
|
|
|
# -*- 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 17:45:07 +05:30
|
|
|
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."""
|
2026-08-03 17:33:32 +05:30
|
|
|
client, repo, meta = env
|
|
|
|
|
repo.items["camp_1"] = _campaign(origin="imported")
|
|
|
|
|
|
|
|
|
|
response = client.delete("/api/campaigns/camp_1")
|
|
|
|
|
|
2026-08-03 17:45:07 +05:30
|
|
|
assert response.status_code == 409
|
|
|
|
|
assert "Ads Manager" in response.json()["detail"]
|
2026-08-03 17:33:32 +05:30
|
|
|
assert meta.deleted == []
|
2026-08-03 17:45:07 +05:30
|
|
|
assert repo.deleted == []
|
2026-08-03 17:33:32 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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")
|