diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d1d8a..9c0e2a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to cueapi-sdk will be documented here. +## [Unreleased] + +### Added + +- `client.cues.bulk_delete(ids)` — delete up to 100 cues in a single call. Returns `{"deleted": [...], "skipped": [...]}`. Per-ID atomic, not batch atomic. Sends `X-Confirm-Destructive: true` header automatically. Wraps `POST /v1/cues/bulk-delete` (cueapi #650). Parity port of cueapi-cli #46. Raises `ValueError` client-side on empty list or > 100 IDs. + ## [0.2.0] - 2026-05-01 ### Added diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index 5ad2c00..8902c37 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -197,6 +197,51 @@ def delete(self, cue_id: str) -> None: """ self._client._delete(f"/v1/cues/{cue_id}") + def bulk_delete(self, ids: List[str]) -> Dict[str, List[str]]: + """Delete multiple cues in a single call (max 100 per call). + + Per-ID atomic, NOT batch atomic — each ID is independently checked + for caller ownership. IDs that don't exist OR aren't owned by the + caller land in the ``skipped`` array (silent skip on miss; no + info leak about other tenants' cues). Cascade FK handles + executions + dispatch_outbox cleanup. + + Sends the ``X-Confirm-Destructive: true`` header automatically; + the substrate requires it for any bulk-destructive endpoint. + + Args: + ids: Cue IDs to delete. Length 1-100. Server enforces the cap; + this method also validates client-side to fail fast. + + Returns: + A dict shaped:: + + { + "deleted": ["cue_abc", "cue_def"], # IDs whose rows are gone + "skipped": ["cue_xyz"] # IDs that didn't exist or weren't owned + } + + Order within each group preserves the request's ``ids`` array. + + Raises: + ValueError: If ``ids`` is empty or has more than 100 entries. + + Example: + >>> client.cues.bulk_delete(["cue_abc", "cue_def", "cue_xyz"]) + {"deleted": ["cue_abc", "cue_def"], "skipped": ["cue_xyz"]} + """ + if not ids: + raise ValueError("ids must contain at least one cue ID.") + if len(ids) > 100: + raise ValueError( + f"Max 100 IDs per call; got {len(ids)}. Split into batches." + ) + return self._client._post( + "/v1/cues/bulk-delete", + json={"ids": list(ids)}, + headers={"X-Confirm-Destructive": "true"}, + ) + def pause(self, cue_id: str) -> Cue: """Pause a cue. Equivalent to update(cue_id, status="paused"). diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py index fa7f6ec..b5d40eb 100644 --- a/tests/test_cues_resource.py +++ b/tests/test_cues_resource.py @@ -55,3 +55,100 @@ def test_fire_omits_merge_strategy_when_not_passed(self): sent_body = mock_client._post.call_args.kwargs["json"] assert "merge_strategy" not in sent_body + + +class TestBulkDelete: + def test_bulk_delete_happy_path(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "deleted": ["cue_abc", "cue_def"], + "skipped": [], + } + resource = CuesResource(mock_client) + + result = resource.bulk_delete(["cue_abc", "cue_def"]) + + mock_client._post.assert_called_once_with( + "/v1/cues/bulk-delete", + json={"ids": ["cue_abc", "cue_def"]}, + headers={"X-Confirm-Destructive": "true"}, + ) + assert result == {"deleted": ["cue_abc", "cue_def"], "skipped": []} + + def test_bulk_delete_with_skipped(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "deleted": ["cue_abc"], + "skipped": ["cue_xyz"], + } + resource = CuesResource(mock_client) + + result = resource.bulk_delete(["cue_abc", "cue_xyz"]) + + assert result["deleted"] == ["cue_abc"] + assert result["skipped"] == ["cue_xyz"] + + def test_bulk_delete_sends_confirm_destructive_header(self): + # Pin the X-Confirm-Destructive header — server requires it for + # any bulk-destructive endpoint. If a future refactor drops this + # header, the server returns 400 confirmation_required and the + # SDK call silently fails. + mock_client = MagicMock() + mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []} + resource = CuesResource(mock_client) + + resource.bulk_delete(["cue_a"]) + + kwargs = mock_client._post.call_args.kwargs + assert kwargs["headers"]["X-Confirm-Destructive"] == "true" + + def test_bulk_delete_empty_ids_raises(self): + mock_client = MagicMock() + resource = CuesResource(mock_client) + + import pytest + + with pytest.raises(ValueError, match="at least one cue ID"): + resource.bulk_delete([]) + + # Server NOT called — fail-fast at SDK layer. + mock_client._post.assert_not_called() + + def test_bulk_delete_over_100_ids_raises(self): + mock_client = MagicMock() + resource = CuesResource(mock_client) + + import pytest + + ids = [f"cue_{i}" for i in range(101)] + with pytest.raises(ValueError, match="Max 100"): + resource.bulk_delete(ids) + + mock_client._post.assert_not_called() + + def test_bulk_delete_exactly_100_ids_ok(self): + # Boundary — 100 IDs is allowed (server cap is inclusive). + mock_client = MagicMock() + mock_client._post.return_value = { + "deleted": [f"cue_{i}" for i in range(100)], + "skipped": [], + } + resource = CuesResource(mock_client) + + ids = [f"cue_{i}" for i in range(100)] + result = resource.bulk_delete(ids) + + assert len(result["deleted"]) == 100 + + def test_bulk_delete_accepts_iterable_not_just_list(self): + # The method coerces the input to a list before sending. Verifies + # tuple / generator inputs work without explicit conversion at + # the call site. + mock_client = MagicMock() + mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []} + resource = CuesResource(mock_client) + + resource.bulk_delete(("cue_a",)) + + sent_body = mock_client._post.call_args.kwargs["json"] + assert sent_body == {"ids": ["cue_a"]}