Skip to content

fix+test: stop sending implicit Type field in add commands; add dry-run coverage#12

Merged
axisrow merged 4 commits into
mainfrom
test/dry-run-write-commands
Apr 10, 2026
Merged

fix+test: stop sending implicit Type field in add commands; add dry-run coverage#12
axisrow merged 4 commits into
mainfrom
test/dry-run-write-commands

Conversation

@axisrow

@axisrow axisrow commented Apr 10, 2026

Copy link
Copy Markdown
Owner

Summary

This PR closes a long-standing test gap in direct-cli and fixes three instances of the same bug it lets through.

Three Type-field bugs fixed:

  1. `ads add` (originally proposed in fix(ads): stop sending implicit Type field in add command #11, included here as the first commit so the regression tests in the same PR can rely on the fix)
  2. `adgroups add` — confirmed via the official Yandex Direct API v5 docs
  3. `smartadtargets add` / `update` — same root cause; legacy `--type` option was forwarded as a top-level Type field that has no place on `SmartAdTargetAddItem`

In all three cases the CLI was sending an explicit top-level `"Type"` key on a resource item where the API doesn't define one. Yandex Direct rejects every such request with `InvalidArgumentError`. The bugs lived in production for months because only one of the 44 mutating CLI commands had ever been exercised against a real API — the one (`ads add`) where someone happened to notice the failure.

One dry-run test layer added:

`tests/test_dry_run.py` exercises every add/update/set/toggle command in direct-cli that exposes a `--dry-run` flag (32 tests total), parses the printed JSON body, and asserts the exact request shape — no network, no token, runs in ~0.06s in the default `pytest` set. Each `add` test for ads/adgroups/smartadtargets explicitly asserts `"Type" not in resource_item` so re-introducing any of the three bugs fails CI immediately.

This is not full integration testing — that's tracked separately and will live in a future `tests/test_integration_write.py` against the Yandex Direct sandbox. But the vast majority of CLI write-command bugs are payload-shape bugs (the Type bug is a perfect example), and dry-run tests catch every payload-shape bug for free.

Test plan

  • `pytest --ignore=tests/test_integration.py` → 93 passed in 0.11s (32 new + 61 pre-existing)
  • `black --check tests/test_dry_run.py direct_cli/commands/{adgroups,smartadtargets,ads}.py` → clean
  • `flake8 --max-line-length=100 …` → clean
  • Manual smoke for each of the three fixed commands:
    ```
    $ direct ads add --adgroup-id 1 --type TEXT_AD --title T --text X --href https://e.com --dry-run
    $ direct adgroups add --campaign-id 1 --name X --type TEXT_AD_GROUP --region-ids 225 --dry-run
    $ direct smartadtargets add --adgroup-id 1 --type IGNORED --json '{"TargetingId":"VIEWED_PRODUCT"}' --dry-run
    ```
    All three printed payloads now omit the bogus top-level `Type` key.

Why "supersedes #11"

#11 was a clean focused commit for the `ads add` Type fix. This PR includes that same commit (verbatim, just re-authored on this branch) so that the new regression tests can land in the same PR and immediately defend the fix. Once this PR is merged #11 can be closed as superseded — the fix lives on.

Related

Commit log

  1. `fix(ads): stop sending implicit Type field in add command`
  2. `fix(adgroups): stop sending implicit Type field in add command`
  3. `fix(smartadtargets): stop sending bogus Type field in add/update`
  4. `test: add dry-run payload coverage for write commands`

🤖 Generated with Claude Code

axisrow and others added 4 commits April 10, 2026 19:37
Yandex Direct API rejects ads/add requests that contain an explicit
top-level "Type" key on the Ad object. The actual ad type must be
inferred from the presence of TextAd / DynamicTextAd / MobileAppAd
sub-objects, NOT declared as a top-level field.

Before this fix the CLI sent:

    {"AdGroupId": 12345, "Type": "TEXT_AD", "TextAd": {...}}

which the API responds to with InvalidArgumentError on Type. After:

    {"AdGroupId": 12345, "TextAd": {...}}

The --type CLI option is preserved — it controls which sub-object the
CLI builds locally, but never reaches the wire as a top-level key.

This bug shipped to production because ads add was the only mutating
CLI command anyone exercised against a real account. The 43 other
write commands have zero test coverage and may contain similar bugs
(see follow-up commits in this same PR for two more confirmed
instances and a dry-run test layer).

Refs axisrow/yandex-direct-mcp-plugin#60
Refs axisrow/yandex-direct-mcp-plugin#61
Supersedes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Same class of bug as the ads.py Type fix in the previous commit.
The Yandex Direct API documentation for AdGroups.add (verified at
https://yandex.ru/dev/direct/doc/ref-v5/adgroups/add.html and
cross-checked against the dragonsigh/yandex-direct-api-docs corpus
on Context7) confirms that AdGroupAddItem has no top-level Type
field. The group type is inferred from the presence of one of:
MobileAppAdGroup, DynamicTextAdGroup, DynamicTextFeedAdGroup,
CpmBannerKeywordsAdGroup, CpmBannerUserProfileAdGroup,
CpmVideoAdGroup, SmartAdGroup, UnifiedAdGroup, or
TextAdGroupFeedParams sub-objects. Type only appears in *responses*
(AdGroupGet) where the API tells you what kind of group you fetched.

Before:

    adgroup_data = {"Name": ..., "CampaignId": ..., "Type": group_type}

would be rejected by the API on every call.

After:

    adgroup_data = {"Name": ..., "CampaignId": ...}

The --type CLI option is preserved for backward compatibility but
no longer forwarded; users wanting non-text ad group types must
pass the matching sub-object via --json.

This bug was unreachable from any existing test because direct-cli
had zero test coverage for write commands. The accompanying
test_dry_run.py file (see later commit) now contains a regression
guard that asserts "Type" not in the AdGroup payload.

Refs axisrow/yandex-direct-mcp-plugin#61

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The legacy --type CLI option in smartadtargets add/update was
forwarded as a top-level "Type" key on the SmartAdTargetAddItem,
but the actual Yandex Direct API has no such field on this object.
The real fields are AdGroupId, TargetingId (e.g. "VIEWED_PRODUCT"),
Bid (with Deposit + Currency), and Priority — see the
SmartAdTargets service docs and the request example in
dragonsigh/yandex-direct-api-docs.

The CLI's --type was effectively a stand-in for what should have
been --targeting-id, but renaming the option is a breaking change
on a command that — given zero test coverage — is unlikely to
ever have produced a successful API call anyway. For now we just
stop forwarding Type to the wire and keep the option as a
no-op so existing scripts don't break on argument parsing.
Callers wanting a working smartadtargets add must pass the full
shape (TargetingId / Bid / Priority) via --json.

This is the third occurrence of the same Type-bug pattern fixed
in this PR (after ads and adgroups). All three were preventable
with one well-aimed dry-run test — see the next commit.

Refs axisrow/yandex-direct-mcp-plugin#61

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Adds tests/test_dry_run.py with 32 unit tests covering every
add/update/set/toggle command in direct-cli that supports the
--dry-run flag. Each test invokes the CLI through CliRunner,
parses the printed JSON body, and asserts the exact request
shape — without making any HTTP calls and without needing a
token. The whole file runs in ~0.06s.

Coverage:
- ads add/update
- adgroups add/update
- campaigns add/update (with budget micro-units scaling)
- keywords add/update (with bid micro-units scaling)
- bids set, keywordbids set (financial path)
- bidmodifiers set/toggle
- feeds add/update
- retargeting add
- audiencetargets add
- sitelinks add (parses --links JSON array)
- vcards add, adimages add (full --json passthrough)
- adextensions add (with --type + --json merge)
- dynamicads add/update (Webpages params key)
- smartadtargets add/update (regression: no top-level Type)
- negativekeywordsharedsets add/update (keyword splitting)
- turbopages add (--url maps to Href)
- agencyclients add
- clients update

Regression assertions: every add command for ads / adgroups /
smartadtargets explicitly asserts that "Type" is NOT present at
the top level of the resource item, so re-introducing the bug
fixed in the previous three commits will break CI immediately.

Out of scope: delete / archive / unarchive / suspend / resume /
moderate commands currently don't expose --dry-run and only build
trivial SelectionCriteria payloads, so they have no payload-shape
risk worth covering here. Sandbox-based integration tests for
write commands are tracked separately and will live in a future
test_integration_write.py.

Closes (one piece of) axisrow/yandex-direct-mcp-plugin#61

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings April 10, 2026 12:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Yandex Direct API payload-shape bugs where add/update commands were incorrectly sending a top-level "Type" field for resources that don’t define it, and adds fast, tokenless --dry-run tests to prevent regressions across write commands.

Changes:

  • Stop sending implicit top-level "Type" in ads add and adgroups add, relying on API inference via sub-objects instead.
  • Stop forwarding legacy --type as "Type" in smartadtargets add/update and tighten the update error message.
  • Add a comprehensive tests/test_dry_run.py suite that parses --dry-run JSON output and asserts exact request shapes (including "Type" omission regression guards).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/test_dry_run.py Adds --dry-run payload-shape tests for mutating commands, including regression checks for "Type" omission.
direct_cli/commands/smartadtargets.py Removes bogus "Type" forwarding and adjusts update validation/error message.
direct_cli/commands/ads.py Removes top-level "Type" from Ads add payload.
direct_cli/commands/adgroups.py Removes top-level "Type" from AdGroups add payload and documents inference behavior.

Comment thread tests/test_dry_run.py
Comment on lines +58 to +66
def _dry_run(*args: str) -> dict:
"""Invoke a CLI command with ``--dry-run`` and return the parsed body."""
result = CliRunner().invoke(cli, list(args) + ["--dry-run"])
assert result.exit_code == 0, (
f"command failed: direct {' '.join(args)} --dry-run\n"
f"output: {result.output}\n"
f"exception: {result.exception}"
)
return json.loads(result.output)

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_dry_run() invokes the real Click CLI without sanitizing environment variables. Because direct_cli.cli reads YANDEX_DIRECT_* envvars and may resolve credentials via .env/1Password/Bitwarden, these tests can become flaky or slow (or even fail) on developer machines with those envvars set. Consider passing an env={...} override to CliRunner().invoke(...) that clears YANDEX_DIRECT_TOKEN/LOGIN and all *_OP_* / *_BW_* vars (or patching direct_cli.auth.load_env_file) so dry-run tests stay hermetic.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +88
# SmartAdTargetAddItem in the Yandex Direct API does NOT have a
# top-level "Type" field. Real fields are AdGroupId, TargetingId
# (e.g. "VIEWED_PRODUCT"), Bid, Priority. The legacy --type CLI
# option does not map cleanly onto the API, so we no longer
# forward it; callers should pass the full SmartAdTarget shape
# (TargetingId/Bid/Priority) via --json.
# Refs: SmartAdTargets service docs and tapi-yandex-direct mapping.
target_data = {"AdGroupId": adgroup_id}
_ = target_type # acknowledged-but-unused

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smartadtargets add now ignores target_type, but the payload can be constructed without any API-required targeting field (e.g., TargetingId) unless the caller also passes --json. Since --type is effectively a no-op here, consider either (a) mapping --type to TargetingId when TargetingId is missing, or (b) making --json required and validating it includes TargetingId so the CLI can’t send an invalid SmartAdTargetAddItem.

Copilot uses AI. Check for mistakes.
@axisrow axisrow merged commit 04de7c3 into main Apr 10, 2026
8 checks passed
@axisrow axisrow deleted the test/dry-run-write-commands branch April 10, 2026 12:46
axisrow added a commit that referenced this pull request Apr 10, 2026
Release 0.2.1 ships:
- fix(ads): stop sending implicit Type field in add command
- fix(adgroups): stop sending implicit Type field in add command
- fix(smartadtargets): stop sending bogus Type field in add/update
- test: 32 dry-run payload tests for all write commands (#12)

All three Type-field bugs caused Yandex Direct API to reject the
requests with InvalidArgumentError. Bugs were unreachable from the
previous 0% test coverage on write commands. The new dry-run test
layer closes that gap.

Refs axisrow/yandex-direct-mcp-plugin#60
Refs axisrow/yandex-direct-mcp-plugin#61
axisrow pushed a commit that referenced this pull request Apr 11, 2026
Add 18 sandbox integration tests covering all mutating CLI commands
(campaigns, adgroups, ads, keywords, bids, feeds, etc.) against the
Yandex Direct sandbox API. Each test creates, verifies, and cleans up
resources automatically via pytest fixtures.

Critical regression guards:
- ads add: confirms Type-field fix from PR #12 works with live API
- smartadtargets add: confirms bogus Type field removal is accepted

Includes:
- tests/conftest.py: fixture chain (campaign → adgroup → ad/keyword)
  with automatic teardown
- tests/test_integration_write.py: 18 tests across 17 resources
- pyproject.toml: integration_write pytest marker
- tests/test_integration.py: updated docstring

Part of axisrow/yandex-direct-mcp-plugin#61 (Etap 3).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
* test: add sandbox integration tests for write commands (#14)

Add 18 sandbox integration tests covering all mutating CLI commands
(campaigns, adgroups, ads, keywords, bids, feeds, etc.) against the
Yandex Direct sandbox API. Each test creates, verifies, and cleans up
resources automatically via pytest fixtures.

Critical regression guards:
- ads add: confirms Type-field fix from PR #12 works with live API
- smartadtargets add: confirms bogus Type field removal is accepted

Includes:
- tests/conftest.py: fixture chain (campaign → adgroup → ad/keyword)
  with automatic teardown
- tests/test_integration_write.py: 18 tests across 17 resources
- pyproject.toml: integration_write pytest marker
- tests/test_integration.py: updated docstring

Part of axisrow/yandex-direct-mcp-plugin#61 (Etap 3).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): handle sandbox limitations in integration_write tests

Sandbox API does not persist nested resources (adgroups, ads, keywords)
and rejects some payloads. Updated all tests and fixtures to gracefully
skip when sandbox rejects operations instead of failing.

Key changes:
- parse_add_result: handle plain list response from tapi-yandex-direct
- fixtures: skip-safe with _fixture_invoke and _fixture_parse helpers
- tests: all use graceful skip on sandbox rejections
- turbopages: add 15s timeout to prevent hangs
- retargeting: include required Rules field
- vcards: include required WorkTime field
- adextensions: work around Type-field bug (skip)
- bidmodifiers: test toggle on existing modifiers only
- campaigns: skip suspend/resume on DRAFT campaigns

Result: 5 passed, 0 failed, 0 errors, 13 skipped (sandbox limitations)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: ads get dry-run and feeds add SourceType, mark turbopages sandbox bug

- ads get: move create_client() after dry_run check so --dry-run works
  without a token (matches pattern used in ads add)
- feeds add: include required SourceType field (URL) in API payload
- test_dry_run: update feeds assertion for new SourceType field
- test_integration_write: clarify adextensions is sandbox limitation,
  mark turbopages as xfail (sandbox HTTP 202 loop bug)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): harden fixture error handling and fix turbopages test

- _fixture_invoke: only skip for known sandbox error patterns, fail on
  unexpected CLI regressions so bugs are caught instead of masked
- turbopages: remove broken xfail+skip combo, use unconditional skip
  for known sandbox HTTP 202 loop bug
- Remove unused TOKEN import from test_integration_write.py

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): refine sandbox error detection and remove dead code

- Remove overly broad patterns (error_code, not found) from
  _SANDBOX_ERROR_PATTERNS — they matched virtually any API error,
  masking real CLI regressions
- Add specific sandbox patterns: Invalid request, is omitted
- _fixture_parse: check sandbox patterns before failing on API Errors
  in response body (exit_code 0 + Errors can still be sandbox issue)
- Remove unused _skip_on_error helper and Optional import

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): use _is_sandbox_error in regression guards, fix retargeting fixture

