Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api_testing_framework/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def fetch_spotify_token(client_id: str, client_secret: str) -> tuple[str, int]:
try:
err = resp.json()
except ValueError:
err - resp.text
err = resp.text
raise RuntimeError(f"Spotify token fetch failed ({resp.status_code}): {err!r}")
body = resp.json()
return body["access_token"], body["expires_in"]
200 changes: 88 additions & 112 deletions src/api_testing_framework/client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import json
import os
from typing import Any, Dict, Optional

import allure
import httpx
from httpx import Request, Response
from tenacity import (
retry,
retry_if_exception_type,
Expand Down Expand Up @@ -62,6 +63,43 @@ def _handle_response(self, response: httpx.Response) -> dict:
raise APIError(response.status_code, data.get("error", response.text), data)
return data

def _record_request(self, request: httpx.Request) -> None:
"""Store the outgoing Request object for later attachment."""
self._last_request = request

def _sanitize_payload(self, raw_text: str) -> tuple[str, Any]:
"""
Truncate and redact JSON paloads based on env settings
"""
max_chars = int(os.getenv("MAX_PAYLOAD_CHARS", "5120"))
redact_keys = set(
filter(None, os.getenv("REDACT_FIELDS", "access_token,password").split(","))
)

# Truncate
text = raw_text
if len(text) > max_chars:
text = text[:max_chars] + "\n\n<truncated>"

# Attempt JSON parse and redact
try:
parsed = json.loads(text)

def _red(o):
if isinstance(o, dict):
return {
k: ("***REDACTED***" if k in redact_keys else _red(v))
for k, v in o.items()
}
if isinstance(o, list):
return [_red(i) for i in o]
return o

sanitized = json.dumps(_red(parsed), indent=2)
return sanitized, allure.attachment_type.JSON
except Exception:
return text, allure.attachment_type.TEXT

def _attach_last_exchange_to_allure(self) -> None:
"""
Attach the most recent httpx.Request and httpx.Response
Expand All @@ -85,18 +123,12 @@ def _attach_last_exchange_to_allure(self) -> None:
)

# Attach request body
if request.content:
if self._last_request.content:
try:
body_text = request.content.decode("utf-8")
atype = (
allure.attachment_type.JSON
if body_text.strip().startswith("{")
else allure.attachment_type.TEXT
)
raw_body = self._last_request.content.decode("utf-8", errors="ignore")
except Exception:
body_text = "<binary_content>"
atype = allure.attachment_type.TEXT

raw_body = "<binary content>"
body_text, atype = self._sanitize_payload(raw_body)
allure.attach(body_text, name="Request Body", attachment_type=atype)

# Attach response status
Expand All @@ -116,37 +148,45 @@ def _attach_last_exchange_to_allure(self) -> None:
)

# Attach response body
response_text = response.text or ""
try:
import json

json.loads(response_text)
atype = allure.attachment_type.JSON
except Exception:
atype = allure.attachment_type.TEXT

raw_response = response.text or ""
response_text, atype = self._sanitize_payload(raw_response)
allure.attach(response_text, name="Response Body", attachment_type=atype)

def _record_request(self, request: httpx.Request) -> None:
"""Store the outgoing Request object for later attachment."""
self._last_request = request

@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(APIError),
)
def get(
self, path: str, params: Dict[str, Any] = None, *, attach: bool = False
def _request(
self,
method: str,
path: str,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
*,
attach: bool = False,
) -> dict:
"""
GET request; if attach=True, record & attach the request/response in Allure
Generic HTTP request handler with retry, token refresh, and Allure attachment.

Args:
method: HTTP method (GET, POST, PUT, DELETE, etc.)
path: URL path relative to base_url
params: Query parameters for the request
json: JSON body for the request
attach: If True, attach request/response to Allure report

Returns:
Parsed JSON response as dict

Raises:
APIError: If the response status indicates an error
"""
self._refresh_token_if_needed()

# Build request
request = self._client.build_request("GET", path, params=params)
# Build request with appropriate parameters
request = self._client.build_request(method, path, params=params, json=json)
if attach:
self._record_request(request)

Expand All @@ -168,100 +208,36 @@ def get(
self._attach_last_exchange_to_allure()
return data

@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(APIError),
)
def get(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
*,
attach: bool = False,
) -> dict:
"""
GET request; if attach=True, record & attach the request/response in Allure
"""
return self._request("GET", path, params=params, attach=attach)

def post(
self, path: str, json: Dict[str, Any] = None, *, attach: bool = False
self, path: str, json: Optional[Dict[str, Any]] = None, *, attach: bool = False
) -> dict:
"""
POST request; if attach=True, record & attach the request/response in Allure
"""
self._refresh_token_if_needed()

# Build request
request = self._client.build_request("POST", path, json=json)
if attach:
self._record_request(request)

# Send and record response
response = self._client.send(request)
if attach:
self._last_response = response

# Handle status; if errors, attach before raising
try:
data = self._handle_response(response)
except APIError:
if attach:
self._attach_last_exchange_to_allure()
raise

# On success, attach if requested
if attach:
self._attach_last_exchange_to_allure()
return data
return self._request("POST", path, json=json, attach=attach)

def put(
self, path: str, json: Dict[str, Any] = None, *, attach: bool = False
self, path: str, json: Optional[Dict[str, Any]] = None, *, attach: bool = False
) -> dict:
"""
PUT request; if attach=True, record & attach the request/response in Allure.
PUT request; if attach=True, record & attach the request/response in Allure
"""
self._refresh_token_if_needed()

# Build the request
request = self._client.build_request("PUT", path, json=json)
if attach:
self._record_request(request)

# Send and record response
response = self._client.send(request)
if attach:
self._last_response = response

# Handle status; if it errors, attach before raising
try:
data = self._handle_response(response)
except APIError:
if attach:
self._attach_last_exchange_to_allure()
raise

# On success, attach if requested
if attach:
self._attach_last_exchange_to_allure()
return data
return self._request("PUT", path, json=json, attach=attach)

def delete(self, path: str, *, attach: bool = False) -> dict:
"""
DELETE request; if attach=True, record & attach the request/response in Allure.
DELETE request; if attach=True, record & attach the request/response in Allure
"""

self._refresh_token_if_needed()

# Build request
request = self._client.build_request("DELETE", path)
if attach:
self._record_request(request)

# Send and record response
response = self._client.send(request)
if attach:
self._last_response = response

# Handle status; if it errors, attach before raising
try:
data = self._handle_response(response)
except APIError:
if attach:
self._attach_last_exchange_to_allure()
raise

# On success, attach if requested
if attach:
self._attach_last_exchange_to_allure()
return data
return self._request("DELETE", path, attach=attach)
23 changes: 12 additions & 11 deletions src/api_testing_framework/spotify/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@

import httpx

from src.api_testing_framework.auth import fetch_spotify_token
from src.api_testing_framework.client import APIClient
from src.api_testing_framework.config import get_settings
from src.api_testing_framework.spotify.models import (
NewReleasesResponse,
TopTracksResponse,
)
from api_testing_framework.auth import fetch_spotify_token
from api_testing_framework.client import APIClient
from api_testing_framework.config import get_settings
from api_testing_framework.spotify.models import NewReleasesResponse, TopTracksResponse


class SpotifyClient(APIClient):
Expand Down Expand Up @@ -53,18 +50,22 @@ def _refresh_token_if_needed(self):
self._token_expires_at = time.time() + expires_in - 10
self._client.headers["Authorization"] = f"Bearer {self._token}"

def get_new_releases(self, limit: int = 20) -> NewReleasesResponse:
def get_new_releases(
self, limit: int = 20, *, attach: bool = False
) -> NewReleasesResponse:
"""
Fetch new album releases from Spotify and return a validated model.
"""
raw = self.get(f"/browse/new-releases?limit={limit}", attach=False)
raw = self.get(f"/browse/new-releases?limit={limit}", attach=attach)
return NewReleasesResponse.model_validate(raw)

def get_artist_top_tracks(
self, artist_id: str, market: str = "US"
self, artist_id: str, market: str = "US", *, attach: bool = False
) -> TopTracksResponse:
"""
Fetch the top tracks for a given artist in the specified market.
"""
raw = self.get(f"/artists/{artist_id}/top-tracks?market={market}", attach=False)
raw = self.get(
f"/artists/{artist_id}/top-tracks?market={market}", attach=attach
)
return TopTracksResponse.model_validate(raw)
34 changes: 34 additions & 0 deletions tests/spotify/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import pytest

from api_testing_framework.config import get_settings
from api_testing_framework.spotify.client import SpotifyClient

CFG = get_settings()


@pytest.fixture(scope="session")
def spotify_client():
"""
Fixture for real SpotifyClient integration tests.
Skips if SPOTIFY_CLIENT_ID/SECRET are not set.
"""
if not CFG.spotify_client_id or not CFG.spotify_client_secret:
pytest.skip("Spotify credentials not set; skipping integration tests")
return SpotifyClient(
base_url=CFG.spotify_api_base_url,
)


@pytest.fixture
def api_client(request):
"""
Provide a SpotifyClient (or a generic APIClient subclass). If ATTACH_ON_FAILURE is set,
APIClient will record each exchane. We attach this instance to the test node so
a pytest hook can retrieve and attach exchanges only on failure.
"""

# cfg = get_settings()
client = SpotifyClient(base_url=CFG.spotify_api_base_url, token=None)

request.node._api_client = client
return client
23 changes: 23 additions & 0 deletions tests/spotify/test_integration_spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,26 @@ def test_spotify_artist_top_tracks():
client = SpotifyClient()
parsed = client.get_artist_top_tracks("3TVXtAsR1Inumwj472S9r4")
assert parsed.tracks


@pytest.mark.integration
def test_spotify_artist_top_tracks_w_attachments(spotify_client: SpotifyClient):
artist_id = "6eUKZXaKkcviH0Ku9w2n3V"
response = spotify_client.get_artist_top_tracks(artist_id=artist_id, attach=True)
assert response.tracks
tracks = response.tracks
assert isinstance(tracks, list) and len(tracks) > 0

for track in tracks:
assert track.id and track.name and isinstance(track.popularity, int)


@pytest.mark.integration
def test_spotify_new_releases_w_attachments(spotify_client: SpotifyClient):
response = spotify_client.get_new_releases(limit=3, attach=True)
assert response.albums.items
items = response.albums.items
assert isinstance(items, list) and len(items) == 3

for album in items:
assert album.id and album.name
Loading