diff --git a/.vscode/settings.json b/.vscode/settings.json index cbf7c33..8ab4316 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,4 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python-envs.defaultEnvManager": "ms-python.python:system", - "python-envs.pythonProjects": [], } diff --git a/akcli/api.py b/akcli/api.py index c6cc733..d721629 100644 --- a/akcli/api.py +++ b/akcli/api.py @@ -9,7 +9,7 @@ from functools import wraps from pathlib import Path from time import sleep -from typing import Any, Optional +from typing import Any, List, Optional import requests from akamai.edgegrid import EdgeGridAuth, EdgeRc @@ -29,7 +29,7 @@ ResourceNotFound, TooManyRequests, ) -from .models import DigResponse, TranslateResponse +from .models import DigResponse, PurgeResponse, TranslateResponse from .typing import Certificate, GenericFunction, Headers, JSONResponse from .utils import highlight @@ -193,29 +193,33 @@ def _request(self, method: str, endpoint: str, *args: Any, **kwargs: Any) -> JSO except Exception as e: raise RequestError(f"An error occurred while making the request: {e}") from e - def _get(self, endpoint: str, headers: Optional[Headers] = None) -> JSONResponse: + def _get(self, endpoint: str, headers: Optional[Headers] = None, use_cache: bool = True) -> JSONResponse: """ Make a GET request. """ - return self._request(method="GET", endpoint=endpoint, headers=headers) + return self._request(method="GET", endpoint=endpoint, headers=headers, use_cache=use_cache) - def _post(self, endpoint: str, json: dict, headers: Optional[Headers] = None) -> JSONResponse: + def _post( + self, endpoint: str, json: dict, headers: Optional[Headers] = None, use_cache: bool = True + ) -> JSONResponse: """ Make a POST request. """ - return self._request(method="POST", endpoint=endpoint, json=json, headers=headers) + return self._request(method="POST", endpoint=endpoint, json=json, headers=headers, use_cache=use_cache) - def _patch(self, endpoint: str, json: dict, headers: Optional[Headers] = None) -> JSONResponse: + def _patch( + self, endpoint: str, json: dict, headers: Optional[Headers] = None, use_cache: bool = True + ) -> JSONResponse: """ Make a PATCH request. """ - return self._request(method="PATCH", endpoint=endpoint, json=json, headers=headers) + return self._request(method="PATCH", endpoint=endpoint, json=json, headers=headers, use_cache=use_cache) - def _delete(self, endpoint: str, headers: Optional[Headers] = None) -> JSONResponse: + def _delete(self, endpoint: str, headers: Optional[Headers] = None, use_cache: bool = True) -> JSONResponse: """ Make a DELETE request. """ - return self._request(method="DELETE", endpoint=endpoint, headers=headers) + return self._request(method="DELETE", endpoint=endpoint, headers=headers, use_cache=use_cache) def dig(self, hostname: str, query_type: str) -> DigResponse: """ @@ -244,3 +248,16 @@ def translate(self, id: str, trace: bool) -> TranslateResponse: data = self._post(endpoint=endpoint, json=payload) return TranslateResponse.parse_model(data) + + def purge(self, method: str, network: str, purge_type: str, objects: List) -> PurgeResponse: + """ + Purge content given CP code, tag or URL/ARL. + Reference: https://techdocs.akamai.com/purge-cache/reference/ + """ + endpoint = f"/ccu/v3/{method}/{purge_type}/{network}" + payload = {"objects": objects} + + # Purge is a write operation — caching would silently skip the actual request + data = self._post(endpoint=endpoint, json=payload, use_cache=False) + + return PurgeResponse.parse_model(data) diff --git a/akcli/cache.py b/akcli/cache.py index 51dde72..5c98a69 100644 --- a/akcli/cache.py +++ b/akcli/cache.py @@ -157,9 +157,11 @@ def wrapper(self, method: str, endpoint: str, *args: Any, **kwargs: Any) -> Seri payload = kwargs.get("json") cache: Cache = self._cache key = cache.generate_key(method, endpoint, payload) - cached = cache.get(key) - if cached is not None and cache.use_cache: + + # Handle cases where wrapped function is passing use_cache + use_cache = kwargs.pop("use_cache", True) and cache.use_cache + if cached is not None and use_cache: return cached.data # If not cached, make request to API and store result diff --git a/akcli/commands/__init__.py b/akcli/commands/__init__.py index c2b439f..c9db648 100644 --- a/akcli/commands/__init__.py +++ b/akcli/commands/__init__.py @@ -7,9 +7,11 @@ import typer from .dig import app as dig_app +from .purge import app as purge_app from .translate import app as translate_app app = typer.Typer() app.add_typer(dig_app) app.add_typer(translate_app) +app.add_typer(purge_app) diff --git a/akcli/commands/_common.py b/akcli/commands/_common.py index 8103fa8..c5581a5 100644 --- a/akcli/commands/_common.py +++ b/akcli/commands/_common.py @@ -4,8 +4,6 @@ Common command arguments and options. """ -from typing import Optional - import typer from merge_args import merge_args from typing_extensions import Annotated @@ -24,7 +22,7 @@ def common_args( @merge_args(func) def wrapper( ctx: typer.Context, - json: Annotated[Optional[bool], typer.Option("--json", help="Output in JSON format.")] = None, + json: Annotated[bool, typer.Option(help="Print output in JSON format.")] = False, **kwargs, ) -> None: return func(ctx=ctx, **kwargs) diff --git a/akcli/commands/dig.py b/akcli/commands/dig.py index 18b820c..8b0737f 100755 --- a/akcli/commands/dig.py +++ b/akcli/commands/dig.py @@ -10,11 +10,12 @@ import typer from typing_extensions import Annotated -from akcli.commands._common import common_args from akcli.config import Config from akcli.exceptions import DigNoAnswerWarning, MutuallyExclusiveArgs, handle_exceptions from akcli.utils import create_table, highlight, print_json +from ._common import common_args + _COMMAND_NAME = "dig" _COLUMNS_HEADERS = [ diff --git a/akcli/commands/purge.py b/akcli/commands/purge.py new file mode 100755 index 0000000..9b992e1 --- /dev/null +++ b/akcli/commands/purge.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +""" +Tool to purge an object or list of objects on the Akamai Network. +""" + +from enum import Enum +from pathlib import Path +from typing import List, Optional + +import typer +from typing_extensions import Annotated + +from akcli.config import Config +from akcli.exceptions import InvalidParams, MutuallyExclusiveArgs, handle_exceptions +from akcli.utils import print_json, print_success + +from ._common import common_args + +_COMMAND_NAME = "purge" + +app = typer.Typer() +config = Config().purge + + +class _PurgeMethod(str, Enum): + """ + Supported purge methods. + """ + + invalidate = "invalidate" + delete = "delete" + + +class _PurgeNetwork(str, Enum): + """ + Supported networks. + """ + + staging = "staging" + production = "production" + + +class _PurgeType(str, Enum): + """ + Supported purge types. + """ + + tag = "tag" + url = "url" + cpcode = "cpcode" + + +def _validate_urls(urls: List[str]) -> None: + """Ensure that URLs start with http:// or https://""" + for u in urls: + if not u.startswith(("http://", "https://")): + raise InvalidParams( + f"Invalid URL [b]'{u}[/b]'. URLs/ARLs must include the protocol scheme (http:// or https://)." + ) + + +def _file_to_list(f: Path) -> List[str]: + """Parse the non-empty lines of a file into a list, stripping whitespace.""" + return [obj.strip() for obj in f.read_text().splitlines() if obj.strip()] + + +@app.command(name=_COMMAND_NAME) +@handle_exceptions() +@common_args +def purge( + ctx: typer.Context, + objects: Annotated[ + Optional[List[str]], typer.Argument(help="List with the objects to purge (CP codes, tags or URLs/ARLs).") + ] = None, + from_file: Annotated[ + Optional[Path], typer.Option(help="Path to a file with one object per line.", exists=True, file_okay=True) + ] = None, + method: Annotated[_PurgeMethod, typer.Option(help="Method used to purge.")] = config.method, # type: ignore + network: Annotated[_PurgeNetwork, typer.Option(help="Akamai network.")] = config.network, # type: ignore + purge_type: Annotated[_PurgeType, typer.Option(help="The type of objects to purge.")] = config.type, # type: ignore +) -> None: + """ + Tool to purge an object or list of objects on the Akamai Network. + """ + api = ctx.obj.api + console = ctx.obj.console + + # Validate args + if objects and from_file: + raise MutuallyExclusiveArgs("'--from-file' and positional arguments are mutually exclusive.") + + if not objects and not from_file: + raise InvalidParams("Provide either objects as positional arguments or use '--from-file'.") + + if from_file is not None: + objects = _file_to_list(from_file) + + # Raise error if URLs/ARLs don't start with 'http://' or 'https://' + if purge_type == _PurgeType.url: + _validate_urls(objects) # type: ignore + + response = api.purge(method, network, purge_type, objects) + + if ctx.params.get("json"): + print_json(console, response.model_dump(by_alias=True)) + raise typer.Exit() + + msg = ( + f"Your request will be completed in [b]{response.estimated_seconds}[/b] seconds.\n" + f"Purge ID: [u]{response.purge_id}[/u]" + ) + + print_success(console, msg) diff --git a/akcli/commands/translate.py b/akcli/commands/translate.py index bd18c66..9990d6a 100755 --- a/akcli/commands/translate.py +++ b/akcli/commands/translate.py @@ -11,7 +11,6 @@ from rich.table import Table from typing_extensions import Annotated -from akcli.commands._common import common_args from akcli.config import Config from akcli.exceptions import TranslateNoLogsWarning, handle_exceptions from akcli.typing import TableParams @@ -22,6 +21,8 @@ snakecase_to_title, ) +from ._common import common_args + _COMMAND_NAME = "translate" _COLUMNS_HEADERS = [ diff --git a/akcli/config.py b/akcli/config.py index c480fc7..c450756 100644 --- a/akcli/config.py +++ b/akcli/config.py @@ -45,6 +45,10 @@ _DEFAULT_TRANSLATE_TRACE = False +_DEFAULT_PURGE_METHOD = "invalidate" +_DEFAULT_PURGE_NETWORK = "staging" +_DEFAULT_PURGE_TYPE = "url" + MIN_REQUEST_TIMEOUT = 0 MAX_REQUEST_TIMEOUT = 120 @@ -93,6 +97,9 @@ class _MainOptions(_OptionsBase): proxy: Optional[str] = _DEFAULT_PROXY +"" + + @dataclass class _DigOptions(_OptionsBase): """ @@ -113,6 +120,17 @@ class _TranslateOptions(_OptionsBase): trace: bool = _DEFAULT_TRANSLATE_TRACE +@dataclass +class _PurgeOptions(_OptionsBase): + """ + Dataclass that contains all the options for the `akcli purge` command. + """ + + method: str = _DEFAULT_PURGE_METHOD + network: str = _DEFAULT_PURGE_NETWORK + type: str = _DEFAULT_PURGE_TYPE + + class Config: """ Handle configuration file loading and initialization and exposing all the config options. @@ -134,6 +152,7 @@ class Config: main: _MainOptions dig: _DigOptions translate: _TranslateOptions + purge: _PurgeOptions def __new__(cls, *args: Any, **kwargs: Any) -> "Config": """ diff --git a/akcli/exceptions.py b/akcli/exceptions.py index 8155651..8e5ce95 100644 --- a/akcli/exceptions.py +++ b/akcli/exceptions.py @@ -158,17 +158,24 @@ class InvalidEdgeRcSection(HandledException): class InvalidPanelType(HandledException): """Raised when an invalid panel type is used.""" - exit_code = 61 + exit_code = 71 default_msg = "Invalid panel type specified." class MutuallyExclusiveArgs(HandledException): """Raised if mutually exclusive arguments are used together.""" - exit_code = 62 + exit_code = 72 default_msg = "Mutually exclusive arguments." +class InvalidParams(HandledException): + """Raised when invalid parameters are passed to a command.""" + + exit_code = 73 + default_msg = "Invalid parameters." + + # ---------------------- # Warnings # ---------------------- diff --git a/akcli/models/__init__.py b/akcli/models/__init__.py index c04ed82..c6c29cc 100644 --- a/akcli/models/__init__.py +++ b/akcli/models/__init__.py @@ -5,9 +5,7 @@ """ from .dig_response import DigResponse +from .purge_response import PurgeResponse from .translate_response import TranslateResponse -__all__ = [ - "DigResponse", - "TranslateResponse", -] +__all__ = ["DigResponse", "PurgeResponse", "TranslateResponse"] diff --git a/akcli/models/purge_response.py b/akcli/models/purge_response.py new file mode 100644 index 0000000..36af1e5 --- /dev/null +++ b/akcli/models/purge_response.py @@ -0,0 +1,20 @@ +# /usr/bin/env python3 + +""" +Generated with `datamodel-code-generator` (https://github.com/koxudaxi/datamodel-code-generator) +and refined with manual adjustments. +""" + +from __future__ import annotations + +from typing import Optional + +from ._base import BaseAPIModel + + +class PurgeResponse(BaseAPIModel): + detail: Optional[str] = None + estimated_seconds: Optional[int] = None + http_status: Optional[int] = None + purge_id: Optional[str] = None + support_id: Optional[str] = None diff --git a/akcli/typing.py b/akcli/typing.py index 6456aa5..73da4c4 100644 --- a/akcli/typing.py +++ b/akcli/typing.py @@ -35,7 +35,7 @@ SerializedConfig = Dict[str, SerializedOptions] """Type alias representing the entire configuration file with all the options in serialized format.""" -PanelType = Literal["info", "result", "warning", "error"] +PanelType = Literal["info", "error", "warning", "result", "success"] """String literals representing valid panel types for rich panels.""" ColumnHeaders = List[_BaseDict] diff --git a/akcli/utils.py b/akcli/utils.py index f96c580..e657271 100644 --- a/akcli/utils.py +++ b/akcli/utils.py @@ -46,15 +46,14 @@ def _print_panel(console: Console, msg: str, panel_type: PanelType) -> None: "error": ("[red]Error[/red]", "red"), "warning": ("[yellow]Warning[/yellow]", "yellow"), "result": ("[blue]Result[/blue]", "blue"), + "success": ("[green]Success[/green]", "green"), } if panel_type not in styles: raise ValueError(f"Invalid panel type: {panel_type}") title, border_style = styles[panel_type] - console.print( - Panel.fit(msg, title=title, border_style=border_style, title_align="left") - ) + console.print(Panel.fit(msg, title=title, border_style=border_style, title_align="left")) def print_info(console: Console, msg: str) -> None: @@ -85,6 +84,13 @@ def print_result(console: Console, msg: str) -> None: _print_panel(console, msg, panel_type="result") +def print_success(console: Console, msg: str) -> None: + """ + Print a success in pretty format using rich.Panel. + """ + _print_panel(console, msg, panel_type="success") + + def print_json(console: Console, data: JSONResponse) -> None: """ Print data as pretty JSON to the console. diff --git a/gen-model.sh b/gen-model.sh index a7cc51c..c55921d 100755 --- a/gen-model.sh +++ b/gen-model.sh @@ -12,7 +12,7 @@ ERR_EXIT=1 PROG_NAME=$(basename "$0") MODELS_DIR="${PWD}/akcli/models/" OUTPUT_MODEL_TYPE="pydantic_v2.BaseModel" -BASE_CLASS=".base_response.BaseAPIModel" +BASE_CLASS="._base.BaseAPIModel" PYTHON_VERSION="3.9" diff --git a/tests/conftest.py b/tests/conftest.py index c4ba047..595acbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,7 @@ os.environ["NO_COLOR"] = "1" os.environ["TERM"] = "dumb" + # ----------------------- # General fixtures # ----------------------- diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 209fa8a..c3a2cbf 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -17,6 +17,9 @@ INVALID_ACCESS_TOKEN, INVALID_CLIENT_TOKEN, PRIV_KEY, + PURGE_BAD_REQUEST_RESPONSE, + PURGE_EXPECTED_PURGE_ID, + PURGE_SUCCESS_RESPONSE, TRANSLATE_30X_CODES_RESPONSE, TRANSLATE_30X_ID, TRANSLATE_BAD_REQUEST_ID, diff --git a/tests/fixtures/constants.py b/tests/fixtures/constants.py index 0bdc5cc..084b9f6 100644 --- a/tests/fixtures/constants.py +++ b/tests/fixtures/constants.py @@ -11,21 +11,23 @@ # ---------------------- _ROOT = Path(__file__).parent.resolve() / "data" +CERT = _ROOT / "server-cert.crt" +PRIV_KEY = _ROOT / "server-key.key" + EDGERC_TEMPLATE = _ROOT / "edgerc.template" DIG_SUCCESS_RESPONSE = _ROOT / "dig_success_response.json" DIG_NO_RECORDS_RESPONSE = _ROOT / "dig_no_records_response.json" + TRANSLATE_PENDING_RESPONSE = _ROOT / "translate_pending_response.json" TRANSLATE_PENDING_30X_RESPONSE = _ROOT / "translate_pending_30.x_codes_response.json" -TRANSLATE_PENDING_NON_30X_RESPONSE = ( - _ROOT / "translate_pending_non_30.x_codes_response.json" -) +TRANSLATE_PENDING_NON_30X_RESPONSE = _ROOT / "translate_pending_non_30.x_codes_response.json" TRANSLATE_PENDING_NO_LOGS_RESPONSE = _ROOT / "translate_pending_no_logs_response.json" TRANSLATE_30X_CODES_RESPONSE = _ROOT / "translate_30.x_codes_response.json" TRANSLATE_NON_30X_CODES_RESPONSE = _ROOT / "translate_non_30.x_codes_response.json" TRANSLATE_NO_LOGS_RESPONSE = _ROOT / "translate_no_logs_response.json" -CERT = _ROOT / "server-cert.crt" -PRIV_KEY = _ROOT / "server-key.key" +PURGE_SUCCESS_RESPONSE = _ROOT / "purge_success_response.json" +PURGE_BAD_REQUEST_RESPONSE = _ROOT / "purge_bad_request_response.json" # ---------------------- # Values @@ -44,3 +46,6 @@ TRANSLATE_NON_30X_ID = "successful-non-30x" TRANSLATE_NO_LOGS_ID = "successful-no-logs" TRANSLATE_BAD_REQUEST_ID = "bad-request" + +PURGE_EXPECTED_PURGE_ID = "edcp-NZbXzFpHBjcJeryhw6PVgG" +PURGE_BAD_REQUEST_TRIGGER = "bad-request-trigger" diff --git a/tests/fixtures/data/purge_bad_request_response.json b/tests/fixtures/data/purge_bad_request_response.json new file mode 100644 index 0000000..05a2b83 --- /dev/null +++ b/tests/fixtures/data/purge_bad_request_response.json @@ -0,0 +1,7 @@ +{ + "describedBy": "https://techdocs.akamai.com/purge-cache/reference/api-errors", + "detail": "Invalid or malformed hostname", + "httpStatus": 400, + "supportId": "edcp-NZbXzFpHBjcJeryhw6PVgG", + "title": "bad URL hostname" +} diff --git a/tests/fixtures/data/purge_success_response.json b/tests/fixtures/data/purge_success_response.json new file mode 100644 index 0000000..b4e4ee0 --- /dev/null +++ b/tests/fixtures/data/purge_success_response.json @@ -0,0 +1,7 @@ +{ + "detail": "Request accepted", + "estimatedSeconds": 5, + "httpStatus": 201, + "purgeId": "edcp-NZbXzFpHBjcJeryhw6PVgG", + "supportId": "edcp-NZbXzFpHBjcJeryhw6PVgG" +} diff --git a/tests/fixtures/http_server.py b/tests/fixtures/http_server.py index ad66ac8..56714ee 100644 --- a/tests/fixtures/http_server.py +++ b/tests/fixtures/http_server.py @@ -17,7 +17,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from time import sleep -from typing import Any, Callable, ClassVar, Dict, Generator, Literal, Type +from typing import Any, Callable, ClassVar, Dict, Generator, Literal, Optional, Type from urllib.parse import urlparse from .constants import ( @@ -29,6 +29,8 @@ DIG_TIMEOUT_HOSTNAME, DIG_VALID_HOSTNAME, PRIV_KEY, + PURGE_BAD_REQUEST_TRIGGER, + PURGE_SUCCESS_RESPONSE, TRANSLATE_30X_CODES_RESPONSE, TRANSLATE_30X_ID, TRANSLATE_BAD_REQUEST_ID, @@ -76,13 +78,24 @@ def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: return wrapper - def _resolve_handler(self) -> Callable[..., Any]: + def _resolve_handler(self) -> Optional[Callable[..., Any]]: """ Resolve the handler function based on the request method and path. + Supports exact matches and prefix matches for dynamic endpoints. """ endpoint = urlparse(self.path).path method = self.command - return self.handlers[method].get(endpoint) # type: ignore + + # Exact match first + if endpoint in self.handlers[method]: + return self.handlers[method][endpoint] + + # Prefix match for dynamic endpoints + for registered, func in self.handlers[method].items(): + if endpoint.startswith(registered): + return func + + return None def _do_generic(self) -> Any: """ @@ -287,6 +300,23 @@ def get_translate_response_no_logs(handler: _HTTPRequestHandler) -> None: return handler.send_ok(sent_data) +@_HTTPRequestHandler.endpoint("POST", "/ccu/v3") +def post_purge_invalidate_url(handler: _HTTPRequestHandler) -> None: + """ + Generic endpoint for all the purge calls. This endpoint covers the endpoints: + - /ccu/v3/invalidate/url/staging + - /ccu/v3/invalidate/url/production + and so on. + """ + data = handler.get_post_data() + + if PURGE_BAD_REQUEST_TRIGGER in data.get("objects", []): + return handler.send_bad_request() + + sent_data = _parse_file_into_bytes(PURGE_SUCCESS_RESPONSE) + return handler.send_ok(sent_data) + + # ----------------------- # Server context manager # ----------------------- diff --git a/tests/integration/test_purge.py b/tests/integration/test_purge.py new file mode 100644 index 0000000..60cdca9 --- /dev/null +++ b/tests/integration/test_purge.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 + +""" +Suite of integration tests for the `purge` subcommand. +""" + +from itertools import product + +import pytest + +from akcli.main import app +from tests.fixtures.constants import PURGE_BAD_REQUEST_TRIGGER, PURGE_EXPECTED_PURGE_ID + +# ---------------------- +# Fixtures +# ---------------------- + + +@pytest.fixture +def dummy_file(tmp_path): + dummy_file = tmp_path / "objects.txt" + dummy_file.write_text("https://example.com/image.jpg\nhttps://example.com/image2.jpg\n") + return dummy_file + + +@pytest.fixture +def dummy_valid_url(): + return "https://example.com/image.jpg" + + +# ---------------------- +# Tests +# ---------------------- + + +def test_purge_fails_when_objects_and_from_file_passed_together(dummy_file, edgerc, cache_dir, tmp_path, runner): + """ + Test `purge` command fails when mutually exclusive params are passed. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + "dummy_object", + "--from-file", + dummy_file, + ] + + result = runner.invoke(app, cmd) + + assert result.exit_code != 0 + assert "MutuallyExclusiveArgs" in result.output + + +def test_purge_fails_when_no_objects_and_no_from_file(edgerc, cache_dir, runner): + """ + Test `purge` command fails when neither objects nor --from-file are provided. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + ] + result = runner.invoke(app, cmd) + assert result.exit_code != 0 + assert "InvalidParams" in result.output + + +def test_purge_fails_when_url_missing_scheme(edgerc, cache_dir, runner): + """ + Test `purge` command fails when a URL is passed without http:// or https://. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + "example.com", + ] + result = runner.invoke(app, cmd) + assert result.exit_code != 0 + assert "InvalidParams" in result.output + + +def test_purge_fails_when_from_file_url_missing_scheme(edgerc, cache_dir, tmp_path, runner): + """ + Test `purge` command fails when a file contains a URL without http:// or https://. + """ + f = tmp_path / "objects.txt" + f.write_text("not-valid.com\nhttps://valid.com\n") + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + "--from-file", + f, + ] + result = runner.invoke(app, cmd) + assert result.exit_code != 0 + assert "InvalidParams" in result.output + + +@pytest.mark.parametrize( + "method, purge_type, network", + list(product(["invalidate", "delete"], ["url", "tag", "cpcode"], ["production", "staging"])), +) +def test_purge_invalidate_url_staging( + https_server, edgerc, cache_dir, runner, dummy_valid_url, method, purge_type, network +): + """ + Test `purge` command succeeds for all combinations of --method, --purge-type and --network. + Covers 12 combinations: 2 methods x 3 types x 2 networks. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + dummy_valid_url, + "--method", + method, + "--purge-type", + purge_type, + "--network", + network, + ] + result = runner.invoke(app, cmd) + assert result.exit_code == 0 + assert PURGE_EXPECTED_PURGE_ID in result.output + + +def test_purge_from_file(https_server, edgerc, cache_dir, tmp_path, runner, dummy_file): + """ + Test `purge` command succeeds when objects are passed via --from-file. + """ + f = tmp_path / "objects.txt" + f.write_text("https://example.com/image.jpg\nhttps://example.com/image2.jpg\n") + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + "--from-file", + f, + "--purge-type", + "url", + ] + result = runner.invoke(app, cmd) + assert result.exit_code == 0 + assert PURGE_EXPECTED_PURGE_ID in result.output + + +def test_purge_json_output(https_server, edgerc, cache_dir, runner, dummy_valid_url): + """ + Test `purge` command outputs valid JSON when --json flag is passed. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + dummy_valid_url, + "--purge-type", + "url", + "--json", + ] + result = runner.invoke(app, cmd) + assert result.exit_code == 0 + assert PURGE_EXPECTED_PURGE_ID in result.output + assert "detail" in result.output + assert "detail" in result.output + assert "estimatedSeconds" in result.output + assert "httpStatus" in result.output + assert "supportId" in result.output + + +def test_purge_bad_request(https_server, edgerc, cache_dir, runner): + """ + Test `purge` command fails with BadRequest when the API returns a 400. + """ + cmd = [ + "--cache-dir", + cache_dir, + "--edgerc", + edgerc, + "--no-validate-certs", + "purge", + PURGE_BAD_REQUEST_TRIGGER, + "--purge-type", + "cpcode", + ] + result = runner.invoke(app, cmd) + assert result.exit_code != 0 + assert "BadRequest" in result.output diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 7e5ef68..b2102bb 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -233,22 +233,16 @@ def test_session_configuration(edgerc_path, section, mock_cache, proxy, verify, ) ), ) -def test_request_call_with_expected_params( - base_url, timeout, mock_api, method, endpoint, json, headers -): +def test_request_call_with_expected_params(base_url, timeout, mock_api, method, endpoint, json, headers): """ Test that `_request` method calls `Session.request` with expected parameters. """ url = base_url + endpoint # Call the method avoiding decorators - mock_api._request.__wrapped__( - mock_api, method, endpoint, json=json, headers=headers - ) + mock_api._request.__wrapped__(mock_api, method, endpoint, json=json, headers=headers) - mock_api._session.request.assert_called_once_with( - method, url, timeout=timeout, json=json, headers=headers - ) + mock_api._session.request.assert_called_once_with(method, url, timeout=timeout, json=json, headers=headers) def test_request_returns_json_on_200(mock_api): @@ -291,9 +285,7 @@ def _call_request(mock_api, method, endpoint, status_code): (429, TooManyRequests), ], ) -def test_request_raises_exception_on_non_200( - mock_api, method, endpoint, status_code, raised_exception -): +def test_request_raises_exception_on_non_200(mock_api, method, endpoint, status_code, raised_exception): """ Test that `_request` method raises an exception on non-200 status codes. """ @@ -302,9 +294,7 @@ def test_request_raises_exception_on_non_200( @pytest.mark.parametrize("status_code", [499, 502, 503, 504]) -def test_request_raises_exception_on_unhandled_status_code( - mock_api, method, endpoint, status_code -): +def test_request_raises_exception_on_unhandled_status_code(mock_api, method, endpoint, status_code): """ Test that `_request` method raises `RequestError` on unhandled status codes. """ @@ -333,22 +323,23 @@ def test_request_raises_exception_on_timeout_proxy_or_unhandled_error( @pytest.mark.parametrize( - "func_name, method, json, headers", + "func_name, method, json, headers, use_cache", [ - ("_get", "GET", None, None), - ("_delete", "DELETE", None, None), - ("_post", "POST", {"key": "value"}, {"some": "header"}), - ("_patch", "PATCH", {"key": "value"}, {"other": "header"}), + ("_get", "GET", None, None, False), + ("_delete", "DELETE", None, None, True), + ("_post", "POST", {"key": "value"}, {"some": "header"}, True), + ("_patch", "PATCH", {"key": "value"}, {"other": "header"}, False), ], ) def test_request_wrappers_call_request_with_expected_params( - mock_api, func_name, method, json, headers, endpoint + mock_api, func_name, method, json, headers, endpoint, use_cache ): """ Test that `_get/_post/_patch/_delete` methods call `_request` with correct params. """ kwargs = {"json": json} if json is not None else {} kwargs["headers"] = headers + kwargs["use_cache"] = use_cache with patch.object(mock_api, "_request", return_value={"ok": True}) as mock_request: func = getattr(mock_api, func_name) @@ -365,9 +356,7 @@ def test_poll_if_needed_raises_exception_when_exceed_maximum_retries(method, end """ pending_response = {"executionStatus": "IN_PROGRESS"} - mock_request = MagicMock( - return_value=pending_response - ) # Always return pending response + mock_request = MagicMock(return_value=pending_response) # Always return pending response decorated_func = _poll_if_needed(mock_request) with ( @@ -381,9 +370,7 @@ def test_poll_if_needed_raises_exception_when_exceed_maximum_retries(method, end assert mock_request.call_count == _MAX_POLLING_ATTEMPTS -def test_poll_changes_params_in_subsequent_cycles_and_returns_expected_when_over( - endpoint, method_post, payload -): +def test_poll_changes_params_in_subsequent_cycles_and_returns_expected_when_over(endpoint, method_post, payload): """ Test that `_poll_if_needed` changes method and endpoint in subsequent polling cycles, calls sleep with `retryAfter` value and returns the result when poll is over. @@ -395,17 +382,13 @@ def test_poll_changes_params_in_subsequent_cycles_and_returns_expected_when_over } completed_response = {"executionStatus": "COMPLETED", "data": {"result": "ok"}} - mock_request = MagicMock( - side_effect=[pending_response, pending_response, completed_response] - ) + mock_request = MagicMock(side_effect=[pending_response, pending_response, completed_response]) decorated_func = _poll_if_needed(mock_request) with patch("akcli.api.sleep") as mock_sleep: result = decorated_func(method=method_post, endpoint=endpoint, json=payload) - mock_sleep.assert_called_with( - pending_response.get("retryAfter") - ) # Ensure sleep is called with retryAfter value + mock_sleep.assert_called_with(pending_response.get("retryAfter")) # Ensure sleep is called with retryAfter value assert result == completed_response assert mock_request.call_count == 3 @@ -422,9 +405,7 @@ def test_poll_changes_params_in_subsequent_cycles_and_returns_expected_when_over } # Subsequent calls should be identical to the previous polling call - assert ( - mock_request.call_args_list[2].kwargs == mock_request.call_args_list[1].kwargs - ) + assert mock_request.call_args_list[2].kwargs == mock_request.call_args_list[1].kwargs @pytest.mark.parametrize( diff --git a/tests/unit/test_build_table_recursive.py b/tests/unit/test_build_table_recursive.py index 4fece28..f166a97 100644 --- a/tests/unit/test_build_table_recursive.py +++ b/tests/unit/test_build_table_recursive.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Unit tests for the `_build_table_recursive` function in the `translate` subcommand. +Suite of tests for the `_build_table_recursive` function in the `translate` subcommand. This function recursively builds a Rich table from a dictionary, creating subtables for nested dictionaries. These tests verify the table structure and cell content diff --git a/tests/unit/test_purge_functions.py b/tests/unit/test_purge_functions.py new file mode 100644 index 0000000..f6175ca --- /dev/null +++ b/tests/unit/test_purge_functions.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +""" +Suite of tests for the `purge` command functions. +""" + +import pytest + +from akcli.commands.purge import _file_to_list, _validate_urls +from akcli.exceptions import InvalidParams + +# ---------------------- +# Fixtures +# ---------------------- + + +@pytest.fixture +def dummy_file(tmp_path): + """Empty table to use as the main table in tests.""" + return tmp_path / "objects.txt" + + +# ---------------------- +# Tests +# ---------------------- + + +def test_validate_urls_dont_raise_error_on_valid_urls(): + """ + Test that the function doesn't raise an error when the string starts with http:// and https:// + """ + urls = ["https://example.com", "http://dummy.com"] + _validate_urls(urls) + + +def test_validate_urls_raises_on_missing_scheme(): + """ + Test that the function raises InvalidParams when a URL doesn't include the protocol scheme. + """ + with pytest.raises(InvalidParams): + _validate_urls(["example.com"]) + + +def test_validate_urls_raises_on_first_invalid_in_mixed_list(): + """ + Test that the function raises InvalidParams on the first invalid URL in a mixed list. + """ + with pytest.raises(InvalidParams): + _validate_urls(["https://valid.com", "invalid.com", "https://also-valid.com"]) + + +def test_validate_urls_raises_on_partial_scheme(): + """ + Test that the function raises InvalidParams when the URL has a partial or malformed scheme. + """ + with pytest.raises(InvalidParams): + _validate_urls(["htp://example.com"]) + + +def test_file_to_list_returns_correct_list(dummy_file): + """ + Test that the function returns a correct list from a file with normal lines. + """ + dummy_file.write_text("https://example.com\nhttps://dummy.com\n") + assert _file_to_list(dummy_file) == ["https://example.com", "https://dummy.com"] + + +def test_file_to_list_discards_empty_lines(dummy_file): + """ + Test that the function discards empty lines from the file. + """ + dummy_file.write_text("https://example.com\n\nhttps://dummy.com\n") + assert _file_to_list(dummy_file) == ["https://example.com", "https://dummy.com"] + + +def test_file_to_list_strips_whitespace(dummy_file): + """ + Test that the function strips leading and trailing whitespace from each line. + """ + dummy_file.write_text(" https://example.com \n https://dummy.com \n") + assert _file_to_list(dummy_file) == ["https://example.com", "https://dummy.com"] + + +def test_file_to_list_returns_empty_list_on_empty_file(dummy_file): + """ + Test that the function returns an empty list when the file is empty. + """ + dummy_file.write_text("") + assert _file_to_list(dummy_file) == []