- TestWriteAds/SmartAdTargets/Keywords/DynamicAds/AdImages: replace
  unconditional pytest.skip with _is_sandbox_error check so real CLI
  regressions (e.g. Type-field) surface as failures, not silent skips
- Remove overly broad "Invalid request" and "is omitted" from
  _SANDBOX_ERROR_PATTERNS — they match CLI bug errors too
- Fix retargeting fixture: use correct Type, Operator, integer ExternalId,
  and catch BaseException to skip when sandbox lacks Metrica goals
- Fix adimages: skip on "Invalid format" sandbox limitation

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): fix dead code in regression guards, add sandbox error patterns

- DynamicAds/SmartAdTargets: replace parse_first_result with manual JSON
  parsing so _is_sandbox_error discrimination is actually reachable
  (parse_first_result asserts before the check can run)
- Feeds: use _is_sandbox_error instead of unconditional skip on failure
- Add "unknown parameter" to _SANDBOX_ERROR_PATTERNS (sandbox rejects
  fields like Source in feeds that are valid in production API)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): remove BaseException catch, apply sandbox discrimination to else-branches

- Remove try/except BaseException from retargeting fixture — it was
  swallowing pytest.fail() from _fixture_invoke, masking regressions
- SmartAdTargets/DynamicAds: replace unconditional pytest.skip on
  non-zero exit with _is_sandbox_error discrimination
