Skip to content

Commit 12b6625

Browse files
axisrowclaude
andcommitted
test(v4_live): move write lifecycle probes to integration_live_write via CLI
Addresses Codex review finding #2 on #179: write-mutating tests were inheriting the v4_live_read file-level marker, breaking the trust boundary between read-only and write test suites. - Removes test_v4_live_wordstat_lifecycle_contract_opt_in_write and test_v4_live_forecast_lifecycle_contract_opt_in_write from tests/test_v4_live_contracts.py (which is correctly marked v4_live_read). - Adds test_live_draft_v4wordstat_lifecycle and test_live_draft_v4forecast_lifecycle to tests/test_integration_live_write.py under the integration_live_write marker. - Rewrites them to use the CLI (via _invoke_live / CliRunner) instead of native call_v4, matching the convention of every other live-write test in that file. - Drops in-test YANDEX_DIRECT_LIVE_WRITE checks — the file-level skipif already enforces the env gate. Verified: pytest --collect-only -m v4_live_read tests/test_v4_live_contracts.py → 9 read-only tests (no lifecycle probes). pytest --collect-only -m integration_live_write tests/test_integration_live_write.py → both v4 lifecycle tests included. env -u YANDEX_DIRECT_LIVE_WRITE pytest <…lifecycle…> → SKIPPED via file-level skipif. Codex review finding #1 (bare `pytest` hits live API when profile is active) is intentionally deferred: project decision is that v4 live read tests should run whenever a profile is available. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent de194ac commit 12b6625

2 files changed

Lines changed: 87 additions & 70 deletions

File tree

