lvi-api-client 0.2.4__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lvi-api-client
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: LogicVein API Client Library
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ 0.2.7
@@ -1840,7 +1840,8 @@ class LogicVeinAPIClient:
1840
1840
  "summary": str - Incident summary,
1841
1841
  "severity": str - "INFO", "WARNING", "ERROR", or "CRITICAL",
1842
1842
  "priority": str - "LOW", "MEDIUM", "HIGH", or "CRITICAL",
1843
- "status": str - "OPEN", "WORKING", or "CLOSED",
1843
+ "status": str - "OPEN", "RESOLVED" or "DEFERRED" seen on a server; whether
1844
+ "WORKING" or "CLOSED" exist has not been measured,
1844
1845
  "resolution": str - "PENDING" or "RESOLVED",
1845
1846
  "assignee": str - Assigned username (empty string if unassigned),
1846
1847
  "labels": list[str] - Label tags,
@@ -1875,14 +1876,15 @@ class LogicVeinAPIClient:
1875
1876
  # Incidents
1876
1877
  # =====================================================
1877
1878
 
1878
- def get_incident_by_id(self, incident_id: int) -> Dict[str, Any]:
1879
+ def get_incident_by_id(self, incident_id: int) -> Optional[Dict[str, Any]]:
1879
1880
  """Get an incident by its ID from the LogicVein server.
1880
1881
 
1881
1882
  Args:
1882
1883
  incident_id (int): The ID of the incident.
1883
1884
 
1884
1885
  Returns:
1885
- Dict[str, Any]: The incident if it exists, otherwise an empty dictionary.
1886
+ Dict[str, Any]: The incident, or None for an unknown ID, which the server answers with
1887
+ a null result. An empty dictionary only when the server gives no result at all.
1886
1888
  """
1887
1889
  response = self.call("Incidents.getIncidentById", incidentId=incident_id)
1888
1890
 
@@ -2549,6 +2551,8 @@ class LogicVeinAPIClient:
2549
2551
  def delete_managed_network(self, network_name: str) -> bool:
2550
2552
  """Delete a managed network from the LogicVein server.
2551
2553
 
2554
+ An alert policy whose only network this is goes with it.
2555
+
2552
2556
  Args:
2553
2557
  network_name (str): The name of the managed network to delete.
2554
2558
 
@@ -3003,6 +3007,7 @@ class LogicVeinAPIClient:
3003
3007
  commands (str): CLI commands to run, one per line.
3004
3008
  job_name (str): Display name for the job.
3005
3009
  backup_on_completion (bool): If True, LVI backs up the device after the job runs.
3010
+ description (str): The job's description. Empty uses ``job_name``.
3006
3011
 
3007
3012
  Returns:
3008
3013
  Dict[str, Any]: The saved job record (contains ``jobId``), or None on failure.
@@ -3011,7 +3016,7 @@ class LogicVeinAPIClient:
3011
3016
  {
3012
3017
  "jobId": 0,
3013
3018
  "jobName": job_name,
3014
- "description": description or "Job scheduled by ServiceNow",
3019
+ "description": description or job_name,
3015
3020
  "managedNetworks": [network],
3016
3021
  "jobType": "Script Tool Job",
3017
3022
  "subType": "org.ziptie.tools.scripts.commandRunner",
@@ -3052,6 +3057,8 @@ class LogicVeinAPIClient:
3052
3057
  network (str): LVI managed network the device belongs to.
3053
3058
  commands (str): CLI commands to run, one per line.
3054
3059
  job_name (str): Display name for the job in the LVI scheduler.
3060
+ backup_on_completion (bool): If True, LVI backs up the device after the job runs.
3061
+ description (str): The job's description. Empty uses ``job_name``.
3055
3062
 
3056
3063
  Returns:
3057
3064
  Dict[str, Any]: The job execution result dict from LVI (contains ``id``
@@ -3071,7 +3078,7 @@ class LogicVeinAPIClient:
3071
3078
  "tool": "org.ziptie.tools.scripts.commandRunner",
3072
3079
  "backupOnCompletion": "true" if backup_on_completion else "false",
3073
3080
  },
3074
- description=description or "Job scheduled by ServiceNow",
3081
+ description=description or job_name,
3075
3082
  )
3076
3083
 
3077
3084
  # =====================================================
@@ -3645,6 +3652,8 @@ class LogicVeinAPIClient:
3645
3652
  """Get actions configured for an alert policy.
