diff --git a/README.md b/README.md index 36be63e..588cc1e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ pip install git+https://github.com/remnawave/python-sdk.git@development | Contract Version | Remnawave Panel Version | | ---------------- | ----------------------- | +| 2.1.19 | >=2.1.19 | | 2.1.18 | >=2.1.18 | | 2.1.17 | >=2.1.16, <=2.1.17 | | 2.1.16 | >=2.1.16 | diff --git a/pyproject.toml b/pyproject.toml index 3df7550..cf6fcf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "remnawave" -version = "2.1.18" -description = "A Python SDK for interacting with the Remnawave API v2.1.18." +version = "2.1.19" +description = "A Python SDK for interacting with the Remnawave API v2.1.19." authors = [ {name = "Artem",email = "dev@forestsnet.com"} ] diff --git a/remnawave/controllers/infra_billing.py b/remnawave/controllers/infra_billing.py index cccc19f..213e138 100644 --- a/remnawave/controllers/infra_billing.py +++ b/remnawave/controllers/infra_billing.py @@ -1,20 +1,21 @@ from typing import Annotated -from rapid_api_client import Path, Query +from rapid_api_client import Path from rapid_api_client.annotations import PydanticBody from remnawave.models import ( + CreateInfraBillingHistoryRecordRequestDto, + CreateInfraBillingHistoryRecordResponseDto, CreateInfraBillingNodeRequestDto, CreateInfraBillingNodeResponseDto, CreateInfraProviderRequestDto, CreateInfraProviderResponseDto, - DeleteInfraBillingNodeResponseDto, - DeleteInfraProviderResponseDto, - GetAllInfraBillingHistoryResponseDto, - GetAllInfraBillingNodesResponseDto, - GetAllInfraProvidersResponseDto, - GetInfraBillingHistoryByUuidResponseDto, - GetInfraBillingNodeByUuidResponseDto, + DeleteInfraBillingHistoryRecordByUuidResponseDto, + DeleteInfraBillingNodeByUuidResponseDto, + DeleteInfraProviderByUuidResponseDto, + GetInfraBillingHistoryRecordsResponseDto, + GetInfraBillingNodesResponseDto, + GetInfraProvidersResponseDto, GetInfraProviderByUuidResponseDto, UpdateInfraBillingNodeRequestDto, UpdateInfraBillingNodeResponseDto, @@ -25,8 +26,8 @@ from remnawave.rapid import BaseController, delete, get, patch, post class InfraBillingController(BaseController): - @get("/infra-billing/providers", response_class=GetAllInfraProvidersResponseDto) - async def get_all_infra_providers(self) -> GetAllInfraProvidersResponseDto: + @get("/infra-billing/providers", response_class=GetInfraProvidersResponseDto) + async def get_infra_providers(self) -> GetInfraProvidersResponseDto: """Get all infra providers""" ... @@ -54,38 +55,38 @@ class InfraBillingController(BaseController): """Get infra provider by uuid""" ... - @delete("/infra-billing/providers/{uuid}", response_class=DeleteInfraProviderResponseDto) + @delete("/infra-billing/providers/{uuid}", response_class=DeleteInfraProviderByUuidResponseDto) async def delete_infra_provider_by_uuid( self, uuid: Annotated[str, Path(description="UUID of the infra provider")], - ) -> DeleteInfraProviderResponseDto: - """Delete infra provider""" + ) -> DeleteInfraProviderByUuidResponseDto: + """Delete infra provider by uuid""" ... - @get("/infra-billing/history", response_class=GetAllInfraBillingHistoryResponseDto) - async def get_all_infra_billing_history( + @post("/infra-billing/history", response_class=CreateInfraBillingHistoryRecordResponseDto) + async def create_infra_billing_history_record( self, - start: Annotated[int, Query(default=0, ge=0, description="Index to start pagination from")], - size: Annotated[int, Query(default=25, ge=1, description="Number of entries per page")], - ) -> GetAllInfraBillingHistoryResponseDto: - """Get all infra billing history""" + body: Annotated[CreateInfraBillingHistoryRecordRequestDto, PydanticBody()], + ) -> CreateInfraBillingHistoryRecordResponseDto: + """Create infra billing history""" ... - @get("/infra-billing/history/{uuid}", response_class=GetInfraBillingHistoryByUuidResponseDto) - async def get_infra_billing_history_by_uuid( - self, - uuid: Annotated[str, Path(description="UUID of the billing history entry")], - ) -> GetInfraBillingHistoryByUuidResponseDto: - """Get infra billing history by uuid""" + @get("/infra-billing/history", response_class=GetInfraBillingHistoryRecordsResponseDto) + async def get_infra_billing_history_records(self) -> GetInfraBillingHistoryRecordsResponseDto: + """Get infra billing history""" ... - @get("/infra-billing/nodes", response_class=GetAllInfraBillingNodesResponseDto) - async def get_all_infra_billing_nodes( + @delete("/infra-billing/history/{uuid}", response_class=DeleteInfraBillingHistoryRecordByUuidResponseDto) + async def delete_infra_billing_history_record_by_uuid( self, - start: Annotated[int, Query(default=0, ge=0, description="Index to start pagination from")], - size: Annotated[int, Query(default=25, ge=1, description="Number of entries per page")], - ) -> GetAllInfraBillingNodesResponseDto: - """Get all infra billing nodes""" + uuid: Annotated[str, Path(description="UUID of the billing history record")], + ) -> DeleteInfraBillingHistoryRecordByUuidResponseDto: + """Delete infra billing history""" + ... + + @get("/infra-billing/nodes", response_class=GetInfraBillingNodesResponseDto) + async def get_billing_nodes(self) -> GetInfraBillingNodesResponseDto: + """Get infra billing nodes""" ... @patch("/infra-billing/nodes", response_class=UpdateInfraBillingNodeResponseDto) @@ -93,7 +94,7 @@ class InfraBillingController(BaseController): self, body: Annotated[UpdateInfraBillingNodeRequestDto, PydanticBody()], ) -> UpdateInfraBillingNodeResponseDto: - """Update infra billing node""" + """Update infra billing nodes""" ... @post("/infra-billing/nodes", response_class=CreateInfraBillingNodeResponseDto) @@ -104,18 +105,10 @@ class InfraBillingController(BaseController): """Create infra billing node""" ... - @get("/infra-billing/nodes/{uuid}", response_class=GetInfraBillingNodeByUuidResponseDto) - async def get_infra_billing_node_by_uuid( - self, - uuid: Annotated[str, Path(description="UUID of the infra billing node")], - ) -> GetInfraBillingNodeByUuidResponseDto: - """Get infra billing node by uuid""" - ... - - @delete("/infra-billing/nodes/{uuid}", response_class=DeleteInfraBillingNodeResponseDto) + @delete("/infra-billing/nodes/{uuid}", response_class=DeleteInfraBillingNodeByUuidResponseDto) async def delete_infra_billing_node_by_uuid( self, uuid: Annotated[str, Path(description="UUID of the infra billing node")], - ) -> DeleteInfraBillingNodeResponseDto: + ) -> DeleteInfraBillingNodeByUuidResponseDto: """Delete infra billing node""" - ... + ... \ No newline at end of file diff --git a/remnawave/models/__init__.py b/remnawave/models/__init__.py index 5465b27..3600863 100644 --- a/remnawave/models/__init__.py +++ b/remnawave/models/__init__.py @@ -98,15 +98,18 @@ from .inbounds_bulk_actions import ( RemoveInboundFromUsersResponseDto, ) from .infra_billing import ( + CreateInfraBillingHistoryRecordRequestDto, + CreateInfraBillingHistoryRecordResponseDto, CreateInfraBillingNodeRequestDto, CreateInfraBillingNodeResponseDto, CreateInfraProviderRequestDto, CreateInfraProviderResponseDto, - DeleteInfraBillingNodeResponseDto, - DeleteInfraProviderResponseDto, - GetAllInfraBillingHistoryResponseDto, - GetAllInfraBillingNodesResponseDto, - GetAllInfraProvidersResponseDto, + DeleteInfraBillingHistoryRecordByUuidResponseDto, + DeleteInfraBillingNodeByUuidResponseDto, # ПЕРЕИМЕНОВАНА (было DeleteInfraBillingNodeResponseDto) + DeleteInfraProviderByUuidResponseDto, # ПЕРЕИМЕНОВАНА (было DeleteInfraProviderResponseDto) + GetInfraBillingHistoryRecordsResponseDto, # ПЕРЕИМЕНОВАНА (было GetAllInfraBillingHistoryResponseDto) + GetInfraBillingNodesResponseDto, # ПЕРЕИМЕНОВАНА (было GetAllInfraBillingNodesResponseDto) + GetInfraProvidersResponseDto, # ПЕРЕИМЕНОВАНА (было GetAllInfraProvidersResponseDto) GetInfraBillingHistoryByUuidResponseDto, GetInfraBillingNodeByUuidResponseDto, GetInfraProviderByUuidResponseDto, @@ -118,6 +121,11 @@ from .infra_billing import ( UpdateInfraBillingNodeResponseDto, UpdateInfraProviderRequestDto, UpdateInfraProviderResponseDto, + DeleteInfraBillingNodeResponseDto, # LEGACY + DeleteInfraProviderResponseDto, # LEGACY + GetAllInfraBillingHistoryResponseDto, # LEGACY + GetAllInfraBillingNodesResponseDto, # LEGACY + GetAllInfraProvidersResponseDto, # LEGACY ) from .internal_squads import ( AddUsersToInternalSquadRequestDto, @@ -459,15 +467,18 @@ __all__ = [ "UpdateConfigProfileResponseDto", "GetAllConfigProfilesResponsePaginated", # Infra billing models + "CreateInfraBillingHistoryRecordRequestDto", + "CreateInfraBillingHistoryRecordResponseDto", "CreateInfraBillingNodeRequestDto", "CreateInfraBillingNodeResponseDto", "CreateInfraProviderRequestDto", - "CreateInfraProviderResponseDto", - "DeleteInfraBillingNodeResponseDto", - "DeleteInfraProviderResponseDto", - "GetAllInfraBillingHistoryResponseDto", - "GetAllInfraBillingNodesResponseDto", - "GetAllInfraProvidersResponseDto", + "CreateInfraProviderResponseDto", + "DeleteInfraBillingHistoryRecordByUuidResponseDto", + "DeleteInfraBillingNodeByUuidResponseDto", # ПЕРЕИМЕНОВАНА (было DeleteInfraBillingNodeResponseDto) + "DeleteInfraProviderByUuidResponseDto", # ПЕРЕИМЕНОВАНА (было DeleteInfraProviderResponseDto) + "GetInfraBillingHistoryRecordsResponseDto", # ПЕРЕИМЕНОВАНА (было GetAllInfraBillingHistoryResponseDto) + "GetInfraBillingNodesResponseDto", # ПЕРЕИМЕНОВАНА (было GetAllInfraBillingNodesResponseDto) + "GetInfraProvidersResponseDto", # ПЕРЕИМЕНОВАНА (было GetAllInfraProvidersResponseDto) "GetInfraBillingHistoryByUuidResponseDto", "GetInfraBillingNodeByUuidResponseDto", "GetInfraProviderByUuidResponseDto", @@ -479,6 +490,11 @@ __all__ = [ "UpdateInfraBillingNodeResponseDto", "UpdateInfraProviderRequestDto", "UpdateInfraProviderResponseDto", + "DeleteInfraBillingNodeResponseDto", # LEGACY + "DeleteInfraProviderResponseDto", # LEGACY + "GetAllInfraBillingHistoryResponseDto", # LEGACY + "GetAllInfraBillingNodesResponseDto", # LEGACY + "GetAllInfraProvidersResponseDto", # LEGACY # Internal squads models "AddUsersToInternalSquadRequestDto", "AddUsersToInternalSquadResponseDto", diff --git a/remnawave/models/infra_billing.py b/remnawave/models/infra_billing.py index a46af8d..82df79b 100644 --- a/remnawave/models/infra_billing.py +++ b/remnawave/models/infra_billing.py @@ -5,6 +5,27 @@ from uuid import UUID from pydantic import BaseModel, Field +class InfraProviderSimpleDto(BaseModel): + """Упрощенная модель провайдера для billingNodes""" + uuid: UUID + name: str + login_url: Optional[str] = Field(alias="loginUrl") + favicon_link: Optional[str] = Field(alias="faviconLink") + + +class InfraBillingHistoryStatsDto(BaseModel): + """Статистика истории биллинга для провайдера""" + total_amount: float = Field(alias="totalAmount") + total_bills: float = Field(alias="totalBills") + + +class InfraBillingNodeSimpleDto(BaseModel): + """Упрощенная модель узла биллинга для провайдера""" + node_uuid: UUID = Field(alias="nodeUuid") + name: str + country_code: str = Field(alias="countryCode") + + class InfraProviderDto(BaseModel): uuid: UUID name: str @@ -12,17 +33,8 @@ class InfraProviderDto(BaseModel): login_url: Optional[str] = Field(None, alias="loginUrl") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") - - -class InfraBillingHistoryDto(BaseModel): - uuid: UUID - node_uuid: UUID = Field(alias="nodeUuid") - provider_uuid: UUID = Field(alias="providerUuid") - provider: InfraProviderDto - node: "NodeDto" - next_billing_at: datetime = Field(alias="nextBillingAt") - created_at: datetime = Field(alias="createdAt") - updated_at: datetime = Field(alias="updatedAt") + billing_history: InfraBillingHistoryStatsDto = Field(alias="billingHistory") + billing_nodes: List[InfraBillingNodeSimpleDto] = Field(alias="billingNodes") class NodeDto(BaseModel): @@ -31,17 +43,42 @@ class NodeDto(BaseModel): country_code: str = Field(alias="countryCode") +class InfraBillingHistoryDto(BaseModel): + uuid: UUID + node_uuid: UUID = Field(alias="nodeUuid") + provider_uuid: UUID = Field(alias="providerUuid") + amount: float + description: Optional[str] = None + payment_date: datetime = Field(alias="paymentDate") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + class InfraBillingNodeDto(BaseModel): uuid: UUID node_uuid: UUID = Field(alias="nodeUuid") provider_uuid: UUID = Field(alias="providerUuid") - provider: InfraProviderDto + provider: InfraProviderSimpleDto node: NodeDto next_billing_at: datetime = Field(alias="nextBillingAt") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") +class AvailableBillingNodeDto(BaseModel): + """Модель для доступных узлов биллинга""" + uuid: UUID + name: str + country_code: str = Field(alias="countryCode") + + +class BillingStatsDto(BaseModel): + """Статистика биллинга""" + upcoming_nodes_count: float = Field(alias="upcomingNodesCount") + current_month_payments: float = Field(alias="currentMonthPayments") + total_spent: float = Field(alias="totalSpent") + + # Provider models class CreateInfraProviderRequestDto(BaseModel): name: str @@ -65,11 +102,12 @@ class UpdateInfraProviderResponseDto(InfraProviderDto): class AllInfraProvidersData(BaseModel): - total: int + total: float = Field(alias="total") providers: List[InfraProviderDto] -class GetAllInfraProvidersResponseDto(AllInfraProvidersData): +# Исправленные имена моделей согласно OpenAPI +class GetInfraProvidersResponseDto(AllInfraProvidersData): pass @@ -77,22 +115,35 @@ class GetInfraProviderByUuidResponseDto(InfraProviderDto): pass -class DeleteInfraProviderResponseDto(BaseModel): +class DeleteInfraProviderByUuidResponseDto(BaseModel): is_deleted: bool = Field(alias="isDeleted") # Billing History models +class CreateInfraBillingHistoryRecordRequestDto(BaseModel): + """Модель для создания записи истории биллинга""" + node_uuid: UUID = Field(serialization_alias="nodeUuid") + provider_uuid: UUID = Field(serialization_alias="providerUuid") + amount: float + description: Optional[str] = None + payment_date: datetime = Field(serialization_alias="paymentDate") + + +class CreateInfraBillingHistoryRecordResponseDto(InfraBillingHistoryDto): + pass + + class InfraBillingHistoryData(BaseModel): records: List[InfraBillingHistoryDto] total: int -class GetAllInfraBillingHistoryResponseDto(InfraBillingHistoryData): +class GetInfraBillingHistoryRecordsResponseDto(InfraBillingHistoryData): pass -class GetInfraBillingHistoryByUuidResponseDto(InfraBillingHistoryDto): - pass +class DeleteInfraBillingHistoryRecordByUuidResponseDto(BaseModel): + is_deleted: bool = Field(alias="isDeleted") # Billing Nodes models @@ -102,15 +153,18 @@ class CreateInfraBillingNodeRequestDto(BaseModel): next_billing_at: datetime = Field(serialization_alias="nextBillingAt") -class CreateInfraBillingNodeResponseDto(InfraBillingNodeDto): - pass +# ИСПРАВЛЕНО: API возвращает список всех billing nodes после создания, а не один созданный +class CreateInfraBillingNodeResponseDto(BaseModel): + total_billing_nodes: float = Field(alias="totalBillingNodes") + billing_nodes: List[InfraBillingNodeDto] = Field(alias="billingNodes") + available_billing_nodes: List[AvailableBillingNodeDto] = Field(alias="availableBillingNodes") + total_available_billing_nodes: float = Field(alias="totalAvailableBillingNodes") + stats: BillingStatsDto class UpdateInfraBillingNodeRequestDto(BaseModel): - uuid: UUID - node_uuid: Optional[UUID] = Field(None, serialization_alias="nodeUuid") - provider_uuid: Optional[UUID] = Field(None, serialization_alias="providerUuid") - next_billing_at: Optional[datetime] = Field(None, serialization_alias="nextBillingAt") + uuids: List[UUID] + next_billing_at: datetime = Field(serialization_alias="nextBillingAt") class UpdateInfraBillingNodeResponseDto(InfraBillingNodeDto): @@ -118,19 +172,31 @@ class UpdateInfraBillingNodeResponseDto(InfraBillingNodeDto): class InfraBillingNodesData(BaseModel): - total_billing_nodes: int = Field(alias="totalBillingNodes") - total_active_nodes: Optional[int] = Field(None, alias="totalActiveNodes") - total_spent: Optional[int] = Field(None, alias="totalSpent") + total_billing_nodes: float = Field(alias="totalBillingNodes") billing_nodes: List[InfraBillingNodeDto] = Field(alias="billingNodes") + available_billing_nodes: List[AvailableBillingNodeDto] = Field(alias="availableBillingNodes") + total_available_billing_nodes: float = Field(alias="totalAvailableBillingNodes") + stats: BillingStatsDto -class GetAllInfraBillingNodesResponseDto(InfraBillingNodesData): +class GetInfraBillingNodesResponseDto(InfraBillingNodesData): pass -class GetInfraBillingNodeByUuidResponseDto(InfraBillingNodeDto): - pass +class DeleteInfraBillingNodeByUuidResponseDto(BaseModel): + """API возвращает обновленный список billing nodes после удаления""" + total_billing_nodes: float = Field(alias="totalBillingNodes") + billing_nodes: List[InfraBillingNodeDto] = Field(alias="billingNodes") + available_billing_nodes: List[AvailableBillingNodeDto] = Field(alias="availableBillingNodes") + total_available_billing_nodes: float = Field(alias="totalAvailableBillingNodes") + stats: BillingStatsDto -class DeleteInfraBillingNodeResponseDto(BaseModel): - is_deleted: bool = Field(alias="isDeleted") +# Legacy aliases для обратной совместимости +GetAllInfraProvidersResponseDto = GetInfraProvidersResponseDto +DeleteInfraProviderResponseDto = DeleteInfraProviderByUuidResponseDto +GetAllInfraBillingHistoryResponseDto = GetInfraBillingHistoryRecordsResponseDto +GetInfraBillingHistoryByUuidResponseDto = InfraBillingHistoryDto +GetAllInfraBillingNodesResponseDto = GetInfraBillingNodesResponseDto +GetInfraBillingNodeByUuidResponseDto = InfraBillingNodeDto +DeleteInfraBillingNodeResponseDto = DeleteInfraBillingNodeByUuidResponseDto \ No newline at end of file diff --git a/remnawave/models/webhook.py b/remnawave/models/webhook.py index 4f34b3a..9f5a26b 100644 --- a/remnawave/models/webhook.py +++ b/remnawave/models/webhook.py @@ -292,7 +292,8 @@ class WebhookPayloadDto(BaseModel): data = NodeDto(**data_raw) elif event.startswith("service."): if event.startswith("service.login_attempt"): - data = LoginAttemptDto(**data_raw) + login_attempt_data = data_raw.get("loginAttempt", {}) + data = LoginAttemptDto(**login_attempt_data) else: # service.panel_started - содержит пустой json data = data_raw elif event.startswith("errors."): diff --git a/tests/test_infra_billing.py b/tests/test_infra_billing.py index 416d602..db8a048 100644 --- a/tests/test_infra_billing.py +++ b/tests/test_infra_billing.py @@ -3,17 +3,18 @@ from datetime import datetime, timedelta import pytest from remnawave.models import ( + CreateInfraBillingHistoryRecordRequestDto, + CreateInfraBillingHistoryRecordResponseDto, CreateInfraBillingNodeRequestDto, CreateInfraBillingNodeResponseDto, CreateInfraProviderRequestDto, CreateInfraProviderResponseDto, - DeleteInfraBillingNodeResponseDto, - DeleteInfraProviderResponseDto, - GetAllInfraBillingHistoryResponseDto, - GetAllInfraBillingNodesResponseDto, - GetAllInfraProvidersResponseDto, - GetInfraBillingHistoryByUuidResponseDto, - GetInfraBillingNodeByUuidResponseDto, + DeleteInfraBillingHistoryRecordByUuidResponseDto, + DeleteInfraBillingNodeByUuidResponseDto, + DeleteInfraProviderByUuidResponseDto, + GetInfraBillingHistoryRecordsResponseDto, + GetInfraBillingNodesResponseDto, + GetInfraProvidersResponseDto, GetInfraProviderByUuidResponseDto, UpdateInfraBillingNodeRequestDto, UpdateInfraBillingNodeResponseDto, @@ -24,7 +25,8 @@ from tests.utils import generate_random_string @pytest.mark.asyncio -async def test_infra_billing(remnawave) -> None: +async def test_infra_billing_providers(remnawave) -> None: + """Test infra billing providers CRUD operations""" provider_name = f"test_provider_{generate_random_string(length=6)}" # Test create infra provider @@ -38,19 +40,26 @@ async def test_infra_billing(remnawave) -> None: assert isinstance(create_provider, CreateInfraProviderResponseDto) assert create_provider.name == provider_name + assert create_provider.favicon_link == "https://example.com/favicon.ico" + assert create_provider.login_url == "https://example.com/login" provider_uuid = str(create_provider.uuid) # Test get all infra providers - all_providers = await remnawave.infra_billing.get_all_infra_providers() - assert isinstance(all_providers, GetAllInfraProvidersResponseDto) + all_providers = await remnawave.infra_billing.get_infra_providers() + assert isinstance(all_providers, GetInfraProvidersResponseDto) assert all_providers.total > 0 assert len(all_providers.providers) > 0 + # Verify our provider is in the list + provider_found = any(p.uuid == create_provider.uuid for p in all_providers.providers) + assert provider_found + # Test get infra provider by uuid provider_by_uuid = await remnawave.infra_billing.get_infra_provider_by_uuid(provider_uuid) assert isinstance(provider_by_uuid, GetInfraProviderByUuidResponseDto) assert provider_by_uuid.name == provider_name + assert provider_by_uuid.uuid == create_provider.uuid # Test update infra provider updated_name = f"updated_{provider_name}" @@ -58,25 +67,159 @@ async def test_infra_billing(remnawave) -> None: UpdateInfraProviderRequestDto( uuid=create_provider.uuid, name=updated_name, - favicon_link="https://example.com/new-favicon.ico" + favicon_link="https://example.com/new-favicon.ico", + login_url="https://example.com/new-login" ) ) assert isinstance(update_provider, UpdateInfraProviderResponseDto) assert update_provider.name == updated_name - - # Test get all infra billing history - billing_history = await remnawave.infra_billing.get_all_infra_billing_history(start=0, size=25) - assert isinstance(billing_history, GetAllInfraBillingHistoryResponseDto) - - # Test get all infra billing nodes - billing_nodes = await remnawave.infra_billing.get_all_infra_billing_nodes(start=0, size=25) - assert isinstance(billing_nodes, GetAllInfraBillingNodesResponseDto) - - # Skip testing actual billing node creation/update/delete as it requires existing nodes - # These would need real node UUIDs which may not exist in test environment + assert update_provider.favicon_link == "https://example.com/new-favicon.ico" + assert update_provider.login_url == "https://example.com/new-login" # Test delete infra provider delete_provider = await remnawave.infra_billing.delete_infra_provider_by_uuid(provider_uuid) - assert isinstance(delete_provider, DeleteInfraProviderResponseDto) + assert isinstance(delete_provider, DeleteInfraProviderByUuidResponseDto) assert delete_provider.is_deleted is True + + +@pytest.mark.asyncio +async def test_infra_billing_history(remnawave) -> None: + """Test infra billing history operations""" + + # Test get all infra billing history + billing_history = await remnawave.infra_billing.get_infra_billing_history_records() + assert isinstance(billing_history, GetInfraBillingHistoryRecordsResponseDto) + assert hasattr(billing_history, 'records') + assert hasattr(billing_history, 'total') + + # Skip creating history record as it may require specific setup + print("Billing history operations tested successfully") + + +@pytest.mark.asyncio +async def test_infra_billing_nodes(remnawave) -> None: + """Test infra billing nodes operations""" + + # Test get all infra billing nodes + billing_nodes = await remnawave.infra_billing.get_billing_nodes() + assert isinstance(billing_nodes, GetInfraBillingNodesResponseDto) + assert hasattr(billing_nodes, 'total_billing_nodes') + assert hasattr(billing_nodes, 'billing_nodes') + assert hasattr(billing_nodes, 'available_billing_nodes') + assert hasattr(billing_nodes, 'total_available_billing_nodes') + assert hasattr(billing_nodes, 'stats') + + # Verify stats structure + assert hasattr(billing_nodes.stats, 'upcoming_nodes_count') + assert hasattr(billing_nodes.stats, 'current_month_payments') + assert hasattr(billing_nodes.stats, 'total_spent') + + # Test create billing node (only if we have providers and available nodes) + providers = await remnawave.infra_billing.get_infra_providers() + if (providers.total > 0 and len(providers.providers) > 0 and + billing_nodes.total_available_billing_nodes > 0 and + len(billing_nodes.available_billing_nodes) > 0): + + provider = providers.providers[0] + available_node = billing_nodes.available_billing_nodes[0] + next_billing = datetime.now() + timedelta(days=30) + + # Create billing node - API возвращает весь список узлов + create_billing_node = await remnawave.infra_billing.create_infra_billing_node( + CreateInfraBillingNodeRequestDto( + node_uuid=available_node.uuid, + provider_uuid=provider.uuid, + next_billing_at=next_billing + ) + ) + + assert isinstance(create_billing_node, CreateInfraBillingNodeResponseDto) + assert hasattr(create_billing_node, 'billing_nodes') + assert hasattr(create_billing_node, 'total_billing_nodes') + + # Find the created billing node + created_node = None + for node in create_billing_node.billing_nodes: + if node.node.uuid == available_node.uuid and node.provider.uuid == provider.uuid: + created_node = node + break + + assert created_node is not None, "Created billing node not found in response" + billing_node_uuid = str(created_node.uuid) + + # Test delete billing node - API возвращает обновленный список + delete_billing_node = await remnawave.infra_billing.delete_infra_billing_node_by_uuid(billing_node_uuid) + assert isinstance(delete_billing_node, DeleteInfraBillingNodeByUuidResponseDto) + assert hasattr(delete_billing_node, 'billing_nodes') + assert hasattr(delete_billing_node, 'total_billing_nodes') + + # Verify the node was deleted (not in the response list) + node_still_exists = any( + node.uuid == created_node.uuid + for node in delete_billing_node.billing_nodes + ) + assert not node_still_exists, "Billing node should be deleted but still found in response" + + +@pytest.mark.asyncio +async def test_infra_billing_complete_workflow(remnawave) -> None: + """Test complete workflow: create provider -> create billing node -> cleanup""" + provider_name = f"workflow_provider_{generate_random_string(length=6)}" + + # 1. Create provider + create_provider = await remnawave.infra_billing.create_infra_provider( + CreateInfraProviderRequestDto( + name=provider_name, + favicon_link="https://workflow.com/favicon.ico", + login_url="https://workflow.com/login" + ) + ) + + assert isinstance(create_provider, CreateInfraProviderResponseDto) + provider_uuid = str(create_provider.uuid) + + try: + # 2. Get available nodes + billing_nodes = await remnawave.infra_billing.get_billing_nodes() + + if (billing_nodes.total_available_billing_nodes > 0 and + len(billing_nodes.available_billing_nodes) > 0): + + available_node = billing_nodes.available_billing_nodes[0] + next_billing = datetime.now() + timedelta(days=30) + + # 3. Create billing node + create_billing_node = await remnawave.infra_billing.create_infra_billing_node( + CreateInfraBillingNodeRequestDto( + node_uuid=available_node.uuid, + provider_uuid=create_provider.uuid, + next_billing_at=next_billing + ) + ) + + # Find created node + created_node = None + for node in create_billing_node.billing_nodes: + if node.node.uuid == available_node.uuid and node.provider.uuid == create_provider.uuid: + created_node = node + break + + assert created_node is not None + billing_node_uuid = str(created_node.uuid) + + # 4. Cleanup billing node - API возвращает обновленный список + delete_billing_node = await remnawave.infra_billing.delete_infra_billing_node_by_uuid(billing_node_uuid) + assert isinstance(delete_billing_node, DeleteInfraBillingNodeByUuidResponseDto) + + # Verify deletion by checking the node is not in the list + node_still_exists = any( + node.uuid == created_node.uuid + for node in delete_billing_node.billing_nodes + ) + assert not node_still_exists, "Billing node should be deleted" + + finally: + # 5. Cleanup provider + delete_provider = await remnawave.infra_billing.delete_infra_provider_by_uuid(provider_uuid) + assert delete_provider.is_deleted is True \ No newline at end of file