You added funds and the UI kept showing "Balance: INR 0.00". Verified directly against the live account: `balance` is 0 because that field means amount *owed*, which is correctly nothing on a prepay account — the money you loaded was never billed to you, so there is nothing to owe. The ₹50 was real the whole time; MaskanX was just asking Meta the wrong question. Meta only exposes the wallet amount through `funding_source_details`, which we were not requesting, as a pre-formatted string: "Available balance (₹50.00 INR)". There is no separate numeric field for it — confirmed live, not assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Read-only Meta Graph client behaviour."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from adclaw.meta.client import (
|
|
ACCESS_TOKEN_ENV,
|
|
MetaClient,
|
|
MetaError,
|
|
MetaNotConfiguredError,
|
|
access_token_from_env,
|
|
)
|
|
|
|
|
|
class FakeTransport:
|
|
"""Records calls and returns queued responses."""
|
|
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.calls = []
|
|
|
|
async def __call__(self, method, url, params):
|
|
self.calls.append({"method": method, "url": url, "params": params})
|
|
return self.responses.pop(0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_ad_account_requests_expected_fields():
|
|
transport = FakeTransport([{"id": "act_1", "balance": "0"}])
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
result = await client.get_ad_account("act_1")
|
|
|
|
assert result["balance"] == "0"
|
|
call = transport.calls[0]
|
|
assert call["method"] == "GET"
|
|
assert call["url"].endswith("/v23.0/act_1")
|
|
assert "min_daily_budget" in call["params"]["fields"]
|
|
# A prepay ("Available funds") account reports 0 in `balance` even with
|
|
# money loaded — that field is what is owed, not what is on deposit.
|
|
# `funding_source_details` is the only field carrying the wallet
|
|
# amount, and only as a display string, so it has to be requested.
|
|
assert "funding_source_details" in call["params"]["fields"]
|
|
assert call["params"]["access_token"] == "tok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_graph_error_is_raised_with_code_and_subcode():
|
|
transport = FakeTransport([
|
|
{"error": {"message": "Bad thing", "code": 100, "error_subcode": 33}},
|
|
])
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
with pytest.raises(MetaError) as excinfo:
|
|
await client.get_ad_account("act_1")
|
|
|
|
assert excinfo.value.code == 100
|
|
assert excinfo.value.subcode == 33
|
|
assert "Bad thing" in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_previews_returns_body_per_format():
|
|
transport = FakeTransport([
|
|
{"data": [{"body": "<iframe src='desktop'></iframe>"}]},
|
|
{"data": [{"body": "<iframe src='story'></iframe>"}]},
|
|
])
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
previews = await client.generate_previews(
|
|
"act_1",
|
|
creative={"object_story_spec": {}},
|
|
ad_formats=["DESKTOP_FEED_STANDARD", "INSTAGRAM_STORY"],
|
|
)
|
|
|
|
assert previews["DESKTOP_FEED_STANDARD"] == "<iframe src='desktop'></iframe>"
|
|
assert previews["INSTAGRAM_STORY"] == "<iframe src='story'></iframe>"
|
|
assert transport.calls[0]["params"]["ad_format"] == "DESKTOP_FEED_STANDARD"
|
|
|
|
# The creative must be sent as a JSON-encoded string in the query
|
|
# params, not as a raw dict, since Graph expects `creative` as JSON text.
|
|
sent = transport.calls[0]["params"]["creative"]
|
|
assert isinstance(sent, str)
|
|
assert json.loads(sent) == {"object_story_spec": {}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_previews_skips_formats_with_no_body():
|
|
transport = FakeTransport([{"data": []}])
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
previews = await client.generate_previews(
|
|
"act_1",
|
|
creative={},
|
|
ad_formats=["DESKTOP_FEED_STANDARD"],
|
|
)
|
|
|
|
assert previews == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_previews_fails_fast_on_partial_error():
|
|
# Deliberate contract (not accidental): if any requested ad format's
|
|
# Graph call errors out, generate_previews propagates that MetaError
|
|
# immediately instead of returning a partial dict. The creative is
|
|
# identical across formats, so a creative-level error would fail every
|
|
# format anyway, and surfacing Meta's real message beats a silently
|
|
# incomplete preview set.
|
|
transport = FakeTransport([
|
|
{"data": [{"body": "<iframe src='desktop'></iframe>"}]},
|
|
{"error": {"message": "Bad creative", "code": 100, "error_subcode": 33}},
|
|
])
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
with pytest.raises(MetaError):
|
|
await client.generate_previews(
|
|
"act_1",
|
|
creative={"object_story_spec": {}},
|
|
ad_formats=["DESKTOP_FEED_STANDARD", "INSTAGRAM_STORY"],
|
|
)
|
|
|
|
|
|
def test_access_token_from_env_returns_token(monkeypatch):
|
|
monkeypatch.setenv(ACCESS_TOKEN_ENV, "tok123")
|
|
|
|
assert access_token_from_env() == "tok123"
|
|
|
|
|
|
def test_access_token_from_env_raises_when_whitespace_only(monkeypatch):
|
|
monkeypatch.setenv(ACCESS_TOKEN_ENV, " ")
|
|
|
|
with pytest.raises(MetaNotConfiguredError):
|
|
access_token_from_env()
|
|
|
|
|
|
def test_access_token_from_env_raises_when_unset(monkeypatch):
|
|
monkeypatch.delenv(ACCESS_TOKEN_ENV, raising=False)
|
|
|
|
with pytest.raises(MetaNotConfiguredError):
|
|
access_token_from_env()
|
|
|
|
|
|
def test_meta_not_configured_error_is_a_meta_error():
|
|
assert issubclass(MetaNotConfiguredError, MetaError)
|