436 lines
18 KiB
Python
436 lines
18 KiB
Python
|
# Copyright (c) 2023-2025 Arista Networks, Inc.
|
||
|
# Use of this source code is governed by the Apache License 2.0
|
||
|
# that can be found in the LICENSE file.
|
||
|
"""Unit tests for the asynceapi._models module."""
|
||
|
|
||
|
import logging
|
||
|
from typing import TYPE_CHECKING
|
||
|
from uuid import UUID
|
||
|
|
||
|
import pytest
|
||
|
|
||
|
from asynceapi._constants import EapiCommandFormat
|
||
|
from asynceapi._errors import EapiReponseError
|
||
|
from asynceapi._models import EapiCommandResult, EapiRequest, EapiResponse
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from asynceapi._types import EapiComplexCommand, EapiSimpleCommand
|
||
|
|
||
|
|
||
|
class TestEapiRequest:
|
||
|
"""Test cases for the EapiRequest class."""
|
||
|
|
||
|
def test_init_with_defaults(self) -> None:
|
||
|
"""Test initialization with just required parameters."""
|
||
|
commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version", "show interfaces"]
|
||
|
req = EapiRequest(commands=commands)
|
||
|
|
||
|
# Check required attributes
|
||
|
assert req.commands == commands
|
||
|
|
||
|
# Check default values
|
||
|
assert req.version == "latest"
|
||
|
assert req.format == EapiCommandFormat.JSON
|
||
|
assert req.timestamps is False
|
||
|
assert req.auto_complete is False
|
||
|
assert req.expand_aliases is False
|
||
|
assert req.stop_on_error is True
|
||
|
|
||
|
# Check that ID is generated as a UUID hex string
|
||
|
try:
|
||
|
UUID(str(req.id))
|
||
|
is_valid_uuid = True
|
||
|
except ValueError:
|
||
|
is_valid_uuid = False
|
||
|
assert is_valid_uuid
|
||
|
|
||
|
def test_init_with_custom_values(self) -> None:
|
||
|
"""Test initialization with custom parameter values."""
|
||
|
commands: list[EapiSimpleCommand | EapiComplexCommand] = [{"cmd": "enable", "input": "password"}, "show version"]
|
||
|
req = EapiRequest(
|
||
|
commands=commands,
|
||
|
version=1,
|
||
|
format=EapiCommandFormat.TEXT,
|
||
|
timestamps=True,
|
||
|
auto_complete=True,
|
||
|
expand_aliases=True,
|
||
|
stop_on_error=False,
|
||
|
id="custom-id-123",
|
||
|
)
|
||
|
|
||
|
# Check all attributes match expected values
|
||
|
assert req.commands == commands
|
||
|
assert req.version == 1
|
||
|
assert req.format == EapiCommandFormat.TEXT
|
||
|
assert req.timestamps is True
|
||
|
assert req.auto_complete is True
|
||
|
assert req.expand_aliases is True
|
||
|
assert req.stop_on_error is False
|
||
|
assert req.id == "custom-id-123"
|
||
|
|
||
|
def test_to_jsonrpc(self) -> None:
|
||
|
"""Test conversion to JSON-RPC dictionary."""
|
||
|
commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version", "show interfaces"]
|
||
|
req = EapiRequest(commands=commands, version=1, format=EapiCommandFormat.TEXT, id="test-id-456")
|
||
|
|
||
|
jsonrpc = req.to_jsonrpc()
|
||
|
|
||
|
# Check that structure matches expected JSON-RPC format
|
||
|
assert jsonrpc["jsonrpc"] == "2.0"
|
||
|
assert jsonrpc["method"] == "runCmds"
|
||
|
assert jsonrpc["id"] == "test-id-456"
|
||
|
|
||
|
# Check params matches our configuration
|
||
|
params = jsonrpc["params"]
|
||
|
assert params["version"] == 1
|
||
|
assert params["cmds"] == commands
|
||
|
assert params["format"] == EapiCommandFormat.TEXT
|
||
|
assert params["timestamps"] is False
|
||
|
assert params["autoComplete"] is False
|
||
|
assert params["expandAliases"] is False
|
||
|
assert params["stopOnError"] is True
|
||
|
|
||
|
def test_to_jsonrpc_with_complex_commands(self) -> None:
|
||
|
"""Test JSON-RPC conversion with complex commands."""
|
||
|
commands: list[EapiSimpleCommand | EapiComplexCommand] = [
|
||
|
{"cmd": "enable", "input": "password"},
|
||
|
{"cmd": "configure", "input": ""},
|
||
|
{"cmd": "hostname test-device"},
|
||
|
]
|
||
|
req = EapiRequest(commands=commands)
|
||
|
|
||
|
jsonrpc = req.to_jsonrpc()
|
||
|
|
||
|
# Verify commands are passed correctly
|
||
|
assert jsonrpc["params"]["cmds"] == commands
|
||
|
|
||
|
def test_immutability(self) -> None:
|
||
|
"""Test that the dataclass is truly immutable (frozen)."""
|
||
|
commands: list[EapiSimpleCommand | EapiComplexCommand] = ["show version"]
|
||
|
req = EapiRequest(commands=commands)
|
||
|
|
||
|
# Attempting to modify any attribute should raise an error
|
||
|
with pytest.raises(AttributeError):
|
||
|
req.commands = ["new command"] # type: ignore[misc]
|
||
|
|
||
|
with pytest.raises(AttributeError):
|
||
|
req.id = "new-id" # type: ignore[misc]
|
||
|
|
||
|
|
||
|
class TestEapiResponse:
|
||
|
"""Test cases for the EapiResponse class."""
|
||
|
|
||
|
def test_init_and_properties(self) -> None:
|
||
|
"""Test basic initialization and properties."""
|
||
|
# Create mock command results
|
||
|
result1 = EapiCommandResult(command="show version", output={"version": "4.33.2F-40713977.4332F (engineering build)"})
|
||
|
result2 = EapiCommandResult(command="show hostname", output={"hostname": "DC1-LEAF1A"})
|
||
|
|
||
|
# Create response with results
|
||
|
results = {0: result1, 1: result2}
|
||
|
response = EapiResponse(request_id="test-123", _results=results)
|
||
|
|
||
|
# Check attributes
|
||
|
assert response.request_id == "test-123"
|
||
|
assert response.error_code is None
|
||
|
assert response.error_message is None
|
||
|
|
||
|
# Check properties
|
||
|
assert response.success is True
|
||
|
assert len(response.results) == 2
|
||
|
assert response.results[0] == result1
|
||
|
assert response.results[1] == result2
|
||
|
|
||
|
def test_error_response(self) -> None:
|
||
|
"""Test initialization with error information."""
|
||
|
result = EapiCommandResult(command="show bad command", output=None, errors=["Invalid input (at token 1: 'bad')"], success=False)
|
||
|
results = {0: result}
|
||
|
response = EapiResponse(
|
||
|
request_id="test-456", _results=results, error_code=1002, error_message="CLI command 1 of 1 'show bad command' failed: invalid command"
|
||
|
)
|
||
|
|
||
|
assert response.request_id == "test-456"
|
||
|
assert response.error_code == 1002
|
||
|
assert response.error_message == "CLI command 1 of 1 'show bad command' failed: invalid command"
|
||
|
assert response.success is False
|
||
|
assert len(response.results) == 1
|
||
|
assert response.results[0].success is False
|
||
|
assert "Invalid input (at token 1: 'bad')" in response.results[0].errors
|
||
|
|
||
|
def test_len_and_iteration(self) -> None:
|
||
|
"""Test __len__ and __iter__ methods."""
|
||
|
# Create 3 command results
|
||
|
results = {
|
||
|
0: EapiCommandResult(command="cmd1", output="out1"),
|
||
|
1: EapiCommandResult(command="cmd2", output="out2"),
|
||
|
2: EapiCommandResult(command="cmd3", output="out3"),
|
||
|
}
|
||
|
response = EapiResponse(request_id="test-789", _results=results)
|
||
|
|
||
|
# Test __len__
|
||
|
assert len(response) == 3
|
||
|
|
||
|
# Test __iter__
|
||
|
iterated_results = list(response)
|
||
|
assert len(iterated_results) == 3
|
||
|
assert [r.command for r in iterated_results] == ["cmd1", "cmd2", "cmd3"]
|
||
|
|
||
|
def test_from_jsonrpc_success(self) -> None:
|
||
|
"""Test from_jsonrpc with successful response."""
|
||
|
# Mock request
|
||
|
request = EapiRequest(commands=["show version", "show hostname"], format=EapiCommandFormat.JSON)
|
||
|
|
||
|
# Mock response data
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "test-id-123",
|
||
|
"result": [{"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}, {"hostname": "DC1-LEAF1A"}],
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify response object
|
||
|
assert response.request_id == "test-id-123"
|
||
|
assert response.success is True
|
||
|
assert response.error_code is None
|
||
|
assert response.error_message is None
|
||
|
|
||
|
# Verify results
|
||
|
assert len(response) == 2
|
||
|
assert response.results[0].command == "show version"
|
||
|
assert response.results[0].output == {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}
|
||
|
assert response.results[0].success is True
|
||
|
assert response.results[1].command == "show hostname"
|
||
|
assert response.results[1].output == {"hostname": "DC1-LEAF1A"}
|
||
|
assert response.results[1].success is True
|
||
|
|
||
|
def test_from_jsonrpc_text_format(self) -> None:
|
||
|
"""Test from_jsonrpc with TEXT format responses."""
|
||
|
# Mock request with TEXT format
|
||
|
request = EapiRequest(commands=["show version", "show hostname"], format=EapiCommandFormat.TEXT)
|
||
|
|
||
|
# Mock response data
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "text-format-id",
|
||
|
"result": [{"output": "Arista cEOSLab\n\nSoftware image version: 4.33.2F-40713977.4332F"}, {"output": "Hostname: DC1-LEAF1A\nFQDN: DC1-LEAF1A\n"}],
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify results contain the text output
|
||
|
assert len(response) == 2
|
||
|
assert response.results[0].output is not None
|
||
|
assert "Arista cEOSLab" in response.results[0].output
|
||
|
assert response.results[1].output is not None
|
||
|
assert "Hostname: DC1-LEAF1A" in response.results[1].output
|
||
|
|
||
|
def test_from_jsonrpc_with_timestamps(self) -> None:
|
||
|
"""Test from_jsonrpc with timestamps enabled."""
|
||
|
# Mock request with timestamps
|
||
|
request = EapiRequest(commands=["show version"], format=EapiCommandFormat.JSON, timestamps=True)
|
||
|
|
||
|
# Mock response data with timestamps
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "timestamp-id",
|
||
|
"result": [
|
||
|
{
|
||
|
"modelName": "cEOSLab",
|
||
|
"version": "4.33.2F-40713977.4332F (engineering build)",
|
||
|
"_meta": {"execStartTime": 1741014072.2534037, "execDuration": 0.0024061203002929688},
|
||
|
}
|
||
|
],
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify timestamp data is processed
|
||
|
assert len(response) == 1
|
||
|
assert response.results[0].start_time == 1741014072.2534037
|
||
|
assert response.results[0].duration == 0.0024061203002929688
|
||
|
|
||
|
# Verify _meta is removed from output
|
||
|
assert response.results[0].output is not None
|
||
|
assert "_meta" not in response.results[0].output
|
||
|
|
||
|
def test_from_jsonrpc_error_stop_on_error_true(self) -> None:
|
||
|
"""Test from_jsonrpc with error and stop_on_error=True."""
|
||
|
# Mock request with stop_on_error=True
|
||
|
request = EapiRequest(commands=["show bad command", "show version", "show hostname"], stop_on_error=True)
|
||
|
|
||
|
# Mock error response
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "error-id",
|
||
|
"error": {
|
||
|
"code": 1002,
|
||
|
"message": "CLI command 1 of 3 'show bad command' failed: invalid command",
|
||
|
"data": [{"errors": ["Invalid input (at token 1: 'bad')"]}],
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify error info
|
||
|
assert response.request_id == "error-id"
|
||
|
assert response.error_code == 1002
|
||
|
assert response.error_message == "CLI command 1 of 3 'show bad command' failed: invalid command"
|
||
|
assert response.success is False
|
||
|
|
||
|
# Verify results - should have entries for all commands
|
||
|
assert len(response) == 3
|
||
|
|
||
|
# First command failed
|
||
|
assert response.results[0].command == "show bad command"
|
||
|
assert response.results[0].output is None
|
||
|
assert response.results[0].success is False
|
||
|
assert response.results[0].errors == ["Invalid input (at token 1: 'bad')"]
|
||
|
|
||
|
# Remaining commands weren't executed due to stop_on_error=True
|
||
|
assert response.results[1].command == "show version"
|
||
|
assert response.results[1].output is None
|
||
|
assert response.results[1].success is False
|
||
|
assert "Command not executed due to previous error" in response.results[1].errors
|
||
|
assert response.results[1].executed is False
|
||
|
|
||
|
assert response.results[2].command == "show hostname"
|
||
|
assert response.results[2].output is None
|
||
|
assert response.results[2].success is False
|
||
|
assert "Command not executed due to previous error" in response.results[2].errors
|
||
|
assert response.results[2].executed is False
|
||
|
|
||
|
def test_from_jsonrpc_error_stop_on_error_false(self) -> None:
|
||
|
"""Test from_jsonrpc with error and stop_on_error=False."""
|
||
|
# Mock request with stop_on_error=False
|
||
|
request = EapiRequest(commands=["show bad command", "show version", "show hostname"], stop_on_error=False)
|
||
|
|
||
|
# Mock response with error for first command but others succeed
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "error-continue-id",
|
||
|
"error": {
|
||
|
"code": 1002,
|
||
|
"message": "CLI command 1 of 3 'show bad command' failed: invalid command",
|
||
|
"data": [
|
||
|
{"errors": ["Invalid input (at token 1: 'bad')"]},
|
||
|
{"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"},
|
||
|
{"hostname": "DC1-LEAF1A"},
|
||
|
],
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify error info
|
||
|
assert response.request_id == "error-continue-id"
|
||
|
assert response.error_code == 1002
|
||
|
assert response.error_message == "CLI command 1 of 3 'show bad command' failed: invalid command"
|
||
|
assert response.success is False
|
||
|
|
||
|
# Verify individual command results
|
||
|
assert len(response) == 3
|
||
|
|
||
|
# First command failed
|
||
|
assert response.results[0].command == "show bad command"
|
||
|
assert response.results[0].output is None
|
||
|
assert response.results[0].success is False
|
||
|
assert response.results[0].errors == ["Invalid input (at token 1: 'bad')"]
|
||
|
|
||
|
# Remaining commands succeeded
|
||
|
assert response.results[1].command == "show version"
|
||
|
assert response.results[1].output == {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}
|
||
|
assert response.results[1].success is True
|
||
|
|
||
|
assert response.results[2].command == "show hostname"
|
||
|
assert response.results[2].output == {"hostname": "DC1-LEAF1A"}
|
||
|
assert response.results[2].success is True
|
||
|
|
||
|
def test_from_jsonrpc_raise_on_error(self) -> None:
|
||
|
"""Test from_jsonrpc with raise_on_error=True."""
|
||
|
# Mock request
|
||
|
request = EapiRequest(commands=["show bad command"])
|
||
|
|
||
|
# Mock error response
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "raise-error-id",
|
||
|
"error": {
|
||
|
"code": 1002,
|
||
|
"message": "CLI command 1 of 1 'show bad command' failed: invalid command",
|
||
|
"data": [{"errors": ["Invalid input (at token 1: 'bad')"]}],
|
||
|
},
|
||
|
}
|
||
|
|
||
|
# Should raise EapiReponseError
|
||
|
with pytest.raises(EapiReponseError) as excinfo:
|
||
|
EapiResponse.from_jsonrpc(jsonrpc_response, request, raise_on_error=True)
|
||
|
|
||
|
# Verify the exception contains the response
|
||
|
assert excinfo.value.response.request_id == "raise-error-id"
|
||
|
assert excinfo.value.response.error_code == 1002
|
||
|
assert excinfo.value.response.error_message == "CLI command 1 of 1 'show bad command' failed: invalid command"
|
||
|
|
||
|
def test_from_jsonrpc_string_data(self, caplog: pytest.LogCaptureFixture) -> None:
|
||
|
"""Test from_jsonrpc with string data response."""
|
||
|
caplog.set_level(logging.WARNING)
|
||
|
|
||
|
# Mock request
|
||
|
request = EapiRequest(commands=["show bgp ipv4 unicast summary", "show bad command"])
|
||
|
|
||
|
# Mock response with JSON string
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "EapiExplorer-1",
|
||
|
"error": {
|
||
|
"code": 1002,
|
||
|
"message": "CLI command 2 of 2 'show bad command' failed: invalid command",
|
||
|
"data": [
|
||
|
'{"vrfs":{"default":{"vrf":"default","routerId":"10.1.0.11","asn":"65101","peers":{}}}}\n',
|
||
|
{"errors": ["Invalid input (at token 1: 'bad')"]},
|
||
|
],
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify string was parsed as JSON
|
||
|
assert response.results[0].output == {"vrfs": {"default": {"vrf": "default", "routerId": "10.1.0.11", "asn": "65101", "peers": {}}}}
|
||
|
|
||
|
# Now test with a non-JSON string
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "EapiExplorer-1",
|
||
|
"error": {
|
||
|
"code": 1002,
|
||
|
"message": "CLI command 2 of 2 'show bad command' failed: invalid command",
|
||
|
"data": ["This is not JSON", {"errors": ["Invalid input (at token 1: 'bad')"]}],
|
||
|
},
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify WARNING log message
|
||
|
assert "Invalid JSON response for command: show bgp ipv4 unicast summary. Storing as text: This is not JSON" in caplog.text
|
||
|
|
||
|
# Verify string is kept as is
|
||
|
assert response.results[0].output == "This is not JSON"
|
||
|
|
||
|
def test_from_jsonrpc_complex_commands(self) -> None:
|
||
|
"""Test from_jsonrpc with complex command structures."""
|
||
|
# Mock request with complex commands
|
||
|
request = EapiRequest(commands=[{"cmd": "enable", "input": "password"}, "show version"])
|
||
|
|
||
|
# Mock response
|
||
|
jsonrpc_response = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"id": "complex-cmd-id",
|
||
|
"result": [{}, {"modelName": "cEOSLab", "version": "4.33.2F-40713977.4332F (engineering build)"}],
|
||
|
}
|
||
|
|
||
|
response = EapiResponse.from_jsonrpc(jsonrpc_response, request)
|
||
|
|
||
|
# Verify command strings are extracted correctly
|
||
|
assert response.results[0].command == "enable"
|
||
|
assert response.results[1].command == "show version"
|