tests/test_integration_live_write.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -423,9 +423,7 @@ def test_live_draft_adimages_add_get_delete() -> None:
423423
else:
424424
result_data = data.get("result", data)
425425
images = result_data.get("AdImages", [])
426-
hashes_in_response = {
427-
img.get("AdImageHash") for img in images
428-
}
426+
hashes_in_response = {img.get("AdImageHash") for img in images}
429427
assert (
430428
img_hash in hashes_in_response
431429
), f"Uploaded image hash {img_hash} not found in get response"
@@ -441,9 +439,7 @@ def test_live_draft_adimages_add_get_delete() -> None:
441439
@pytest.mark.vcr
442440
def test_live_draft_advideos_add_get() -> None:
443441
"""Add a video from file and verify via get."""
444-
video_file = os.path.join(
445-
os.path.dirname(__file__), "fixtures", "test-video.mp4"
446-
)
442+
video_file = os.path.join(os.path.dirname(__file__), "fixtures", "test-video.mp4")
447443
r = _invoke_live(
448444
"advideos",
449445
"add",
@@ -463,9 +459,7 @@ def test_live_draft_advideos_add_get() -> None:
463459
@pytest.mark.vcr
464460
def test_live_draft_creatives_chain_advideo_to_creative() -> None:
465461
"""Chain: add advideo from file -> create creative from it -> verify via get."""
466-
video_file = os.path.join(
467-
os.path.dirname(__file__), "fixtures", "test-video.mp4"
468-
)
462+
video_file = os.path.join(os.path.dirname(__file__), "fixtures", "test-video.mp4")
469463
r = _invoke_live(
470464
"advideos",
471465
"add",
@@ -612,7 +606,12 @@ def test_live_draft_keywords_add_update_delete() -> None:
612606
kid = _extract_first_id(r.output)
613607

614608
r = _invoke_live(
615-
"keywords", "update", "--id", str(kid), "--keyword", "draft test keyword updated"
609+
"keywords",
610+
"update",
611+
"--id",
612+
str(kid),
613+
"--keyword",
614+
"draft test keyword updated",
616615
)
617616
_assert_success(r, "keywords update")
618617

@@ -1251,3 +1250,81 @@ def test_live_draft_ads_suspend_resume_archive_unarchive() -> None:
12511250
_invoke_live("ads", "delete", "--id", str(aid))
12521251
_invoke_live("adgroups", "delete", "--id", str(gid))
12531252
_safe_delete_campaign(cid)
1253+
1254+
1255+
# ── v4 Live API lifecycle ────────────────────────────────────────────────
1256+
1257+
1258+
def test_live_draft_v4wordstat_lifecycle() -> None:
1259+
"""v4wordstat create-report → list-reports → delete-report through the CLI.
1260+
1261+
GetWordstatReport is out of scope: it requires StatusReport=Done (polling).
1262+
"""
1263+
r = _invoke_live(
1264+
"v4wordstat",
1265+
"create-report",
1266+
"--phrases",
1267+
"купить ноутбук",
1268+
"--geo-ids",
1269+
"0",
1270+
)
1271+
_assert_success(r, "v4wordstat create-report")
1272+
report_id = json.loads(r.output)
1273+
assert (
1274+
isinstance(report_id, int) and report_id > 0
1275+
), f"unexpected create-report output: {r.output}"
1276+
1277+
try:
1278+
r = _invoke_live("v4wordstat", "list-reports")
1279+
_assert_success(r, "v4wordstat list-reports")
1280+
reports = json.loads(r.output)
1281+
assert isinstance(reports, list)
1282+
our = next(
1283+
(item for item in reports if item.get("ReportID") == report_id), None
1284+
)
1285+
assert (
1286+
our is not None
1287+
), f"created report {report_id} not in list: {r.output[:500]}"
1288+
assert {"ReportID", "StatusReport"} <= set(our)
1289+
finally:
1290+
r = _invoke_live("v4wordstat", "delete-report", "--report-id", str(report_id))
1291+
_assert_success(r, "v4wordstat delete-report")
1292+
1293+
1294+
def test_live_draft_v4forecast_lifecycle() -> None:
1295+
"""v4forecast create → list → delete through the CLI.
1296+
1297+
get is out of scope: it requires StatusForecast=Done (polling).
1298+
"""
1299+
r = _invoke_live(
1300+
"v4forecast",
1301+
"create",
1302+
"--phrases",
1303+
"купить ноутбук",
1304+
"--geo-ids",
1305+
"213",
1306+
"--currency",
1307+
"RUB",
1308+
)
1309+
_assert_success(r, "v4forecast create")
1310+
forecast_id = json.loads(r.output)
1311+
assert (
1312+
isinstance(forecast_id, int) and forecast_id > 0
1313+
), f"unexpected create output: {r.output}"
1314+
1315+
try:
1316+
r = _invoke_live("v4forecast", "list")
1317+
_assert_success(r, "v4forecast list")
1318+
forecasts = json.loads(r.output)
1319+
assert isinstance(forecasts, list)
1320+
our = next(
1321+
(item for item in forecasts if item.get("ForecastID") == forecast_id),
1322+
None,
1323+
)
1324+
assert (
1325+
our is not None
1326+
), f"created forecast {forecast_id} not in list: {r.output[:500]}"
1327+
assert {"ForecastID", "StatusForecast"} <= set(our)
1328+
finally:
1329+
r = _invoke_live("v4forecast", "delete", "--forecast-id", str(forecast_id))
1330+
_assert_success(r, "v4forecast delete")

tests/test_v4_live_contracts.py

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -203,66 +203,6 @@ def test_v4_sandbox_check_payment_custom_transaction_id_contract():
203203
assert exc_info.value.error_str == "Transaction does not exist"
204204

205205

206-
def test_v4_live_wordstat_lifecycle_contract_opt_in_write():
207-
# Covers CreateNewWordstatReport, GetWordstatReportList, DeleteWordstatReport.
208-
# GetWordstatReport is out of scope: it requires StatusReport=Done (polling).
209-
# Gated behind YANDEX_DIRECT_LIVE_WRITE=1: this test mutates the live
210-
# account (creates and deletes a wordstat report).
211-
if os.getenv("YANDEX_DIRECT_LIVE_WRITE") != "1":
212-
pytest.skip("YANDEX_DIRECT_LIVE_WRITE=1 is required")
213-
token, login = _credentials()
214-
client = create_v4_client(token=token, login=login)
215-
216-
report_id = call_v4(
217-
client,
218-
"CreateNewWordstatReport",
219-
{"Phrases": ["купить ноутбук"], "GeoID": [0]},
220-
)
221-
assert isinstance(report_id, int)
222-
assert report_id > 0
223-
224-
try:
225-
reports = call_v4(client, "GetWordstatReportList")
226-
assert isinstance(reports, list)
227-
our_report = next((r for r in reports if r.get("ReportID") == report_id), None)
228-
assert our_report is not None
229-
assert {"ReportID", "StatusReport"} <= set(our_report)
230-
finally:
231-
# TODO: assert delete return value (== 1 per docs) after first live run.
232-
call_v4(client, "DeleteWordstatReport", report_id)
233-
234-
235-
def test_v4_live_forecast_lifecycle_contract_opt_in_write():
236-
# Covers CreateNewForecast, GetForecastList, DeleteForecastReport.
237-
# GetForecast is out of scope: it requires StatusForecast=Done (polling).
238-
# Gated behind YANDEX_DIRECT_LIVE_WRITE=1: this test mutates the live
239-
# account (creates and deletes a forecast report).
240-
if os.getenv("YANDEX_DIRECT_LIVE_WRITE") != "1":
241-
pytest.skip("YANDEX_DIRECT_LIVE_WRITE=1 is required")
242-
token, login = _credentials()
243-
client = create_v4_client(token=token, login=login)
244-
245-
forecast_id = call_v4(
246-
client,
247-
"CreateNewForecast",
248-
{"Phrases": ["купить ноутбук"], "Currency": "RUB", "GeoID": [213]},
249-
)
250-
assert isinstance(forecast_id, int)
251-
assert forecast_id > 0
252-
253-
try:
254-
forecasts = call_v4(client, "GetForecastList")
255-
assert isinstance(forecasts, list)
256-
our_forecast = next(
257-
(f for f in forecasts if f.get("ForecastID") == forecast_id), None
258-
)
259-
assert our_forecast is not None
260-
assert {"ForecastID", "StatusForecast"} <= set(our_forecast)
261-
finally:
262-
# TODO: assert delete return value (== 1 per docs) after first live run.
263-
call_v4(client, "DeleteForecastReport", forecast_id)
264-
265-
266206
def test_v4_live_tags_get_campaigns_contract():
267207
token, login = _credentials()
268208
campaign_id = _campaign_id(token, login)

0 commit comments

Comments
 (0)