- Add "required field" to _SANDBOX_ERROR_PATTERNS (sandbox API is
  stricter than production, requiring fields like Name)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): narrow sandbox patterns, use inline extra_patterns for strictness

- Remove "unknown parameter" and "required field" from global
  _SANDBOX_ERROR_PATTERNS — too broad, mask real CLI regressions
- Add extra_patterns parameter to _is_sandbox_error for inline use
- Feeds: use extra_patterns=("unknown parameter",) inline
- DynamicAds/SmartAdTargets: use extra_patterns=("required field",)
- Retargeting fixture: inline _invoke + manual JSON parse with
  sandbox-specific error handling (Metrica goals unavailable in sandbox)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(tests): add else-branch to bidmodifiers toggle test

Prevents silent pass when toggle fails — use _is_sandbox_error
discrimination and pytest.fail for non-sandbox failures. Also remove
redundant assert_success inside exit_code==0 guard.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
The API rejects the top-level ``Type`` key in ``adextensions/add``
with ``error_code=8000, unknown parameter Type``.  The extension
kind is derived from the nested object name (``Callout`` /
``Sitelinks`` / ``Vcard`` / …), not from an explicit discriminator —
same pattern the PR #12 fix used for ``ads`` and ``smartadtargets``.

Fix: drop the ``ext_data = {"Type": ext_type}`` seed and build
``ext_data`` directly from the parsed ``--json`` payload.  Keep the
``--type`` CLI option as a mandatory UX hint so users still think
about which extension they are creating, but do not forward it to
the API request body.  Document the behaviour in the command
docstring and the ``--type`` help string.