3646
3653
 
3647
3654
  Calls Collection.getActionsForPolicy. Returns the result list directly (not paginated).
3655
+ Settings come back as stored, secrets included: an exec action's ``password`` and a
3656
+ trap action's ``community`` are not masked, and the web UI sends them back unchanged.
3648
3657
  Each action has:
3649
3658
  {
3650
3659
  "actionId": int - Action ID,
@@ -3674,6 +3683,10 @@ class LogicVeinAPIClient:
3674
3683
  Returns the saved actions with assigned actionIds (new actions use -1 as input id,
3675
3684
  which is replaced with the assigned ID in the response).
3676
3685
 
3686
+ The saved actions **replace** all of the policy's actions; they are not added to them.
3687
+ An empty list does nothing, so it cannot clear a policy's actions. ``username`` is
3688
+ filled in with the caller's when left out.
3689
+
3677
3690
  Each action object should have:
3678
3691
  {
3679
3692
  "actionId": int - Use -1 for new actions,
@@ -4285,6 +4298,33 @@ class LogicVeinAPIClient:
4285
4298
  Returns:
4286
4299
  List[Dict[str, Any]]: All matching alert policies.
4287
4300
  """
4301
+ return self._search_alert_policies(managed_networks, queries, sort_column, descending, page_size, strict=False)
4302
+
4303
+ def _search_alert_policies(
4304
+ self,
4305
+ managed_networks: List[str],
4306
+ queries: List[str],
4307
+ sort_column: str,
4308
+ descending: bool,
4309
+ page_size: int,
4310
+ strict: bool,
4311
+ ) -> List[Dict[str, Any]]:
4312
+ """Read every page of alert policies.
4313
+
4314
+ Args:
4315
+ managed_networks (List[str]): Networks to search. Empty for all.
4316
+ queries (List[str]): Query filters.
4317
+ sort_column (str): Column to sort by.
4318
+ descending (bool): Sort descending if True.
4319
+ page_size (int): Records per API call.
4320
+ strict (bool): Raise on an answer with no policies list, rather than stopping there.
4321
+
4322
+ Returns:
4323
+ List[Dict[str, Any]]: All matching alert policies.
4324
+
4325
+ Raises:
4326
+ ValueError: If ``strict`` and an answer has no policies list.
4327
+ """
4288
4328
  all_policies: List[Dict[str, Any]] = []
4289
4329
  offset = 0
4290
4330
 
@@ -4303,8 +4343,11 @@ class LogicVeinAPIClient:
4303
4343
  }
4304
4344
  response = self.call("Collection.searchAlertPolicies", **params)
4305
4345
 
4306
- if response and isinstance(response, dict) and "result" in response and "policies" in response["result"]:
4307
- batch = response["result"]["policies"]
4346
+ result = response.get("result") if isinstance(response, dict) else None
4347
+ if isinstance(result, dict) and isinstance(result.get("policies"), list):
4348
+ batch = result["policies"]
4349
+ elif strict:
4350
+ raise ValueError("Collection.searchAlertPolicies did not answer with a list of policies")
4308
4351
  else:
4309
4352
  break
4310
4353
 
@@ -4322,6 +4365,12 @@ class LogicVeinAPIClient:
4322
4365
 
4323
4366
  Calls Collection.saveAlertPolicy. The response mirrors the input policy object.
4324
4367
  Use policyId=-1 (or omit) to create a new policy; provide an existing policyId to update.
4368
+ The result carries the new policy's ID.
4369
+
4370
+ Saving an existing policy **removes all its actions**, although ``actionTypes`` still
4371
+ lists them: save them again with :meth:`save_alert_actions`, as the web UI does. Saving
4372
+ a name that is already in use makes a second policy of that name; check with
4373
+ :meth:`is_alert_policy_name_unique` first.
4325
4374
 
4326
4375
  The policy object has:
4327
4376
  {
@@ -4344,6 +4393,89 @@ class LogicVeinAPIClient:
4344
4393
  return response["result"]
4345
4394
  return None
4346
4395
 
4396
+ def delete_alert_policy(self, policy_id: int, reassign_policy_id: int = -1) -> bool:
4397
+ """Delete an alert policy, with its actions.
4398
+
4399
+ Calls Collection.deleteAlertPolicy, with the parameters the web UI sends. The server
4400
+ refuses ``policyId`` alone. The UI asks first whether the policy is referenced
4401
+ (:meth:`is_alert_policy_referenced`); what the server does with the references of a
4402
+ referenced policy deleted with ``reassign_policy_id`` -1 has not been measured.
4403
+
4404
+ The server's answer is not relied on: the policies are read back afterwards. An answer
4405
+ that cannot show whether the policy is gone raises rather than reading as deleted, and
4406
+ errors from either call (``LogicVeinAPIError``, ``requests`` errors) propagate.
4407
+
4408
+ Args:
4409
+ policy_id (int): The ID of the policy to delete.
4410
+ reassign_policy_id (int): The policy to move its references to, or -1 for none.
4411
+
4412
+ Returns:
4413
+ bool: True if the policy is gone afterwards.
4414
+
4415
+ Raises:
4416
+ ValueError: If the read-back answer has no list of policies, or a policy without an ID.
4417
+ """
4418
+ self.call("Collection.deleteAlertPolicy", policyId=policy_id, reassignPolicyId=reassign_policy_id)
4419
+ remaining = self._search_alert_policies([], [], "policyName", False, 500, strict=True)
4420
+ ids = [policy.get("policyId") if isinstance(policy, dict) else None for policy in remaining]
4421
+ if not all(isinstance(found, int) for found in ids):
4422
+ raise ValueError("Collection.searchAlertPolicies answered with a policy that has no ID")
4423
+ return policy_id not in ids
4424
+
4425
+ def is_alert_policy_referenced(self, policy_id: int) -> bool:
4426
+ """Check whether anything refers to an alert policy.
4427
+
4428
+ Calls Collection.isAlertPolicyReferenced, which the web UI calls before deleting one.
4429
+ Monitor triggers name the policy they alert through, but they are not the only
4430
+ references counted: on the lab, a policy no trigger names also answered true. An
4431
+ unknown ID answers false.
4432
+
4433
+ Args:
4434
+ policy_id (int): The ID of the policy.
4435
+
4436
+ Returns:
4437
+ bool: True if the policy is referenced.
4438
+
4439
+ Raises:
4440
+ ValueError: If the server's answer is not true or false.
4441
+ """
4442
+ response = self.call("Collection.isAlertPolicyReferenced", policyId=policy_id)
4443
+ referenced = response.get("result") if isinstance(response, dict) else None
4444
+ if not isinstance(referenced, bool):
4445
+ raise ValueError(f"Collection.isAlertPolicyReferenced did not answer true or false for {policy_id}")
4446
+ return referenced
4447
+
4448
+ def is_alert_policy_name_unique(
4449
+ self, policy_name: str, managed_networks: List[str], ignore_policy_id: int = -1
4450
+ ) -> bool:
4451
+ """Check whether an alert policy name is free in some networks.
4452
+
4453
+ Calls Collection.isAlertPolicyNameUnique, with the parameters the web UI sends as a
4454
+ name is typed. :meth:`save_alert_policy` does not check: it saves a second policy of
4455
+ a name in use.
4456
+
4457
+ Args:
4458
+ policy_name (str): The name to check.
4459
+ managed_networks (List[str]): The networks to check it in.
4460
+ ignore_policy_id (int): A policy to leave out, such as the one being renamed. -1 for none.
4461
+
4462
+ Returns:
4463
+ bool: True if the name is free.
4464
+
4465
+ Raises:
4466
+ ValueError: If the server's answer is not true or false.
4467
+ """
4468
+ response = self.call(
4469
+ "Collection.isAlertPolicyNameUnique",
4470
+ policyName=policy_name,
4471
+ managedNetworks=managed_networks,
4472
+ ignorePolicyId=ignore_policy_id,
4473
+ )
4474
+ unique = response.get("result") if isinstance(response, dict) else None
4475
+ if not isinstance(unique, bool):
4476
+ raise ValueError(f"Collection.isAlertPolicyNameUnique did not answer true or false for {policy_name}")
4477
+ return unique
4478
+
4347
4479
  # =====================================================
4348
4480
  # Maps / Topology
4349
4481
  # =====================================================
@@ -4620,27 +4752,32 @@ class LogicVeinAPIClient:
4620
4752
  where ``recordId`` is the ``id`` field from a ``getExecutionDetails``
4621
4753
  record.
4622
4754
 
4755
+ The server finds the output by ``recordId`` alone: a record of another
4756
+ execution comes back as long as the execution ID exists.
4757
+
4623
4758
  Args:
4624
4759
  execution_id: The execution ID (``executionId`` from the job result).
4625
4760
  record_id: The per-device record ID (``id`` from ``getExecutionDetails``).
4626
4761
 
4627
4762
  Returns:
4628
- The raw output text, or an empty string if the request fails.
4763
+ The raw output text.
4764
+
4765
+ Raises:
4766
+ requests.HTTPError: If the server refuses the request, for example
4767
+ 401 for a bad token, 404 for an unknown execution or 500 for a
4768
+ malformed ID.
4769
+ ValueError: If the server has no record with that ID. It answers
4770
+ 200 with an empty body and no content type then, which is not
4771
+ an empty output: a Command Runner output holds at least the
4772
+ command and the prompt.
4629
4773
  """
4630
4774
  url = f"https://{self.host}/servlet/pluginDetail"
4631
4775
  params = {"executionId": execution_id, "recordId": record_id}
4632
- try:
4633
- response = self.lvi_client.get(url, params=params, timeout=self.timeout)
4634
- response.raise_for_status()
4635
- return response.text
4636
- except Exception as exc:
4637
- logger.warning(
4638
- "get_execution_output failed for executionId=%s recordId=%s: %s",
4639
- execution_id,
4640
- record_id,
4641
- exc,
4642
- )
4643
- return ""
4776
+ response = self.lvi_client.get(url, params=params, timeout=self.timeout)
4777
+ response.raise_for_status()
4778
+ if not response.content and not response.headers.get("Content-Type"):
4779
+ raise ValueError(f"No output record {record_id} in execution {execution_id}")
4780
+ return response.text
4644
4781
 
4645
4782
  # =====================================================
4646
4783
  # ZeroTouch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lvi-api-client
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: LogicVein API Client Library
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -9,5 +9,6 @@ src/lvi_api_client.egg-info/SOURCES.txt
9
9
  src/lvi_api_client.egg-info/dependency_links.txt
10
10
  src/lvi_api_client.egg-info/requires.txt
11
11
  src/lvi_api_client.egg-info/top_level.txt
12
+ tests/test_check_version_bump.py
12
13
  tests/test_lvi_api_client.py
13
14
  tests/test_server_request_shapes.py
@@ -0,0 +1,61 @@
1
+ """Tests for scripts/ci/check_version_bump.py."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _PATH = Path(__file__).resolve().parent.parent / "scripts" / "ci" / "check_version_bump.py"
9
+ _SPEC = importlib.util.spec_from_file_location("check_version_bump", _PATH)
10
+ check = importlib.util.module_from_spec(_SPEC)
11
+ _SPEC.loader.exec_module(check)
12
+
13
+ LOG = "# Changelog\n\n## [0.2.7] - 2026-11-08\n\n### Fixed\n- Something.\n\n## [0.2.6] - 2026-11-07\n"
14
+
15
+
16
+ def test_a_heading_for_the_version_passes():
17
+ assert check.changelog_problem(LOG, "0.2.7") == ""
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "changelog",
22
+ [
23
+ "## [Unreleased]\n\n### Fixed\n- Something.\n\n## [0.2.6] - 2026-11-07\n",
24
+ "Fixed in [0.2.7], see below.\n## [0.2.6]\n",
25
+ "## [0.2.70] - 2026-11-08\n",
26
+ ],
27
+ ids=["only unreleased", "not a heading", "another version"],
28
+ )
29
+ def test_no_heading_for_the_version_fails(changelog):
30
+ assert "no '## [0.2.7]' heading" in check.changelog_problem(changelog, "0.2.7")
31
+
32
+
33
+ def _run(monkeypatch, tmp_path, changed, version, changelog, base_version="0.2.6"):
34
+ (tmp_path / "VERSION").write_text(version + "\n")
35
+ (tmp_path / "CHANGELOG.md").write_text(changelog)
36
+ monkeypatch.setattr(check, "REPO_ROOT", tmp_path)
37
+
38
+ def git(*args):
39
+ return "\n".join(changed) if args[0] == "diff" else base_version + "\n"
40
+
41
+ monkeypatch.setattr(check, "_git", git)
42
+ monkeypatch.setattr("sys.argv", ["check_version_bump.py", "--base", "abc123"])
43
+ return check.main()
44
+
45
+
46
+ def test_a_bump_with_entries_left_under_unreleased_fails(monkeypatch, tmp_path, capsys):
47
+ changelog = "## [Unreleased]\n\n### Fixed\n- Something.\n"
48
+ assert _run(monkeypatch, tmp_path, ["src/x.py", "VERSION", "CHANGELOG.md"], "0.2.7", changelog) == 1
49
+ assert "no '## [0.2.7]' heading" in capsys.readouterr().out
50
+
51
+
52
+ def test_a_bump_without_a_src_change_still_needs_the_heading(monkeypatch, tmp_path):
53
+ assert _run(monkeypatch, tmp_path, ["VERSION"], "0.2.7", "## [0.2.6]\n") == 1
54
+
55
+
56
+ def test_a_bump_with_its_heading_passes(monkeypatch, tmp_path):
57
+ assert _run(monkeypatch, tmp_path, ["src/x.py", "VERSION", "CHANGELOG.md"], "0.2.7", LOG) == 0
58
+
59
+
60
+ def test_a_docs_change_needs_nothing(monkeypatch, tmp_path):
61
+ assert _run(monkeypatch, tmp_path, ["CHANGELOG.md", "README.md"], "0.2.6", "## [Unreleased]\n") == 0
@@ -5048,7 +5048,7 @@ class TestCreateCommandRunnerJob:
5048
5048
 
5049
5049
 
5050
5050
  class TestGetExecutionOutput:
5051
- """Tests for get_execution_output (lines 3641-3674): success and exception paths."""
5051
+ """Tests for get_execution_output: the text on success, and an error for every failure."""
5052
5052
 
5053
5053
  @pytest.fixture
5054
5054
  def client(self, monkeypatch):
@@ -5057,39 +5057,50 @@ class TestGetExecutionOutput:
5057
5057
  with patch("src.logicvein.lvi_api_client.requests.Session"):
5058
5058
  return LogicVeinAPIClient()
5059
5059
 
5060
+ @staticmethod
5061
+ def answer(status, body=b"", content_type=None):
5062
+ """A real requests.Response, shaped like the lab server's answers (issue #6)."""
5063
+ response = requests.Response()
5064
+ response.status_code = status
5065
+ response._content = body
5066
+ response.encoding = "utf-8"
5067
+ if content_type:
5068
+ response.headers["Content-Type"] = content_type
5069
+ response.url = "https://test.example.com/servlet/pluginDetail"
5070
+ return response
5071
+
5060
5072
  def test_get_execution_output_success(self, client):
5061
- """get_execution_output returns response text on success."""
5062
- mock_get_response = Mock()
5063
- mock_get_response.raise_for_status = Mock()
5064
- mock_get_response.text = "show version output\nCisco IOS Version 15.1"
5065
- client.lvi_client.get = Mock(return_value=mock_get_response)
5073
+ """get_execution_output returns the text, and asks for the execution and record."""
5074
+ output = b"show clock\r\n16:57:34.919 UTC Sat Nov 7 2026\r\nc1800#"
5075
+ client.lvi_client.get = Mock(return_value=self.answer(200, output, "text/plain;charset=utf-8"))
5066
5076
 
5067
5077
  result = client.get_execution_output(execution_id=42, record_id=7)
5068
5078
 
5069
- assert result == "show version output\nCisco IOS Version 15.1"
5070
- client.lvi_client.get.assert_called_once()
5071
- call_args = client.lvi_client.get.call_args
5072
- assert "executionId" in call_args.kwargs["params"]
5073
- assert call_args.kwargs["params"]["executionId"] == 42
5074
- assert call_args.kwargs["params"]["recordId"] == 7
5079
+ assert result == output.decode()
5080
+ assert client.lvi_client.get.call_args.kwargs["params"] == {"executionId": 42, "recordId": 7}
5075
5081
 
5076
- def test_get_execution_output_http_error_returns_empty_string(self, client):
5077
- """get_execution_output returns '' when raise_for_status raises."""
5078
- mock_get_response = Mock()
5079
- mock_get_response.raise_for_status.side_effect = requests.HTTPError("403 Forbidden")
5080
- client.lvi_client.get = Mock(return_value=mock_get_response)
5082
+ @pytest.mark.parametrize("status", [401, 404, 500])
5083
+ def test_get_execution_output_http_error_raises(self, client, status):
5084
+ """A refused read raises instead of reading as an empty output (issue #6)."""
5085
+ page = b"<!doctype html><html></html>"
5086
+ client.lvi_client.get = Mock(return_value=self.answer(status, page, "text/html;charset=utf-8"))
5081
5087
 
5082
- result = client.get_execution_output(execution_id=42, record_id=7)
5088
+ with pytest.raises(requests.HTTPError):
5089
+ client.get_execution_output(execution_id=42, record_id=7)
5083
5090
 
5084
- assert result == ""
5091
+ def test_get_execution_output_unknown_record_raises(self, client):
5092
+ """The server answers an unknown record with 200, no body and no content type (issue #6)."""
5093
+ client.lvi_client.get = Mock(return_value=self.answer(200))
5085
5094
 
5086
- def test_get_execution_output_connection_error_returns_empty_string(self, client):
5087
- """get_execution_output returns '' when a connection exception is raised."""
5088
- client.lvi_client.get = Mock(side_effect=requests.exceptions.ConnectionError("refused"))
5095
+ with pytest.raises(ValueError, match="No output record 7 in execution 42"):
5096
+ client.get_execution_output(execution_id=42, record_id=7)
5089
5097
 
5090
- result = client.get_execution_output(execution_id=99, record_id=1)
5098
+ def test_get_execution_output_connection_error_raises(self, client):
5099
+ """A transport failure propagates instead of reading as an empty output (issue #6)."""
5100
+ client.lvi_client.get = Mock(side_effect=requests.exceptions.ConnectionError("refused"))
5091
5101
 
5092
- assert result == ""
5102
+ with pytest.raises(requests.exceptions.ConnectionError):
5103
+ client.get_execution_output(execution_id=99, record_id=1)
5093
5104
 
5094
5105
 
5095
5106
  class TestRunCommandRunnerJob:
@@ -5121,6 +5132,27 @@ class TestRunCommandRunnerJob:
5121
5132
  assert result["executionId"] == "exec-77"
5122
5133
  client.lvi_client.post.assert_called_once()
5123
5134
 
5135
+ def test_run_command_runner_job_describes_the_job_by_its_name(self, client, mock_response):
5136
+ """An empty description uses the job name, not "Job scheduled by ServiceNow" (issue #5)."""
5137
+ mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"executionId": 1}}
5138
+ client.lvi_client.post = Mock(return_value=mock_response)
5139
+
5140
+ client.run_command_runner_job("10.1.1.1", "Default", "show version", job_name="NTP check")
5141
+
5142
+ body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
5143
+ assert body["method"] == "Scheduler.runNow"
5144
+ assert body["params"]["jobData"]["description"] == "NTP check"
5145
+
5146
+ def test_run_command_runner_job_keeps_a_given_description(self, client, mock_response):
5147
+ """A description the caller gives is sent as it is."""
5148
+ mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"executionId": 1}}
5149
+ client.lvi_client.post = Mock(return_value=mock_response)
5150
+
5151
+ client.run_command_runner_job("10.1.1.1", "Default", "show version", description="Change 42")
5152
+
5153
+ body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
5154
+ assert body["params"]["jobData"]["description"] == "Change 42"
5155
+
5124
5156
  def test_run_command_runner_job_with_backup(self, client, mock_response):
