fix+test: stop sending implicit Type field in add commands; add dry-run coverage#12
Conversation
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]>
There was a problem hiding this comment.
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"inads addandadgroups add, relying on API inference via sub-objects instead. - Stop forwarding legacy
--typeas"Type"insmartadtargets add/updateand tighten the update error message. - Add a comprehensive
tests/test_dry_run.pysuite that parses--dry-runJSON 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. |
| 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) |
There was a problem hiding this comment.
_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.
| # 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 |
There was a problem hiding this comment.
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.
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
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]>
* 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]>
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]>
…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]>
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]>
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]>
…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]>
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]>
* 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]>
* 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]>
…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]>
…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]>
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:
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
```
$ 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
🤖 Generated with Claude Code