|
2 | 2 |
|
3 | 3 | from unittest.mock import AsyncMock, MagicMock, patch |
4 | 4 |
|
| 5 | +import httpx |
5 | 6 | import pytest |
6 | 7 |
|
7 | 8 | from ocpi.core import enums, schemas |
8 | 9 | from ocpi.core.adapter import BaseAdapter |
9 | 10 | from ocpi.core.crud import Crud |
10 | 11 | from ocpi.core.push import ( |
| 12 | + _pick_version_details_url, |
11 | 13 | client_method, |
12 | 14 | client_url, |
13 | 15 | push_object, |
@@ -533,3 +535,270 @@ async def test_push_object_multiple_receivers(): |
533 | 535 | # Should have responses for both receivers |
534 | 536 | assert len(result.receiver_responses) == 2 |
535 | 537 | assert mock_client.return_value.__aenter__.return_value.get.await_count == 2 |
| 538 | + |
| 539 | + |
| 540 | +# --------------------------------------------------------------------------- |
| 541 | +# _pick_version_details_url unit tests |
| 542 | +# --------------------------------------------------------------------------- |
| 543 | + |
| 544 | + |
| 545 | +def test_pick_version_details_url_exact_match(): |
| 546 | + """Returns the URL for the exact requested version.""" |
| 547 | + versions_list = [ |
| 548 | + {"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"}, |
| 549 | + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, |
| 550 | + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, |
| 551 | + ] |
| 552 | + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) |
| 553 | + assert result == "https://example.com/ocpi/2.2.1/details" |
| 554 | + |
| 555 | + |
| 556 | +def test_pick_version_details_url_fallback_to_highest(): |
| 557 | + """Falls back to the highest available version when requested is absent.""" |
| 558 | + versions_list = [ |
| 559 | + {"version": "2.1.1", "url": "https://example.com/ocpi/2.1.1/details"}, |
| 560 | + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, |
| 561 | + ] |
| 562 | + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) |
| 563 | + assert result == "https://example.com/ocpi/2.3.0/details" |
| 564 | + |
| 565 | + |
| 566 | +def test_pick_version_details_url_no_mutual_version(): |
| 567 | + """Returns None when no known version is present in the list.""" |
| 568 | + versions_list = [{"version": "1.0", "url": "https://example.com/ocpi/1.0/details"}] |
| 569 | + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) |
| 570 | + assert result is None |
| 571 | + |
| 572 | + |
| 573 | +def test_pick_version_details_url_empty_list(): |
| 574 | + """Returns None for an empty versions list.""" |
| 575 | + result = _pick_version_details_url([], VersionNumber.v_2_2_1) |
| 576 | + assert result is None |
| 577 | + |
| 578 | + |
| 579 | +def test_pick_version_details_url_missing_url_key(): |
| 580 | + """Entries without a 'url' key are skipped without raising KeyError.""" |
| 581 | + versions_list = [ |
| 582 | + {"version": "2.2.1"}, # no "url" — must not raise |
| 583 | + {"version": "2.3.0", "url": "https://example.com/ocpi/2.3.0/details"}, |
| 584 | + ] |
| 585 | + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) |
| 586 | + # 2.2.1 entry is skipped; falls back to 2.3.0 |
| 587 | + assert result == "https://example.com/ocpi/2.3.0/details" |
| 588 | + |
| 589 | + |
| 590 | +def test_pick_version_details_url_missing_version_key(): |
| 591 | + """Entries without a 'version' key are skipped without raising KeyError.""" |
| 592 | + versions_list = [ |
| 593 | + {"url": "https://example.com/ocpi/unknown/details"}, # no "version" |
| 594 | + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, |
| 595 | + ] |
| 596 | + result = _pick_version_details_url(versions_list, VersionNumber.v_2_2_1) |
| 597 | + assert result == "https://example.com/ocpi/2.2.1/details" |
| 598 | + |
| 599 | + |
| 600 | +# --------------------------------------------------------------------------- |
| 601 | +# push_object — version negotiation path |
| 602 | +# --------------------------------------------------------------------------- |
| 603 | + |
| 604 | + |
| 605 | +@pytest.mark.asyncio |
| 606 | +async def test_push_object_version_negotiation_via_versions_list(): |
| 607 | + """push_object performs a second GET to fetch version details when the |
| 608 | + first response returns an OCPI versions list instead of version details.""" |
| 609 | + mock_crud = AsyncMock(spec=MockCrud) |
| 610 | + mock_crud.get.return_value = {"id": "loc-123"} |
| 611 | + |
| 612 | + mock_adapter = MagicMock(spec=BaseAdapter) |
| 613 | + mock_adapter.location_adapter.return_value.model_dump.return_value = {"id": "loc-123"} |
| 614 | + |
| 615 | + push = schemas.Push( |
| 616 | + module_id=enums.ModuleID.locations, |
| 617 | + object_id="loc-123", |
| 618 | + receivers=[ |
| 619 | + schemas.Receiver( |
| 620 | + endpoints_url="https://example.com/versions", auth_token="token" |
| 621 | + ), |
| 622 | + ], |
| 623 | + ) |
| 624 | + |
| 625 | + mock_versions_response = MagicMock() |
| 626 | + mock_versions_response.status_code = 200 |
| 627 | + mock_versions_response.raise_for_status = MagicMock() |
| 628 | + mock_versions_response.json.return_value = { |
| 629 | + "data": [ |
| 630 | + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, |
| 631 | + ] |
| 632 | + } |
| 633 | + |
| 634 | + mock_details_response = MagicMock() |
| 635 | + mock_details_response.status_code = 200 |
| 636 | + mock_details_response.raise_for_status = MagicMock() |
| 637 | + mock_details_response.json.return_value = { |
| 638 | + "data": { |
| 639 | + "endpoints": [ |
| 640 | + { |
| 641 | + "identifier": enums.ModuleID.locations, |
| 642 | + "role": InterfaceRole.receiver, |
| 643 | + "url": "https://example.com/locations", |
| 644 | + } |
| 645 | + ] |
| 646 | + } |
| 647 | + } |
| 648 | + |
| 649 | + mock_push_response = MagicMock() |
| 650 | + mock_push_response.status_code = 200 |
| 651 | + mock_push_response.json.return_value = {"status_code": 1000} |
| 652 | + |
| 653 | + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: |
| 654 | + mock_client.return_value.__aenter__.return_value.get = AsyncMock( |
| 655 | + side_effect=[mock_versions_response, mock_details_response] |
| 656 | + ) |
| 657 | + mock_client.return_value.__aenter__.return_value.send = AsyncMock( |
| 658 | + return_value=mock_push_response |
| 659 | + ) |
| 660 | + mock_client.return_value.__aenter__.return_value.build_request = MagicMock() |
| 661 | + |
| 662 | + result = await push_object( |
| 663 | + version=VersionNumber.v_2_2_1, |
| 664 | + push=push, |
| 665 | + crud=mock_crud, |
| 666 | + adapter=mock_adapter, |
| 667 | + auth_token="auth-token", |
| 668 | + ) |
| 669 | + |
| 670 | + assert len(result.receiver_responses) == 1 |
| 671 | + assert result.receiver_responses[0].status_code == 200 |
| 672 | + |
| 673 | + get_calls = mock_client.return_value.__aenter__.return_value.get.call_args_list |
| 674 | + assert len(get_calls) == 2 |
| 675 | + assert get_calls[1][0][0] == "https://example.com/ocpi/2.2.1/details" |
| 676 | + |
| 677 | + |
| 678 | +@pytest.mark.asyncio |
| 679 | +async def test_push_object_version_negotiation_no_mutual_version(): |
| 680 | + """push_object raises ValueError when no mutual OCPI version can be negotiated.""" |
| 681 | + mock_crud = AsyncMock(spec=MockCrud) |
| 682 | + mock_adapter = MagicMock(spec=BaseAdapter) |
| 683 | + |
| 684 | + push = schemas.Push( |
| 685 | + module_id=enums.ModuleID.locations, |
| 686 | + object_id="loc-123", |
| 687 | + receivers=[ |
| 688 | + schemas.Receiver( |
| 689 | + endpoints_url="https://example.com/versions", auth_token="token" |
| 690 | + ), |
| 691 | + ], |
| 692 | + ) |
| 693 | + |
| 694 | + mock_versions_response = MagicMock() |
| 695 | + mock_versions_response.status_code = 200 |
| 696 | + mock_versions_response.raise_for_status = MagicMock() |
| 697 | + mock_versions_response.json.return_value = { |
| 698 | + "data": [ |
| 699 | + {"version": "1.0", "url": "https://example.com/ocpi/1.0/details"}, |
| 700 | + ] |
| 701 | + } |
| 702 | + |
| 703 | + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: |
| 704 | + mock_client.return_value.__aenter__.return_value.get = AsyncMock( |
| 705 | + return_value=mock_versions_response |
| 706 | + ) |
| 707 | + |
| 708 | + with pytest.raises(ValueError, match="No mutual OCPI version found"): |
| 709 | + await push_object( |
| 710 | + version=VersionNumber.v_2_2_1, |
| 711 | + push=push, |
| 712 | + crud=mock_crud, |
| 713 | + adapter=mock_adapter, |
| 714 | + ) |
| 715 | + |
| 716 | + |
| 717 | +# --------------------------------------------------------------------------- |
| 718 | +# push_object — HTTP error handling |
| 719 | +# --------------------------------------------------------------------------- |
| 720 | + |
| 721 | + |
| 722 | +@pytest.mark.asyncio |
| 723 | +async def test_push_object_raises_on_non_200_endpoints_response(): |
| 724 | + """push_object propagates httpx.HTTPStatusError when the first GET fails.""" |
| 725 | + mock_crud = AsyncMock(spec=MockCrud) |
| 726 | + mock_adapter = MagicMock(spec=BaseAdapter) |
| 727 | + |
| 728 | + push = schemas.Push( |
| 729 | + module_id=enums.ModuleID.locations, |
| 730 | + object_id="loc-123", |
| 731 | + receivers=[ |
| 732 | + schemas.Receiver( |
| 733 | + endpoints_url="https://example.com/versions", auth_token="token" |
| 734 | + ), |
| 735 | + ], |
| 736 | + ) |
| 737 | + |
| 738 | + mock_error_response = MagicMock() |
| 739 | + mock_error_response.status_code = 503 |
| 740 | + mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError( |
| 741 | + "Service Unavailable", |
| 742 | + request=MagicMock(), |
| 743 | + response=mock_error_response, |
| 744 | + ) |
| 745 | + |
| 746 | + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: |
| 747 | + mock_client.return_value.__aenter__.return_value.get = AsyncMock( |
| 748 | + return_value=mock_error_response |
| 749 | + ) |
| 750 | + |
| 751 | + with pytest.raises(httpx.HTTPStatusError): |
| 752 | + await push_object( |
| 753 | + version=VersionNumber.v_2_2_1, |
| 754 | + push=push, |
| 755 | + crud=mock_crud, |
| 756 | + adapter=mock_adapter, |
| 757 | + ) |
| 758 | + |
| 759 | + |
| 760 | +@pytest.mark.asyncio |
| 761 | +async def test_push_object_raises_on_non_200_version_details_response(): |
| 762 | + """push_object propagates httpx.HTTPStatusError when the version details GET fails.""" |
| 763 | + mock_crud = AsyncMock(spec=MockCrud) |
| 764 | + mock_adapter = MagicMock(spec=BaseAdapter) |
| 765 | + |
| 766 | + push = schemas.Push( |
| 767 | + module_id=enums.ModuleID.locations, |
| 768 | + object_id="loc-123", |
| 769 | + receivers=[ |
| 770 | + schemas.Receiver( |
| 771 | + endpoints_url="https://example.com/versions", auth_token="token" |
| 772 | + ), |
| 773 | + ], |
| 774 | + ) |
| 775 | + |
| 776 | + mock_versions_response = MagicMock() |
| 777 | + mock_versions_response.status_code = 200 |
| 778 | + mock_versions_response.raise_for_status = MagicMock() |
| 779 | + mock_versions_response.json.return_value = { |
| 780 | + "data": [ |
| 781 | + {"version": "2.2.1", "url": "https://example.com/ocpi/2.2.1/details"}, |
| 782 | + ] |
| 783 | + } |
| 784 | + |
| 785 | + mock_error_response = MagicMock() |
| 786 | + mock_error_response.status_code = 404 |
| 787 | + mock_error_response.raise_for_status.side_effect = httpx.HTTPStatusError( |
| 788 | + "Not Found", |
| 789 | + request=MagicMock(), |
| 790 | + response=mock_error_response, |
| 791 | + ) |
| 792 | + |
| 793 | + with patch("ocpi.core.push.httpx.AsyncClient") as mock_client: |
| 794 | + mock_client.return_value.__aenter__.return_value.get = AsyncMock( |
| 795 | + side_effect=[mock_versions_response, mock_error_response] |
| 796 | + ) |
| 797 | + |
| 798 | + with pytest.raises(httpx.HTTPStatusError): |
| 799 | + await push_object( |
| 800 | + version=VersionNumber.v_2_2_1, |
| 801 | + push=push, |
| 802 | + crud=mock_crud, |
| 803 | + adapter=mock_adapter, |
| 804 | + ) |
0 commit comments