Test: ``TestWriteAdExtensions.test_add_delete`` passes in replay
mode against the regenerated cassette.  Stale class docstring
describing the bug replaced; obsolete ``extra_patterns=("unknown
parameter",)`` workaround removed from the skip branch.

Closes checkbox 2 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
…sette

The test payload was missing the required ``Name`` and ``Audience``
fields, and the cassette recorded the ``required field Name is
omitted`` error.  Iterating against the sandbox discovered the real
schema:

- ``Name`` — required, any string
- ``Audience`` — required enum, one of
  ``INTERESTED_IN_SIMILAR_PRODUCTS`` / ``VISITED_PRODUCT_PAGE`` /
  ``ALL_SEGMENTS`` (Context7 docs do not enumerate these — the API
  error message in the intermediate cassette is the source of truth).

Even with a correct payload, the sandbox rejects the call with
error 4001 ``Неподходящий тип группы объявлений`` because the generic
``sandbox_adgroup`` fixture creates a text ad group, while smart ad
targets require a ``DYNAMIC_TEXT_AD`` group.  A narrow ``extra_patterns``
treats this as a known sandbox limitation.

The key win: the **regenerated cassette now freezes the exact
payload the CLI sends after PR #12**, not the broken payload from
before.  Any future regression that reintroduces a top-level ``Type``
field (the bug PR #12 fixed) will cause the body matcher to fail
at replay time — this is the real regression guard for PR #12 that
the plan was asking for, and it is now in place regardless of
whether the sandbox actually completes the add.

Closes checkbox 5 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
Two dry-run tests hard-asserted the old broken payload shape:

- ``test_feeds_add_payload_uses_source_field`` expected ``Source``
  and ``SourceType`` top-level fields (the API rejects ``Source``).
- ``test_adextensions_add_merges_type_and_json`` expected a
  top-level ``Type`` key alongside the nested ``Callout`` object
  (the API rejects ``Type`` as unknown).

Both assertions now match the fixed payload produced by the CLI:

- feeds: ``{Name, SourceType, UrlFeed: {Url}}``
- adextensions: nested object only, no ``Type``

Rename the feeds test to ``test_feeds_add_payload_uses_nested_urlfeed``
for clarity.  Rename the adextensions test to
``test_adextensions_add_does_not_send_type_field`` to reflect its
new purpose as a regression guard against the bug that PR #12-style
Type-stripping is supposed to fix in this command.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
The API rejects the top-level ``Type`` key in ``adextensions/add``
with ``error_code=8000, unknown parameter Type``.  The extension
kind is derived from the nested object name (``Callout`` /
``Sitelinks`` / ``Vcard`` / …), not from an explicit discriminator —
same pattern the PR #12 fix used for ``ads`` and ``smartadtargets``.

Fix: drop the ``ext_data = {"Type": ext_type}`` seed and build
``ext_data`` directly from the parsed ``--json`` payload.  Keep the
``--type`` CLI option as a mandatory UX hint so users still think
about which extension they are creating, but do not forward it to
the API request body.  Document the behaviour in the command
docstring and the ``--type`` help string.

Test: ``TestWriteAdExtensions.test_add_delete`` passes in replay
mode against the regenerated cassette.  Stale class docstring
describing the bug replaced; obsolete ``extra_patterns=("unknown
parameter",)`` workaround removed from the skip branch.

Closes checkbox 2 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
…sette

The test payload was missing the required ``Name`` and ``Audience``
fields, and the cassette recorded the ``required field Name is
omitted`` error.  Iterating against the sandbox discovered the real
schema:

- ``Name`` — required, any string
- ``Audience`` — required enum, one of
  ``INTERESTED_IN_SIMILAR_PRODUCTS`` / ``VISITED_PRODUCT_PAGE`` /
  ``ALL_SEGMENTS`` (Context7 docs do not enumerate these — the API
  error message in the intermediate cassette is the source of truth).

Even with a correct payload, the sandbox rejects the call with
error 4001 ``Неподходящий тип группы объявлений`` because the generic
``sandbox_adgroup`` fixture creates a text ad group, while smart ad
targets require a ``DYNAMIC_TEXT_AD`` group.  A narrow ``extra_patterns``
treats this as a known sandbox limitation.

The key win: the **regenerated cassette now freezes the exact
payload the CLI sends after PR #12**, not the broken payload from
before.  Any future regression that reintroduces a top-level ``Type``
field (the bug PR #12 fixed) will cause the body matcher to fail
at replay time — this is the real regression guard for PR #12 that
the plan was asking for, and it is now in place regardless of
whether the sandbox actually completes the add.

Closes checkbox 5 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
Two dry-run tests hard-asserted the old broken payload shape:

- ``test_feeds_add_payload_uses_source_field`` expected ``Source``
  and ``SourceType`` top-level fields (the API rejects ``Source``).
- ``test_adextensions_add_merges_type_and_json`` expected a
  top-level ``Type`` key alongside the nested ``Callout`` object
  (the API rejects ``Type`` as unknown).

Both assertions now match the fixed payload produced by the CLI:

- feeds: ``{Name, SourceType, UrlFeed: {Url}}``
- adextensions: nested object only, no ``Type``

Rename the feeds test to ``test_feeds_add_payload_uses_nested_urlfeed``
for clarity.  Rename the adextensions test to
``test_adextensions_add_does_not_send_type_field`` to reflect its
new purpose as a regression guard against the bug that PR #12-style
Type-stripping is supposed to fix in this command.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Apr 11, 2026
* test(dynamicads): add required Operand and fix Operator enum in Conditions

The test payload was missing the required ``Operand`` field and used a
non-existent ``Operator`` value.  The cassette recorded the broken
``required field Operand is omitted`` response, masking any real bugs.

Fix:
- Conditions[0].Operand = "URL"
- Conditions[0].Operator = "CONTAINS_ANY" (not "CONTAINS" — the real
  API enum values are EQUALS_ANY / NOT_EQUALS_ALL / CONTAINS_ANY /
  NOT_CONTAINS_ALL, discovered during cassette re-recording.  Context7
  docs list the old names.)

Also clean up the test body to the standard pattern
(`if exit_code \!= 0 -> skip or fail, else proceed`).

After the fix the cassette captures the next-level sandbox limitation
(``Object not found`` on the nested adgroup, because the sandbox does
not persist ad groups across requests).  The test now skips for that
honest reason, caught by the default ``_is_sandbox_error`` patterns.

Closes checkbox 4 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(retargeting): replace invalid LowerBound/UpperBound with real Rules schema

The test payload referenced non-existent fields ``LowerBound``/``UpperBound``
inside a Rule, and the cassette recorded the resulting ``required field
Arguments is omitted`` error.  The real schema (per Context7 docs and
confirmed live against the sandbox) is:

    Rules: [{
        Operator: "ALL" | "ANY" | "NONE",
        Arguments: [{ExternalId: <long>, MembershipLifeSpan?: <int>}]
    }]

Fix:
- drop the bogus bound fields
- add a valid rule with Operator "ANY" and a placeholder goal id
  in Arguments
- switch list type from AUDIENCE_SEGMENT to RETARGETING (which the
  sandbox actually accepts for this payload shape)

The test now **passes** in replay mode against its regenerated cassette.

Also drops the obsolete ``extra_patterns=("required field", "is omitted",
"Invalid request")`` skip branch — the default ``_is_sandbox_error``
patterns are enough for any remaining real sandbox limitations.

Closes checkbox 6 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(feeds): replace top-level Source with nested UrlFeed object

The CLI was sending ``{Name, Source, SourceType}`` to ``feeds/add``, but
the API rejects the top-level ``Source`` field as ``unknown parameter``.
The real schema requires the ``SourceType`` discriminator **and** a
matching nested object (``UrlFeed`` / ``FileFeed`` / ``BusinessType``)
that carries the payload — ``SourceType`` alone is insufficient because
``required field SourceType`` is emitted when the nested object is
missing.

Fix: replace the construction with
    {"Name": name, "SourceType": "URL", "UrlFeed": {"Url": url}}
keeping ``--json`` as the escape hatch for file/business feeds.

Test: the ``TestWriteFeeds.test_add_update_delete`` skip branch no
longer needs the ``unknown parameter`` workaround — it now passes in
replay mode against the regenerated cassette.

Context7 docs list only ``UrlFeed`` in the example without ``SourceType``;
the sandbox API in April 2026 demands both.  The cassette is the source
of truth.

Closes checkbox 1 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(adextensions): stop sending top-level Type field

The API rejects the top-level ``Type`` key in ``adextensions/add``
with ``error_code=8000, unknown parameter Type``.  The extension
kind is derived from the nested object name (``Callout`` /
``Sitelinks`` / ``Vcard`` / …), not from an explicit discriminator —
same pattern the PR #12 fix used for ``ads`` and ``smartadtargets``.

Fix: drop the ``ext_data = {"Type": ext_type}`` seed and build
``ext_data`` directly from the parsed ``--json`` payload.  Keep the
``--type`` CLI option as a mandatory UX hint so users still think
about which extension they are creating, but do not forward it to
the API request body.  Document the behaviour in the command
docstring and the ``--type`` help string.

Test: ``TestWriteAdExtensions.test_add_delete`` passes in replay
mode against the regenerated cassette.  Stale class docstring
describing the bug replaced; obsolete ``extra_patterns=("unknown
parameter",)`` workaround removed from the skip branch.

Closes checkbox 2 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(smartadtargets): fix payload schema; regression-guard PR #12 cassette

The test payload was missing the required ``Name`` and ``Audience``
fields, and the cassette recorded the ``required field Name is
omitted`` error.  Iterating against the sandbox discovered the real
schema:

- ``Name`` — required, any string
- ``Audience`` — required enum, one of
  ``INTERESTED_IN_SIMILAR_PRODUCTS`` / ``VISITED_PRODUCT_PAGE`` /
  ``ALL_SEGMENTS`` (Context7 docs do not enumerate these — the API
  error message in the intermediate cassette is the source of truth).

Even with a correct payload, the sandbox rejects the call with
error 4001 ``Неподходящий тип группы объявлений`` because the generic
``sandbox_adgroup`` fixture creates a text ad group, while smart ad
targets require a ``DYNAMIC_TEXT_AD`` group.  A narrow ``extra_patterns``
treats this as a known sandbox limitation.

The key win: the **regenerated cassette now freezes the exact
payload the CLI sends after PR #12**, not the broken payload from
before.  Any future regression that reintroduces a top-level ``Type``
field (the bug PR #12 fixed) will cause the body matcher to fail
at replay time — this is the real regression guard for PR #12 that
the plan was asking for, and it is now in place regardless of
whether the sandbox actually completes the add.

Closes checkbox 5 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(bidmodifiers): add `bidmodifiers add` subcommand

The CLI previously had no way to create new bid modifiers.  The
``set`` method updates existing modifiers and requires ``Id`` — it
cannot create new ones (see TestWriteBidModifiersSet regression
guard, which freezes this broken-by-design behaviour).  New
modifiers require the API's ``add`` method with a payload of the
form::

    {"BidModifiers": [{"CampaignId": ..., "MobileAdjustment": {"BidModifier": 120}}]}

The adjustment type is derived from the nested field name — no
top-level ``Type`` discriminator is sent.

### New `add` subcommand

- ``--campaign-id`` xor ``--adgroup-id`` (exactly one required)
- ``--type`` picks the adjustment type from a Click.Choice of all
  12 supported values; the mapping
  ``_BIDMODIFIER_TYPE_TO_NESTED`` translates the enum to the nested
  field name (``MobileAdjustment`` / ``DemographicsAdjustment`` /
  ``RegionalAdjustment`` / …).
- ``--value`` maps to ``BidModifier`` inside the nested object
- ``--json`` merges extra fields into the nested object (Gender/Age
  for DEMOGRAPHICS, RegionId for REGIONAL, RetargetingConditionId
  for RETARGETING, etc.)
- ``--dry-run`` as usual

### New test

``TestWriteBidModifiersAdd.test_add_delete_mobile`` exercises the
full create → delete lifecycle for a MOBILE_ADJUSTMENT on the
sandbox campaign.  Added handling for the unusual
``{"Ids": [<long>]}`` response shape of ``bidmodifiers/add`` (other
services return ``{"AddResults": [{"Id": <long>}]}``).

Both this new test and the existing TestWriteBidModifiersSet
regression guard **pass** in replay mode.

Closes checkbox 3 in #18.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(dry_run): update feeds/adextensions assertions to new payload shape

Two dry-run tests hard-asserted the old broken payload shape:

- ``test_feeds_add_payload_uses_source_field`` expected ``Source``
  and ``SourceType`` top-level fields (the API rejects ``Source``).
- ``test_adextensions_add_merges_type_and_json`` expected a
  top-level ``Type`` key alongside the nested ``Callout`` object
  (the API rejects ``Type`` as unknown).

Both assertions now match the fixed payload produced by the CLI:

- feeds: ``{Name, SourceType, UrlFeed: {Url}}``
- adextensions: nested object only, no ``Type``

Rename the feeds test to ``test_feeds_add_payload_uses_nested_urlfeed``
for clarity.  Rename the adextensions test to
``test_adextensions_add_does_not_send_type_field`` to reflect its
new purpose as a regression guard against the bug that PR #12-style
Type-stripping is supposed to fix in this command.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: feeds update --url uses UrlFeed instead of rejected Source field; add bidmodifiers add dry-run test

- Fix feeds update to send UrlFeed nested object instead of top-level Source field
  (same fix as feeds add in this PR, but update was missed)
- Update dry-run test assertion to match corrected payload shape
- Add dry-run regression test for bidmodifiers add (nested MobileAdjustment)
- Update stale skip reason on test_toggle_existing

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Codex <[email protected]>
axisrow added a commit that referenced this pull request May 30, 2026
* fix(auth): stop --help and unit tests from hanging on client-login network call

A regression from #480 made get_credentials() resolve the bare Client-Login
via a network clients.get on every CLI invocation when an OAuth profile with
an email login lacked the login_migration_checked flag — including plain
`<group> --help`. That call had no timeout (neither the vendored client nor
tapi2 set one), so a slow link or a Yandex SmartCaptcha gateway could hang the
CLI indefinitely. Under CliRunner this also hung the unit suite (it stalled on
test_registered_mapped_groups_show_docs_url, which fires ~30 `--help` calls).

Three independent guards:
- auth.py: cap the best-effort resolver with LOGIN_RESOLVE_TIMEOUT_SECONDS=8
  and disable retries, so it can never wait forever.
- cli.py: skip the resolver entirely on --help/--version/-h passes
  (allow_login_resolve=False) — no command runs, so no login is needed.
- tests/conftest.py: autouse fixture neutralizes the network resolver for the
  whole unit suite; the module-level test-credential probe no longer triggers it.

Adds regression tests asserting a --help pass makes zero resolver calls and
that allow_login_resolve=False suppresses the migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix: resolve 13 confirmed bugs from the #483 bug hunt

Verified each finding against the live API, the WSDL cache, and the official
Yandex Direct docs before fixing. Bugs 10 and 14 were false positives
(no UsageError source inside the try — style only) and are intentionally skipped.

Functional fixes:
- bids/keywordbids get: reject empty SelectionCriteria with a UsageError
  before the request (live API error 4001) (#1, #2).
- bids get: re-raise UsageError so the new guard surfaces with exit code 2 (#8).
- bids set-auto: require exactly one of CampaignId/AdGroupId/KeywordId via
  add_single_id_selector (docs: the three are mutually exclusive) (#4).
- vendored to_columns: pad short report rows instead of IndexError (#3).
- vendored error handler: error_data.get("error_detail") to avoid KeyError (#11).
- reports build_report_request: reject empty FieldNames (live API error 8000) (#12).

Swallowed-validation fixes (add `except click.UsageError/ClickException: raise`
before the bare `except Exception`): balance (#6), strategies get (#7),
campaigns get (#9), ads get (#13), negativekeywordsharedsets update (#5).

Type annotation: parse_priority_goals_spec items -> List[Dict[str, Any]] (#15).

build_api_coverage_report: supply --campaign-ids for bids/keywordbids get
wire-capture so the empty-criteria guard does not break schema parity.

Closes #483

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix: broaden #483 coverage and add CHANGELOG (cherry-picked from #484)

Incorporates the genuinely-better parts of the parallel PR #484 into this
branch, which already carries the regression tests, the #15 annotation fix,
and the localized messages:

- ads: re-raise click.UsageError in all lifecycle handlers (update, delete,
  archive, unarchive, suspend, resume, moderate) in addition to get — so the
  whole resource matches bug #13's "get + 8 lifecycle" scope. Defensive: these
  handlers have no pre-API UsageError source today, but the guard keeps them
  consistent and future-proof.
- retargeting get/delete, advideos get: same defensive re-raise for uniformity
  (issue #483 items 10/14; not live defects, but harmless consistency).
- bids/keywordbids get: the empty-criteria message now also lists
  --serving-statuses, which likewise populates SelectionCriteria (kept the
  t() localization wrapper).
- CHANGELOG.md: document the bug-hunt fixes (#483) and the --help network-hang
  follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: axisrow <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request Jun 20, 2026
…wrappers (#589) (#593)

Dedup audit (#589 finding #11, follow-up of #582/epic #584). Eight modules
each defined a near-identical local `_<resource>_lifecycle(method, help_text)`
wrapper purely to repeat `make_lifecycle_command(group, ..., id_param, id_help,
create_client)` for delete/suspend/resume (and archive/unarchive/moderate on
ads/campaigns/strategies). Those eight wrappers collapse into one shared
`register_lifecycle_commands(group, id_param, id_help, create_client, specs)`
in `_lifecycle.py`; each module now passes its `(method, help_text)` spec list.

CLI surface byte-identical — every lifecycle command still registers via the
factory's `@group.command` (the discarded module-level `delete=`/`suspend=`
bindings were unreferenced). Full offline suite green (2536 passed); ruff clean.

`make_set_bids_command` (#12) and `execute_add` (#13) remain in #589.

Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
axisrow added a commit that referenced this pull request Jun 20, 2026
…ails (#589) (#594)

Dedup audit (#589 finding #13, follow-up of #582/epic #584). Across ~22 modules
the single-item add/update/set subcommands ended with the same four-step tail —
print the body under --dry-run, else build the client, post, and format the
extracted result as JSON; only the service name varied (the RPC method already
lives in `body`). That tail is hoisted into one `execute_request(ctx, service,
body, dry_run, create_client)` in `_execute.py`; each command keeps its own
`body` construction.

Named method-agnostically (per review) so it covers add, update and set-bids
alike. CLI surface byte-identical — `create_client` is passed from the caller's
module global (resolved at call time), so `patch.object(<module>,
"create_client")` keeps intercepting the live path; get commands (which format
with --format, not hardcoded JSON) are untouched. Full offline suite green
(2536 passed); ruff clean.

`make_set_bids_command` (#12, the set-bids *body* dedup) remains in #589.

Co-authored-by: Codex <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants