Skip to content

fix(cli): close 12 High bugs from #198 audit (PR 2/7)#208

Merged
axisrow merged 4 commits into
mainfrom
worktree-milestone-0.3.9-pr2-fix-12-bugs
May 20, 2026
Merged

fix(cli): close 12 High bugs from #198 audit (PR 2/7)#208
axisrow merged 4 commits into
mainfrom
worktree-milestone-0.3.9-pr2-fix-12-bugs

Conversation

@axisrow

@axisrow axisrow commented May 20, 2026

Copy link
Copy Markdown
Owner

Summary

PR 2 of milestone 0.3.9. The systemic-audit issue #198 found 13 High-severity bugs across mutating CLI commands; PR #205 added the WSDL ↔ CLI parity gate that marked 8 of them as xfail-strict pending their fixes. This PR applies the fixes and drops every xfail marker, so the gate now passes outright.

Counts after this PR:

  • tests/test_wsdl_parity_gate.py — 37 passed, 8 skipped, 0 xfailed, 0 failed.
  • Full offline suite — 733 passed, 43 skipped.

Fixes by audit ID

# File Fix
H1 ads.py Empty-payload guard on ads update.
H2 ads.py Per-type allow-list rejects incompatible flags (e.g. --image-hash on --type TEXT_AD).
H4 adgroups.py --region-ids is now required=True.
H5 adgroups.py Empty-payload guard on adgroups update.
H6 campaigns.py --counter-id is now required for --type SMART_CAMPAIGN.
H7 dynamicads.py Dropped over-constrained --condition requirement (WSDL Conditions minOccurs=0).
H8 bids.py Empty-payload guard on bids set.
H9 keywordbids.py Empty-payload guard on keywordbids set.
H10 keywords.py Empty-payload guard on keywords update.
H11 strategies.py STRATEGY_TYPES rebuilt from StrategyAddItem: 5 bogus removed, 5 WSDL-only added. Membership sets updated.
H12 strategies.py strategies update validates --goal-id for the goal-id family, mirroring add.
H13 Verified false-positive. WSDL ClientUpdateItem has no ClientId minOccurs=1; existing Provide at least one field guard already covers the real WSDL contract.

Gate cleanup

  • All @pytest.mark.xfail(strict=True) markers removed from tests/test_wsdl_parity_gate.py.
  • KNOWN_REQUIRED_FIELD_BUGS exemption set deleted.

The gate now fails fast on any future regression of these patterns.

Pre-existing tests adjusted to new validation surface

  • test_adgroups_add_case_insensitive_default_type — passes --region-ids (now required).
  • test_campaigns_add_smart_requires_filter_average_cpc — passes --counter-id (validated first now).
  • test_dynamicads_add_requires_condition — rewritten as test_dynamicads_add_without_condition_omits_conditions_field to reflect minOccurs=0 behaviour.

Test plan

  • pytest tests/test_wsdl_parity_gate.py → 37 passed, 8 skipped, 0 xfailed.
  • pytest -m 'not integration' → 733 passed, 43 skipped.
  • black + flake8 clean on touched files.
  • CI green.

Refs #198. Part of milestone 0.3.9.

🤖 Generated with Claude Code

Per the systemic-audit issue #198, the WSDL parity gate landed in PR
#205 marked 8 commands as xfail-strict pending their fixes. This PR
applies the fixes one by one and drops every xfail / KNOWN_*_BUGS
exemption, so the gate now passes outright (37 passed, 8 skipped).

Patterns and fixes:

* H1, H5, H8, H9, H10 — empty-payload no-op guard:
  - ``ads update``, ``adgroups update``, ``keywords update``: if the
    built payload has only ``Id``, raise UsageError listing the
    available update fields.
  - ``bids set``, ``keywordbids set``: require at least one bid value
    before constructing the request.

* H2 — silent data loss on ``ads update``:
  - Per-type allow-list (TEXT_AD / TEXT_IMAGE_AD / MOBILE_APP_AD)
    rejects incompatible flags up front (e.g. ``--image-hash`` with
    ``--type TEXT_AD``) instead of silently dropping them.

* H4 — ``adgroups add --region-ids`` is now ``required=True``
  (WSDL ``AdGroupAddItem.RegionIds`` minOccurs=1).

* H6 — ``campaigns add --type SMART_CAMPAIGN --counter-id`` is now
  required (WSDL ``SmartCampaignAddItem.CounterId`` minOccurs=1).

* H7 — ``dynamicads add`` no longer requires ``--condition``; the WSDL
  field is minOccurs=0 and the CLI was over-constraining.

* H11 — ``STRATEGY_TYPES`` in ``direct_cli/commands/strategies.py`` is
  rebuilt from ``StrategyAddItem``: dropped 5 names that were not in
  the WSDL (WbMaximumClicksPerBid, WbMaximumConversionRatePerBid,
  AverageCrrPerCampaign, MaxProfitPerFilter, MaxProfitPerCampaign),
  added 5 that were missing (AverageCpcPerCampaign, AverageCpcPerFilter,
  PayForConversionCrr, AverageCpaMultipleGoals,
  PayForConversionMultipleGoals). Membership sets (CPA / CRR / PFC /
  multi-goal) updated accordingly.

* H12 — ``strategies update`` now validates ``--goal-id`` for the
  goal-id strategy family, mirroring ``strategies add``.

* H13 — verified false-positive. WSDL ``ClientUpdateItem`` does NOT
  declare ``ClientId`` minOccurs=1; the login goes through the
  Client-Login header, and ``clients update`` already enforces
  "Provide at least one field to update". No code change here.

The parity gate (``tests/test_wsdl_parity_gate.py``) drops every
``xfail`` marker and the ``KNOWN_REQUIRED_FIELD_BUGS`` exemption set,
so any regression of these patterns will now fail CI on the spot.

Three pre-existing tests updated to match the new validation surface:
- ``test_adgroups_add_case_insensitive_default_type`` now passes
  ``--region-ids`` (now required).
- ``test_campaigns_add_smart_requires_filter_average_cpc`` now passes
  ``--counter-id`` (now required, validated before
  ``--filter-average-cpc``).
- ``test_dynamicads_add_requires_condition`` rewritten as
  ``test_dynamicads_add_without_condition_omits_conditions_field``
  to reflect the new minOccurs=0 behaviour.

Refs #198. Closes PR 2 of milestone 0.3.9.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@axisrow axisrow added this to the 0.3.9 milestone May 20, 2026
@axisrow
axisrow marked this pull request as ready for review May 20, 2026 14:19
Copilot AI review requested due to automatic review settings May 20, 2026 14:19
@axisrow

axisrow commented May 20, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 6m 12s —— View job


Claude PR Review — Critical-issues only

  • Read PR diff
  • Inspect each changed command file
  • Verify empty-payload guards and per-type allow-lists against WSDL
  • Check strategy enum / strategies update flow against WSDL
  • Post findings

I cross-checked each of the 12 fixes against the WSDL cache in tests/wsdl_cache/. 11 of 12 fixes are correct and a real improvement. I found one potential over-constraint in H12 and one harmless code-style nit; no security issues, no data-loss risks, no performance regressions.

🟡 Potential bug — H12: strategies update may over-constrain --goal-id vs. WSDL

direct_cli/commands/strategies.py:354-358:

if strategy_type:
    # GoalId is minOccurs=1 inside Strategy*Update for the
    # goal-id family (issue #198 H12). Mirror the add command's
    # validation here so updates do not silently drop GoalId.
    if strategy_type in GOAL_ID_STRATEGY_TYPES and goal_id is None:
        raise click.UsageError("Provide --goal-id for this strategy type")

The inline comment's WSDL claim does not match the cached schema. In tests/wsdl_cache/strategies.xml:

  • StrategyAverageCpaAddItem.GoalIdminOccurs="1" (required on add — correct)
  • StrategyAverageCpaBase.GoalIdminOccurs="0" (and every Strategy*UpdateItem is <extension base="ns:Strategy*Base"/> with no override)

The same pattern holds for StrategyPayForConversionBase, StrategyAverageCpaPerCampaignBase, StrategyAverageCrrBase, StrategyPayForConversionCrrBase. So per WSDL, GoalId is optional inside every Strategy*UpdateItem. The justification "mirroring add" therefore mixes contracts that differ on purpose: WSDL lets update change --average-cpa (or any other base field) without re-specifying GoalId.

Concrete regression this introduces, if the WSDL is authoritative:

direct strategies update --id 123 --type AverageCpa --average-cpa 1500000
# was accepted; now → UsageError: Provide --goal-id for this strategy type

The user has to look up and repeat the existing GoalId even when they are not changing it.

Two ways to resolve, depending on what the audit actually observed:

  1. If the live API really rejects updates without GoalId (despite the WSDL): keep the validation, but rewrite the comment so it doesn't claim minOccurs=1. E.g. # Empirically, the Yandex live API rejects Strategy*Update without GoalId even though the WSDL marks it minOccurs=0 (issue #198 H12). That preserves the future-proofing for whoever wonders why we're stricter than the schema.
  2. If the audit didn't actually verify this against the live API: drop the H12 validation. The WSDL is the contract and the gate already accepts the no-validation state (the parity gate looks at top-level StrategyUpdateItem required fields, which don't include GoalId).

Fix this →

🟢 Other fixes — verified correct

ID Verdict Notes
H1 ads update empty-subtype guard fires when len(ad_data) == 1 after type-specific block is built. Covers all paths.
H2 Per-type allow-list is built before the dry-run branch and uses is not None so explicit empties (--title "") still fall through to the empty-payload guard.
H4 Matches WSDL AdGroupAddItem.RegionIds minOccurs=1.
H5 adgroups update empty-payload guard.
H6 Matches WSDL SmartCampaignAddItem.CounterId minOccurs=1. Validation runs before the --filter-average-cpc check, which is a UX improvement — the test was updated accordingly.
H7 Matches WSDL WebpageAddItem.Conditions minOccurs=0. Correct removal of over-constraint.
H8 bids set requires --bid.
H9 keywordbids set requires at least one of --search-bid / --network-bid.
H10 keywords update empty-payload guard.
H11 I diffed STRATEGY_TYPES against every <element name="X" type="ns:Strategy*AddItem"/> in the WSDL — all 16 names match exactly, no extras, no omissions.
H13 Verified there is no ClientId minOccurs=1 in ClientUpdateItem; the existing "Provide at least one field" guard already covers the real WSDL contract.

🔵 Minor (non-blocking)

  • direct_cli/commands/keywordbids.py:99-101 — implicit string concatenation:

    raise click.UsageError(
        "keywordbids set requires at least one of " "--search-bid or --network-bid."
    )

    Two adjacent string literals get joined to "...of --search-bid..." which is correct output, just an unusual idiom. Not a bug; can be left as-is or flattened.

Summary

The only finding worth gating on is H12: the inline comment makes a WSDL claim that the cached schema disproves, and the validation may reject valid update payloads that the WSDL allows. Either rewrite the rationale to point at live-API behavior or drop the check. Everything else looks good — empty-payload guards close real silent-no-op bugs, the per-type allow-list eliminates the silent-data-loss class, and STRATEGY_TYPES is now exactly the WSDL set.
· Branch

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 is the second milestone PR for the new WSDL ↔ CLI parity gate: it removes the xfail(strict=True) markers by adding/adjusting CLI-side validation so mutating commands (add/update/set) reject invalid “no-op” payloads and enforce WSDL-required inputs, bringing the parity gate fully green.

Changes:

  • Removed all strict xfail markers and the KNOWN_REQUIRED_FIELD_BUGS exemption from the WSDL parity gate tests.
  • Added empty-payload guards across multiple mutating commands (e.g., ads update, adgroups update, bids set, keywordbids set, keywords update) to prevent silent no-ops.
  • Tightened/adjusted validation to match WSDL semantics (e.g., adgroups add --region-ids required; campaigns add SMART_CAMPAIGN --counter-id required; dynamicads add no longer requires --condition).

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_wsdl_parity_gate.py Drops xfails/exemptions so the parity gate now fails on regressions.
tests/test_low_coverage_payloads.py Updates dynamicads add test to reflect Conditions being optional and omitted when absent.
tests/test_dry_run.py Adjusts existing dry-run tests for newly-required flags (--region-ids, --counter-id).
direct_cli/commands/ads.py Adds per-type flag allow-list + rejects empty-subtype {Id} updates.
direct_cli/commands/adgroups.py Makes --region-ids required for add; rejects empty update payloads.
direct_cli/commands/bids.py Rejects bids set with no bid value.
direct_cli/commands/keywordbids.py Rejects keywordbids set when both bid fields are missing.
direct_cli/commands/keywords.py Rejects keywords update when no updatable fields are provided.
direct_cli/commands/dynamicads.py Makes --condition optional and only emits Conditions when provided.
direct_cli/commands/campaigns.py Enforces --counter-id for SMART_CAMPAIGN and always includes it in payload.
direct_cli/commands/strategies.py Updates STRATEGY_TYPES to match WSDL and adds goal-id validation for updates.
Comments suppressed due to low confidence (2)

direct_cli/commands/strategies.py:48

  • PayForConversionCrr is included in PAY_FOR_CONVERSION_STRATEGY_TYPES, which makes _build_strategy_fields treat --average-cpa as a valid input and map it to Cpa. The WSDL StrategyPayForConversionCrrAddItem has required fields Crr + GoalId and does not define Cpa, so this type should not be in the “pay-for-conversion uses Cpa” set; it likely belongs with the CRR family so --average-crr maps to Crr.
PAY_FOR_CONVERSION_STRATEGY_TYPES = {
    "PayForConversion",
    "PayForConversionPerFilter",
    "PayForConversionPerCampaign",
    "PayForConversionCrr",
}

direct_cli/commands/strategies.py:358

  • PayForConversionMultipleGoals is added to STRATEGY_TYPES but is not covered by the GOAL_ID_STRATEGY_TYPES validation. In the cached WSDL, StrategyPayForConversionMultipleGoalsAddItem requires GoalId (minOccurs=1), so without including this type in the goal-id family the CLI can’t enforce/persist --goal-id and will build an invalid payload for this subtype.
            # GoalId is minOccurs=1 inside Strategy*Update for the
            # goal-id family (issue #198 H12). Mirror the add command's
            # validation here so updates do not silently drop GoalId.
            if strategy_type in GOAL_ID_STRATEGY_TYPES and goal_id is None:
                raise click.UsageError("Provide --goal-id for this strategy type")

Comment on lines 22 to +24
"AverageCpc",
"AverageCpcPerCampaign",
"AverageCpcPerFilter",
Addresses PR #208 review:

* claude[bot] H12 over-constraint: StrategyAverageCpaBase, StrategyPayForConversionBase,
  StrategyAverageCrrBase, StrategyPayForConversionCrrBase, StrategyAverageCpaPerCampaignBase
  all carry GoalId minOccurs=0, and every Strategy*UpdateItem is an empty extension of its
  *Base. Per strict WSDL parity the update command MUST accept payloads without --goal-id,
  so the issue #198 H12 validation block is removed (with a comment explaining why the
  add-time minOccurs=1 check still stands).
* copilot #1: PayForConversionCrrAddItem schema is Crr + GoalId (no Cpa). Moved
  PayForConversionCrr out of PAY_FOR_CONVERSION_STRATEGY_TYPES into CRR_STRATEGY_TYPES so
  --average-crr now maps to Crr, not the WSDL-unknown Cpa.
* copilot #2: StrategyPayForConversionMultipleGoalsAddItem.GoalId is minOccurs=1, so the
  type is added to GOAL_ID_STRATEGY_TYPES; the add command now rejects payloads without
  --goal-id for that subtype. AverageCpaMultipleGoals stays out — its AddItem has no
  GoalId field at all.

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

axisrow commented May 20, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 3m 33s —— View job


Claude PR Review — Critical-issues only (re-review after 83f3636)

  • Read PR diff and recent commits
  • Inspect strategies.py after the follow-up commit
  • Verify each of the 12 fixes against WSDL cache
  • Cross-check copilot-suppressed findings against current code
  • Post findings

I re-reviewed after 83f3636 "fix(strategies): align with WSDL on update GoalId and Crr-family". That commit cleanly resolves my prior H12 concern and two of copilot's three suppressed findings (PayForConversionCrr moved to CRR_STRATEGY_TYPES, PayForConversionMultipleGoals added to GOAL_ID_STRATEGY_TYPES). 11 of 12 audit items are now correctly fixed.

However one suppressed copilot finding is still a live bug: strategies add --type AverageCpcPerFilter produces a WSDL-invalid payload.

🔴 Critical — strategies add --type AverageCpcPerFilter emits wrong field name

direct_cli/commands/strategies.py:98-99:

if average_cpc is not None:
    fields["AverageCpc"] = average_cpc

This is unconditional regardless of strategy_type. The WSDL however defines two different field names for the two CPC strategy subtypes (tests/wsdl_cache/strategies.xml:294-309):

AddItem Field name minOccurs
StrategyAverageCpcAddItem AverageCpc 1
StrategyAverageCpcPerCampaignAddItem AverageCpc 1
StrategyAverageCpcPerFilterAddItem FilterAverageCpc 0

So a natural invocation like:

direct strategies add --name "Per-filter CPC" --type AverageCpcPerFilter --average-cpc 1000000

produces:

{ "AverageCpcPerFilter": { "AverageCpc": 1000000 } }

…but the WSDL StrategyAverageCpcPerFilterAddItem has no AverageCpc field — only FilterAverageCpc, WeeklySpendLimit, CustomPeriodBudget, BidCeiling. The live API will reject this (or silently ignore the unknown field and create a strategy with no CPC set — either way, broken).

Parallel proof inside the same repo: direct_cli/commands/campaigns.py:324 correctly uses FilterAverageCpc for the SMART_CAMPAIGN → AVERAGE_CPC_PER_FILTER path, and tests/test_dry_run.py:984 asserts that field name explicitly. The strategies path is the inconsistent one.

The PAYLOAD_CASES set for strategies.add (tests/test_low_coverage_payloads.py:209-307) covers only AverageCpa, AverageCrr, PayForConversion — nothing exercises AverageCpcPerFilter (or AverageCpaMultipleGoals/PayForConversionMultipleGoals), which is why the gate doesn't catch this. Copilot's first suppressed comment flagged exactly this; the follow-up commit fixed the other two but missed this one.

Suggested fix — same shape as the existing PAY_FOR_CONVERSION / FILTER_CPA branches:

FILTER_CPC_STRATEGY_TYPES = {"AverageCpcPerFilter"}

if average_cpc is not None:
    if strategy_type in FILTER_CPC_STRATEGY_TYPES:
        fields["FilterAverageCpc"] = average_cpc
    else:
        fields["AverageCpc"] = average_cpc

…and add a payload test analogous to test_strategies_add_pay_for_conversion_payload_uses_cpa_field so the parity gate locks the field name down.

Fix this →

🟢 Other 11 fixes — verified correct

ID Verdict Notes
H1 ads update empty-subtype guard at ads.py:432 fires when len(ad_data) == 1.
H2 Per-type allow-list (ads.py:351-391) rejects incompatible flags before payload build. Uses is not None so explicit empties also reach the empty-payload guard.
H4 Matches WSDL AdGroupAddItem.RegionIds minOccurs=1.
H5 adgroups update empty-payload guard at adgroups.py:228-232.
H6 Matches WSDL SmartCampaignAddItem.CounterId minOccurs=1 (verified at tests/wsdl_cache/campaigns.xml). Validation runs before --filter-average-cpc check.
H7 Matches WSDL WebpageAddItem.Conditions minOccurs=0. Correct removal of over-constraint.
H8 bids set --bid now required.
H9 keywordbids set requires at least one of --search-bid / --network-bid.
H10 keywords update empty-payload guard at keywords.py:215-219.
H11 16 names in STRATEGY_TYPES exactly match <element name="X" type="ns:Strategy*AddItem"/> in the WSDL.
H12 Fixed in 83f3636. Update no longer requires --goal-id (WSDL Strategy*UpdateItem extends Strategy*Base where GoalId is minOccurs=0). Add-side validation retained because *AddItem.GoalId is minOccurs=1.
H13 No ClientId minOccurs=1 in ClientUpdateItem; existing "Provide at least one field" guard already covers the real WSDL contract.