5125
5157
  """run_command_runner_job passes backup_on_completion=True in job parameters."""
5126
5158
  mock_response.json.return_value = {
@@ -5201,15 +5233,15 @@ class TestCreateCommandRunnerJobBackupAndDescription:
5201
5233
  body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
5202
5234
  assert body["params"]["jobData"]["description"] == "My custom description"
5203
5235
 
5204
- def test_empty_description_defaults_to_service_now_string(self, client, mock_response):
5205
- """create_command_runner_job defaults description when empty string passed."""
5236
+ def test_empty_description_defaults_to_the_job_name(self, client, mock_response):
5237
+ """create_command_runner_job describes the job by its name, not by any one caller (issue #5)."""
5206
5238
  mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"jobId": 12}}
5207
5239
  client.lvi_client.post = Mock(return_value=mock_response)
5208
5240
 
5209
- client.create_command_runner_job("10.1.1.1", "Default", "show version", description="")
5241
+ client.create_command_runner_job("10.1.1.1", "Default", "show version", job_name="NTP check", description="")
5210
5242
 
5211
5243
  body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
5212
- assert body["params"]["jobData"]["description"] == "Job scheduled by ServiceNow"
5244
+ assert body["params"]["jobData"]["description"] == "NTP check"
5213
5245
 
5214
5246
 
5215
5247
  class TestRunJobNowSubTypeBranch:
@@ -620,3 +620,70 @@ class TestDeployingASet:
620
620
  with pytest.raises(ValueError):
621
621
  client.associate_monitor_set(2, queries)
622
622
  assert server.requests == []
623
+
624
+
625
+ class TestAlertPolicies:
626
+ """Deleting alert policies and the checks the web UI makes around saving and deleting them."""
627
+
628
+ def test_a_delete_sends_what_the_ui_sends_and_reads_the_policies_back(self, connect):
629
+ client, server = connect(
630
+ {
631
+ "Collection.deleteAlertPolicy": None,
632
+ "Collection.searchAlertPolicies": {"policies": [{"policyId": 1, "policyName": "kept"}]},
633
+ }
634
+ )
635
+ assert client.delete_alert_policy(65) is True
636
+ assert server.sent("Collection.deleteAlertPolicy") == {"policyId": 65, "reassignPolicyId": -1}
637
+ assert server.sent("Collection.searchAlertPolicies")["managedNetworks"] == []
638
+
639
+ def test_a_policy_still_listed_after_a_delete_is_not_deleted(self, connect):
640
+ client, _ = connect(
641
+ {
642
+ "Collection.deleteAlertPolicy": True,
643
+ "Collection.searchAlertPolicies": {"policies": [{"policyId": 65, "policyName": "p"}]},
644
+ }
645
+ )
646
+ assert client.delete_alert_policy(65, reassign_policy_id=1) is False
647
+
648
+ @pytest.mark.parametrize(
649
+ "answer", [None, {}, {"policies": None}, {"policies": [{}]}, {"policies": [{"policyId": None}, "x"]}]
650
+ )
651
+ def test_a_failed_read_back_raises_rather_than_reading_as_deleted(self, connect, answer):
652
+ client, _ = connect({"Collection.deleteAlertPolicy": None, "Collection.searchAlertPolicies": answer})
653
+ with pytest.raises(ValueError):
654
+ client.delete_alert_policy(65)
655
+
656
+ def test_a_failed_search_still_reads_as_no_policies(self, connect):
657
+ client, _ = connect({"Collection.searchAlertPolicies": None})
658
+ assert client.search_alert_policies() == []
659
+
660
+ @pytest.mark.parametrize("answer", [True, False])
661
+ def test_a_reference_check_sends_the_id_and_returns_the_answer(self, connect, answer):
662
+ client, server = connect({"Collection.isAlertPolicyReferenced": answer})
663
+ assert client.is_alert_policy_referenced(27) is answer
664
+ assert server.sent("Collection.isAlertPolicyReferenced") == {"policyId": 27}
665
+
666
+ def test_a_reference_check_with_no_answer_raises_rather_than_reading_as_unreferenced(self, connect):
667
+ client, _ = connect({"Collection.isAlertPolicyReferenced": None})
668
+ with pytest.raises(ValueError):
669
+ client.is_alert_policy_referenced(27)
670
+
671
+ @pytest.mark.parametrize("answer", [True, False])
672
+ def test_a_name_check_sends_what_the_ui_sends_and_returns_the_answer(self, connect, answer):
673
+ client, server = connect({"Collection.isAlertPolicyNameUnique": answer})
674
+ assert client.is_alert_policy_name_unique("p1", ["Default"], ignore_policy_id=1) is answer
675
+ assert server.sent("Collection.isAlertPolicyNameUnique") == {
676
+ "policyName": "p1",
677
+ "managedNetworks": ["Default"],
678
+ "ignorePolicyId": 1,
679
+ }
680
+
681
+ def test_a_name_check_ignores_no_policy_by_default(self, connect):
682
+ client, server = connect({"Collection.isAlertPolicyNameUnique": True})
683
+ client.is_alert_policy_name_unique("p1", ["Default"])
684
+ assert server.sent("Collection.isAlertPolicyNameUnique")["ignorePolicyId"] == -1
685
+
686
+ def test_a_name_check_with_no_answer_raises_rather_than_reading_as_taken(self, connect):
687
+ client, _ = connect({"Collection.isAlertPolicyNameUnique": None})
688
+ with pytest.raises(ValueError):
689
+ client.is_alert_policy_name_unique("p1", ["Default"])
@@ -1 +0,0 @@
1
- 0.2.4
File without changes
File without changes
File without changes