🔵 Lower-severity observations (non-blocking)

  1. AverageCpaMultipleGoals with --average-cpaStrategyAverageCpaMultipleGoalsAddItem has no Cpa/AverageCpa field at all. If a user passes --average-cpa to --type AverageCpaMultipleGoals, _build_strategy_fields falls through to the else branch and emits AverageCpa, producing an invalid payload. Less likely than the H1 bug above (the option name doesn't suggest this type) but the same fix shape applies — either reject the flag for this subtype or omit it.

  2. keywordbids.py:99-101 — implicit string concatenation across two literals. Not a bug; cosmetic.

Summary

One real bug remains: strategies add --type AverageCpcPerFilter produces a WSDL-invalid payload because the field-name dispatch in _build_strategy_fields doesn't account for FilterAverageCpc. Same pattern as the PayForConversion → Cpa and AverageCpaPerFilter → FilterAverageCpa branches that the function already handles correctly. The other 11 audit fixes are verified correct against the cached WSDL. PR is mergeable once that one field-name mapping is added (and ideally a payload test alongside it).
· Branch

…pcPerFilter

Addresses PR #208 re-review:

* claude[bot] critical: StrategyAverageCpcPerFilterAddItem has no AverageCpc
  field — its WSDL schema is FilterAverageCpc + WeeklySpendLimit + ... . The
  CLI used to emit {AverageCpc: N} unconditionally, producing a WSDL-invalid
  payload for `--type AverageCpcPerFilter`. Added a FILTER_CPC_STRATEGY_TYPES
  branch mirroring the existing FILTER_CPA_STRATEGY_TYPES handling so
  `--average-cpc` maps to `FilterAverageCpc` for that subtype only.
* claude[bot] observation: AverageCpaMultipleGoals and PayForConversion-
  MultipleGoals AddItems have no Cpa/AverageCpa field at all. Now
  `_build_strategy_fields` raises `UsageError` if `--average-cpa` is passed
  with a multi-goal subtype, instead of silently emitting an unknown key.

Tests:

* test_strategies_add_average_cpc_per_filter_payload_uses_filter_field locks
  the new dispatch.
* test_strategies_add_pay_for_conversion_crr_payload_uses_crr_field confirms
  the earlier commit's CRR-family classification still routes through `Crr`.
* test_strategies_add_multi_goal_rejects_average_cpa exercises the
  multi-goal guard.
* test_strategies_add_pay_for_conversion_multi_goal_requires_goal_id
  exercises the GOAL_ID_STRATEGY_TYPES extension from the prior commit.

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

axisrow commented May 20, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 6m 4s —— View job


Claude PR Review — Critical-issues only (re-review after 2d890b7)

  • Read PR diff and recent commits (2d890b7, 83f3636, 278ae8e)
  • Inspect strategies.py after the latest commit
  • Verify the AverageCpcPerFilter fix lands on the right code paths
  • Check AverageCpaMultipleGoals / PayForConversionMultipleGoals subtypes
  • Post findings

I re-reviewed after 2d890b7 "route --average-cpc to FilterAverageCpc for AverageCpcPerFilter". That commit closes the field-name bug I flagged previously, and also adds an explicit UsageError for --average-cpa against the MultiGoal subtypes (good — that was the second copilot-suppressed concern). The 12 audit fixes all check out against the cached WSDL.

I found one remaining bug of the same class the PR exists to fix (silent no-op) — not in the audit's original 12, but caught by the same pattern.

🟠 Bug — strategies update still has an empty-subtype no-op

direct_cli/commands/strategies.py:358-380:

strategy_fields = _build_strategy_fields(strategy_type, ...)
if strategy_fields and not strategy_type:
    raise click.UsageError("Provide --type when setting strategy-specific fields")
if strategy_type:
    strategy_data[strategy_type] = strategy_fields

Reproducer:

direct strategies update --id 42 --type AverageCpa --dry-run
  • strategy_fields = {} (no field flags supplied)
  • first guard: if strategy_fields and not strategy_type:False (falsy strategy_fields short-circuits)
  • second branch: strategy_data["AverageCpa"] = {} is set
  • payload sent: {"Strategies": [{"Id": 42, "AverageCpa": {}}]} — exactly the empty subtype no-op that H1 / H5 / H8 / H9 / H10 close in ads.py, adgroups.py, bids.py, keywordbids.py, keywords.py. Yandex accepts this as a successful no-op, so the user sees no error and no change.

This is a real production hazard for the same reason the other five guards exist: a user trying to update e.g. --bid-ceiling and mistyping the flag name gets a 200 OK with no diff applied.

Why the parity gate didn't catch it: EMPTY_PAYLOAD_PROBES in tests/test_wsdl_parity_gate.py:56-82 includes ads.update, adgroups.update, bids.set, keywordbids.set, keywords.updatestrategies.update is missing.

Fix shape (mirrors keywords update):

# Reject empty-subtype no-ops (issue #198 pattern, H5/H10 sibling).
if strategy_type and not strategy_fields:
    raise click.UsageError(
        f"strategies update requires at least one field for "
        f"--type {strategy_type}."
    )
if len(strategy_data) == 1 and not (counter_ids or priority_goals or attribution_model):
    raise click.UsageError(
        "strategies update requires at least one updatable field."
    )

…plus an entry in EMPTY_PAYLOAD_PROBES so the parity gate locks it down.

Fix this →

🟡 Class-of-bug observation (non-blocking, broader than #198 H-list)

_build_strategy_fields still silently emits or drops fields when a strategy-specific flag is paired with a subtype that doesn't define that field. The PR only handled the MultiGoal+--average-cpa case explicitly. The same H2-class issue remains for, e.g.:

Invocation Current behavior WSDL truth
--type AverageCpa --average-cpc N emits AverageCpc field StrategyAverageCpaAddItem has no AverageCpc
--type AverageCpc --average-cpa N emits AverageCpa field StrategyAverageCpcAddItem has no AverageCpa
--type AverageCpa --average-crr N emits AverageCrr field StrategyAverageCpaAddItem has no AverageCrr
--type AverageCpaMultipleGoals --goal-id N silently drops GoalId type has no GoalId — drop is "correct" but should be a hard reject per H2 pattern
--type WbMaximumClicks --average-cpc N emits AverageCpc StrategyMaximumClicksAddItem has no AverageCpc

The live API will either reject these (best case) or accept them and silently ignore the unknown field (worst case — user gets a strategy without the CPC/CPA they intended). The ads.py H2 fix solved the equivalent for ads update via a per-type allow-list table — strategies would benefit from the same pattern. Out of scope for this PR (issue #198 didn't enumerate these), but worth filing as a follow-up.

🟢 The 12 audit fixes — verified correct

ID Verdict Notes
H1 ads update empty-subtype guard at ads.py:432.
H2 Per-type allow-list (ads.py:351-391) rejects incompatible flags before payload build.
H4 Matches WSDL AdGroupAddItem.RegionIds minOccurs=1.
H5 adgroups update empty-payload guard.
H6 Matches WSDL SmartCampaignAddItem.CounterId minOccurs=1; validation runs before --filter-average-cpc check.
H7 Matches WSDL WebpageAddItem.Conditions minOccurs=0.
H8 bids set --bid now required.
H9 keywordbids set requires at least one of --search-bid / --network-bid.
H10 keywords update empty-payload guard.
H11 16 names in STRATEGY_TYPES exactly match <element name="X" type="ns:Strategy*AddItem"/> in the WSDL.
H12 Fixed in 83f3636. Update no longer requires --goal-id (WSDL Strategy*UpdateItem extends Strategy*Base where GoalId is minOccurs=0).
H13 No ClientId minOccurs=1 in ClientUpdateItem.

Also verified the follow-up commits resolved both prior copilot/me findings: PayForConversionCrr is correctly in CRR_STRATEGY_TYPES (Crr+GoalId, no Cpa), PayForConversionMultipleGoals is correctly in GOAL_ID_STRATEGY_TYPES, and AverageCpcPerFilter correctly emits FilterAverageCpc with a payload test at tests/test_low_coverage_payloads.py:346-359.

Summary

The PR is one short guard away from done. Blocking: the strategies update empty-subtype no-op described above — it's exactly the bug class this PR was created to close, just not on the audit's hit list. Once that guard lands (plus a probe entry in the parity gate so it can't regress), this is mergeable. Everything in the listed 12 H-bugs checks out against the cached WSDL.
· Branch

PR #208 re-review found one more silent no-op of the same class this PR
closes for ads/adgroups/bids/keywordbids/keywords:

```
direct strategies update --id 42 --type AverageCpa
```

…built `{Id: 42, AverageCpa: {}}` and the live API accepted it as a 200 OK
no-op. A user mistyping a flag name got a successful response with no diff
applied.

Two guards now reject this:

* if `--type X` is present but `_build_strategy_fields(...)` returned `{}`,
  the command errors with "strategies update requires at least one field
  for --type X.";
* if after all flag merging `strategy_data` still contains only `Id` (no
  --name, no --counter-ids, no --priority-goal, no --attribution-model),
  the command errors with "strategies update requires at least one
  updatable field."

Also locked the regression down in the WSDL parity gate: added a
`strategies.update` probe to `EMPTY_PAYLOAD_PROBES` alongside the existing
five so future refactors cannot silently re-open the no-op.

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

axisrow commented May 20, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 1m 52s —— View job


Claude PR Review — Critical-issues only (re-review after 7945109)

  • Read recent commits and identify deltas since last review
  • Verify strategies update empty-subtype no-op guard
  • Verify parity-gate probe entry locks it down
  • Spot-check other 12 audit fixes
  • Post findings

I re-reviewed after 7945109 "fix(strategies): reject empty-subtype no-op on update". That commit cleanly closes the only blocker I flagged in my prior pass, and the fix matches the shape I'd suggested exactly. No remaining critical issues — PR is mergeable from a correctness standpoint.

🟢 Blocker from prior review — resolved

direct_cli/commands/strategies.py:372-380 adds the missing guard:

if strategy_type and not strategy_fields:
    raise click.UsageError(
        f"strategies update requires at least one field for "
        f"--type {strategy_type}."
    )

…and strategies.py:401-406 adds the broader "Id-only" guard:

if len(strategy_data) == 1:
    raise click.UsageError(
        "strategies update requires at least one updatable field."
    )

Both fire before payload construction. I traced the relevant cases:

Input Outcome
--id 42 only len(strategy_data)==1 → UsageError ✓
--id 42 --type AverageCpa strategy_type and not strategy_fields → UsageError ✓
--id 42 --type AverageCpa --average-cpa 1000 Passes, strategy_fields={"AverageCpa":1000}
--id 42 --name x Passes, strategy_data has 2 keys ✓
--id 42 --counter-ids 1,2 Passes, strategy_data has 2 keys ✓

Parity-gate lock-down is also in place — tests/test_wsdl_parity_gate.py:82-86 adds:

(
    "strategies.update",
    ["strategies", "update", "--id", "1", "--type", "AverageCpa"],
    "at least one",
),

So the regression cannot silently re-open in a future refactor.

🟢 All 12 audit fixes — verified correct

ID Verdict Notes
H1 ads update empty-subtype guard.
H2 Per-type allow-list rejects incompatible flags.
H4 Matches WSDL AdGroupAddItem.RegionIds minOccurs=1.
H5 adgroups update empty-payload guard.
H6 Matches WSDL SmartCampaignAddItem.CounterId minOccurs=1.
H7 Matches WSDL WebpageAddItem.Conditions minOccurs=0.
H8 bids set --bid now required.
H9 keywordbids set requires at least one of --search-bid/--network-bid.
H10 keywords update empty-payload guard.
H11 STRATEGY_TYPES (16 names) matches WSDL exactly.
H12 Fixed in 83f3636 — update no longer over-constrains --goal-id.
H13 No ClientId minOccurs=1; existing guard already correct.

🟡 Class-of-bug observation (non-blocking, broader than #198 H-list)

_build_strategy_fields (strategies.py:88-133) still silently emits a few flags against subtypes that don't define them. The 2d890b7/7945109 commits fixed two cases (AverageCpcPerFilter → FilterAverageCpc, MULTI_GOAL+--average-cpa → hard reject), but these remain:

Invocation Current behavior WSDL truth
--type AverageCpa --average-cpc N emits AverageCpc StrategyAverageCpaAddItem has no AverageCpc
--type WbMaximumClicks --average-cpa N emits AverageCpa StrategyWbMaximumClicksAddItem has no AverageCpa
--type MaxProfit --average-cpc N emits AverageCpc StrategyMaxProfitAddItem has no AverageCpc
--type AverageCpa --goal-id N (with --average-cpa) OK — emits GoalId Correct
--type WbMaximumClicks --goal-id N silently drops GoalId Drop is "correct" per WSDL but should reject per H2 pattern

The live API will either reject these (best case) or silently ignore the unknown key (worst case — user gets a strategy without the CPC/CPA they intended). The ads.py H2 fix solved the equivalent via a per-type allow-list; strategies would benefit from the same pattern. Out of scope for this PR (the audit didn't enumerate these), but worth filing as a follow-up — particularly because the new STRATEGY_TYPES list is larger now, so the cross-product of "wrong flag + wrong subtype" silent emits has grown too.

Summary

No blockers. The empty-subtype no-op I flagged last round is fixed and lock-down is in place. All 12 audit fixes verified against cached WSDL. The remaining _build_strategy_fields silent-emit class is out-of-scope for #198 but worth tracking as a follow-up issue with the same shape as ads.py H2.
· Branch

@axisrow
axisrow merged commit 87b7715 into main May 20, 2026
6 checks passed
@axisrow
axisrow deleted the worktree-milestone-0.3.9-pr2-fix-12-bugs branch May 20, 2026 15:09
axisrow added a commit that referenced this pull request May 21, 2026
Reword 0.3.10 entry from "fix per-type rejection" to "cover per-type
rejection" to avoid implying a behavioral fix shipped in this release —
the corrected rejection behavior was already shipped in 0.3.9 via the
#198 audit follow-up PRs (#205/#208/#214/#202/#204). This PR only
adds regression coverage.

Addresses Copilot review comment on PR #226.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
axisrow added a commit that referenced this pull request May 21, 2026
…226)

* test: lock down subtype validation invariants from #210 repro matrix

Add 9 new SILENT_LOSS_PROBES in tests/test_wsdl_parity_gate.py covering
campaigns add, adgroups add, ads add, bidmodifiers add and strategies add
incompatible flag/--type combinations from the #210 umbrella repro matrix.
Add 3 non-regression tests in tests/test_dry_run.py for strategies update
field aliases (AverageCpcPerFilter→FilterAverageCpc, PayForConversion→Cpa)
and confirm AverageCpa update without --goal-id stays WSDL-valid.

Refs: Closes #210.

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

* docs(changelog): clarify PR is test-only, not behavioral fix

Reword 0.3.10 entry from "fix per-type rejection" to "cover per-type
rejection" to avoid implying a behavioral fix shipped in this release —
the corrected rejection behavior was already shipped in 0.3.9 via the
#198 audit follow-up PRs (#205/#208/#214/#202/#204). This PR only
adds regression coverage.

Addresses Copilot review comment on PR #226.

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

---------

Co-authored-by: axisrow <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[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