diff --git a/google_health_api/api.py b/google_health_api/api.py index 67dfed6..decfc64 100644 --- a/google_health_api/api.py +++ b/google_health_api/api.py @@ -1258,9 +1258,15 @@ class GoogleHealthApi: operations: OperationsSubApi """Namespaced client for retrieving and waiting on long-running operations.""" - def __init__(self, auth: AbstractAuth) -> None: + def __init__( + self, + auth: AbstractAuth, + session: GoogleHealthSession | None = None, + ) -> None: """Initialize the client.""" - self._session = GoogleHealthSession(auth, auth._websession, auth._host) + self._session = session or GoogleHealthSession( + auth, auth._websession, auth._host + ) self.steps = RollupDataPointSubApi(self._session, STEPS) self.heart_rate = RollupDataPointSubApi(self._session, HEART_RATE) self.sleep = DataPointSubApi(self._session, SLEEP) @@ -1428,6 +1434,6 @@ async def get_irn_profile(self, user: str = "me") -> IrnProfile: async def get_user_info(self) -> UserInfo: """Retrieve the authenticated user's Google OAuth2 userinfo.""" - resp = await self._session.get("https://www.googleapis.com/oauth2/v3/userinfo") + resp = await self._session.get(self._session.user_info_url) raw_json = await resp.json() return UserInfo.from_dict(raw_json) diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index 38c1b3c..0925a56 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -168,40 +168,13 @@ async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi: else: auth = CredentialsAuth(session, auth_data[1], host=host) - api = GoogleHealthApi(auth) - # Inject CliHealthSession - api._session = CliHealthSession(auth, session, host) - # Update nested api classes with the new session - api.steps._session = api._session - api.heart_rate._session = api._session - api.sleep._session = api._session - api.distance._session = api._session - api.basal_energy_burned._session = api._session - api.vo2_max._session = api._session - api.weight._session = api._session - api.height._session = api._session - api.oxygen_saturation._session = api._session - api.daily_oxygen_saturation._session = api._session - api.exercise._session = api._session - api.electrocardiogram._session = api._session - api.irregular_rhythm_notification._session = api._session - api.daily_vo2_max._session = api._session - api.daily_heart_rate_zones._session = api._session - api.daily_sleep_temperature_derivations._session = api._session - api.daily_respiratory_rate._session = api._session - api.respiratory_rate_sleep_summary._session = api._session - api.active_energy_burned._session = api._session - api.total_calories._session = api._session - api.floors._session = api._session - api.hydration_log._session = api._session - api.daily_resting_heart_rate._session = api._session - api.heart_rate_variability._session = api._session - api.daily_heart_rate_variability._session = api._session - api.nutrition_log._session = api._session - api.paired_devices._session = api._session - api.subscribers._session = api._session - api.subscribers.subscriptions._session = api._session - return api + cli_session = CliHealthSession( + auth, + session, + host, + user_info_url=os.environ.get("GOOGLE_HEALTH_USERINFO_URL"), + ) + return GoogleHealthApi(auth, session=cli_session) async def handle_datatype_cmd( diff --git a/google_health_api/cli/main.py b/google_health_api/cli/main.py index b862776..3aac999 100644 --- a/google_health_api/cli/main.py +++ b/google_health_api/cli/main.py @@ -93,8 +93,8 @@ def add_standard_datapoint_commands( ) -def main() -> None: - """CLI parser setup and subcommand routing.""" +def build_parser() -> argparse.ArgumentParser: + """Build the command line argument parser.""" parser = argparse.ArgumentParser( description="Google Health API CLI tool revamped with Agent DX principles." ) @@ -517,7 +517,12 @@ def main() -> None: subscriptions_delete.add_argument( "name", type=str, help="The subscription resource name" ) + return parser + +def main() -> None: + """CLI entrypoint and subcommand routing.""" + parser = build_parser() args = parser.parse_args() if args.command == "login": diff --git a/google_health_api/cli/utils.py b/google_health_api/cli/utils.py index 12fc1b0..f2353f1 100644 --- a/google_health_api/cli/utils.py +++ b/google_health_api/cli/utils.py @@ -4,6 +4,7 @@ import sys from typing import Any, NoReturn +from google_health_api.api import PendingOperation from google_health_api.model import DataPoint, ReconciledDataPoint @@ -50,6 +51,9 @@ def serialize_reconciled_datapoint( def serialize_response(result: Any, field_name: str | None = None) -> Any: """Convert API response object/paginated result to JSON-serializable structure.""" + if isinstance(result, PendingOperation): + result = result.operation + if hasattr(result, "to_dict"): return result.to_dict() if hasattr(result, "data_points"): diff --git a/google_health_api/client.py b/google_health_api/client.py index d12d175..5041ed4 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -8,7 +8,7 @@ from aiohttp.client_exceptions import ClientError from .auth import AbstractAuth -from .const import HEALTH_API_URL +from .const import HEALTH_API_URL, USERINFO_API_URL from .exceptions import ( HealthApiException, HealthApiForbiddenException, @@ -28,12 +28,14 @@ def __init__( auth: AbstractAuth, websession: aiohttp.ClientSession, host: str | None = None, + user_info_url: str | None = None, ) -> None: """Initialize the session.""" self._auth = auth self._websession = websession self._host = host or HEALTH_API_URL self._timezone_cache: dict[str, str] = {} + self.user_info_url = user_info_url or USERINFO_API_URL async def request( self, diff --git a/google_health_api/const.py b/google_health_api/const.py index 1890d4d..888bd2e 100644 --- a/google_health_api/const.py +++ b/google_health_api/const.py @@ -1,6 +1,7 @@ """Constants for the Google Health API.""" HEALTH_API_URL = "https://health.googleapis.com" +USERINFO_API_URL = "https://www.googleapis.com/oauth2/v3/userinfo" class HealthApiScope: diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index b277e94..76b3cea 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -1,12 +1,17 @@ """Shared pytest fixtures for CLI tests.""" +import os import sys -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator +from typing import Any from unittest.mock import MagicMock, patch +import aiohttp import pytest -from google_health_api.cli.main import main +from google_health_api.cli.commands import async_run_cmd +from google_health_api.cli.main import build_parser, main +from tests.conftest import PATH_PREFIX, AuthCallback def run_cli(args: list[str]) -> None: @@ -15,6 +20,13 @@ def run_cli(args: list[str]) -> None: main() +async def async_run_cli(args: list[str]) -> None: + """Helper to run the CLI asynchronously within a running loop.""" + parser = build_parser() + parsed_args = parser.parse_args(args) + await async_run_cmd(parsed_args) + + @pytest.fixture def mock_load_credentials() -> Generator[MagicMock]: """Fixture to mock load_credentials_or_env.""" @@ -41,3 +53,41 @@ def mock_installed_flow_cls() -> Generator[MagicMock]: """Fixture to mock InstalledAppFlow in login subcommand.""" with patch("google_health_api.cli.subcommands.login.InstalledAppFlow") as mock: yield mock + + +@pytest.fixture +def run_cli_against_server( + auth_cb: AuthCallback, +) -> Callable[[list[str], list[tuple[str, str, Any]]], Awaitable[None]]: + """Fixture to run the CLI against a mock server with register endpoints.""" + + async def run( + args: list[str], + handlers: list[ + tuple[ + str, + str, + Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], + ] + ], + ) -> None: + auth = await auth_cb(handlers) + server_url = str(auth._websession.make_url(PATH_PREFIX)) + + # Direct setup_client to the mock server and stub credentials + with ( + patch.dict( + os.environ, + { + "GOOGLE_HEALTH_API_URL": server_url, + "GOOGLE_HEALTH_USERINFO_URL": f"{server_url}/oauth2/v3/userinfo", + }, + ), + patch( + "google_health_api.cli.commands.load_credentials_or_env", + return_value=("env", "fake-token"), + ), + ): + await async_run_cli(args) + + return run diff --git a/tests/cli/subcommands/test_devices.py b/tests/cli/subcommands/test_devices.py index d09e3ba..0e4e5d7 100644 --- a/tests/cli/subcommands/test_devices.py +++ b/tests/cli/subcommands/test_devices.py @@ -1,38 +1,44 @@ """Tests for devices CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_devices_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_devices_commands( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test devices CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_devices_api = AsyncMock() - mock_api.paired_devices = mock_devices_api - - # devices list - mock_dev_res = MagicMock(spec=["to_dict"]) - mock_dev_res.to_dict.return_value = {"pairedDevices": []} - mock_devices_api.list.return_value = mock_dev_res - run_cli(["devices", "list"]) - mock_devices_api.list.assert_called_once() - capsys.readouterr() - - # devices get - mock_dev = MagicMock(spec=["to_dict"]) - mock_dev.to_dict.return_value = {"id": "dev123"} - mock_devices_api.get.return_value = mock_dev - run_cli(["devices", "get", "dev123"]) + device_data = { + "name": "projects/me/pairedDevices/dev123", + "deviceType": "WATCH", + } + + async def list_devices_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response({"pairedDevices": [device_data]}) + + async def get_device_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response(device_data) + + # test devices list + await run_cli_against_server( + ["devices", "list"], + [("GET", "v4/users/me/pairedDevices", list_devices_handler)], + ) + captured = capsys.readouterr() + assert "WATCH" in captured.out + + # test devices get + await run_cli_against_server( + ["devices", "get", "dev123"], + [("GET", "v4/users/me/pairedDevices/dev123", get_device_handler)], + ) captured = capsys.readouterr() - assert json.loads(captured.out)["id"] == "dev123" + assert json.loads(captured.out)["name"] == "projects/me/pairedDevices/dev123" diff --git a/tests/cli/subcommands/test_identity.py b/tests/cli/subcommands/test_identity.py index 63912a6..dc8515c 100644 --- a/tests/cli/subcommands/test_identity.py +++ b/tests/cli/subcommands/test_identity.py @@ -1,30 +1,44 @@ """Tests for identity and irn CLI commands.""" -from unittest.mock import AsyncMock, MagicMock +import json +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_identity_and_irn( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_identity_and_irn( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test identity and irn CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - mock_api.get_identity = AsyncMock( - return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"subject": "user123"}) + async def get_identity_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response( + {"name": "users/me/identity", "healthUserId": "user123"} + ) + + async def get_irn_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response( + {"name": "users/me/irnProfile", "onboardingStatus": True} + ) + + # test identity get + await run_cli_against_server( + ["identity", "get"], + [("GET", "v4/users/me/identity", get_identity_handler)], ) - run_cli(["identity", "get"]) - mock_api.get_identity.assert_called_once() + captured = capsys.readouterr() + assert json.loads(captured.out)["healthUserId"] == "user123" - mock_api.get_irn_profile = AsyncMock( - return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) + # test irn get + await run_cli_against_server( + ["irn", "get"], + [("GET", "v4/users/me/irnProfile", get_irn_handler)], ) - run_cli(["irn", "get"]) - mock_api.get_irn_profile.assert_called_once() + captured = capsys.readouterr() + assert json.loads(captured.out)["onboardingStatus"] is True diff --git a/tests/cli/subcommands/test_profile.py b/tests/cli/subcommands/test_profile.py index 42203ea..eb27619 100644 --- a/tests/cli/subcommands/test_profile.py +++ b/tests/cli/subcommands/test_profile.py @@ -1,69 +1,77 @@ """Tests for profile CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_profile_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_profile_commands( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test profile CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - # profile get - mock_prof = MagicMock(spec=["to_dict"]) - mock_prof.to_dict.return_value = { + profile_data = { "name": "users/me/profile", - "displayName": "Alice", + "age": 30, } - mock_api.get_profile = AsyncMock(return_value=mock_prof) - run_cli(["profile", "get"]) + async def get_profile_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response(profile_data) + + async def update_profile_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["age"] == 35 + assert request.query.get("updateMask") == "age" + return aiohttp.web.json_response(body) + + # profile get + await run_cli_against_server( + ["profile", "get"], + [("GET", "v4/users/me/profile", get_profile_handler)], + ) captured = capsys.readouterr() - assert json.loads(captured.out)["displayName"] == "Alice" + assert json.loads(captured.out)["age"] == 30 # profile update with json & update-mask & dry-run - payload = {"name": "users/me/profile", "displayName": "Bob"} + payload = {"name": "users/me/profile", "age": 35} with pytest.raises(SystemExit) as exit_info: - run_cli( + await run_cli_against_server( [ "--dry-run", "--json", json.dumps(payload), "--params", - json.dumps({"updateMask": "displayName"}), + json.dumps({"updateMask": "age"}), "profile", "update", - ] + ], + [], ) assert exit_info.value.code == 0 captured = capsys.readouterr() assert "dry_run" in json.loads(captured.out) # profile update execution - mock_api.update_profile = AsyncMock(return_value=mock_prof) - run_cli( + await run_cli_against_server( [ "--json", json.dumps(payload), "profile", "update", "--update-mask", - "displayName", - ] + "age", + ], + [("PATCH", "v4/users/me/profile", update_profile_handler)], ) - mock_api.update_profile.assert_called_once() # profile update missing json with pytest.raises(SystemExit): - run_cli(["profile", "update"]) + await run_cli_against_server(["profile", "update"], []) captured = capsys.readouterr() assert "Please provide raw JSON input" in captured.out diff --git a/tests/cli/subcommands/test_settings.py b/tests/cli/subcommands/test_settings.py index ae4f75b..bd6beaf 100644 --- a/tests/cli/subcommands/test_settings.py +++ b/tests/cli/subcommands/test_settings.py @@ -1,36 +1,43 @@ """Tests for settings CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_settings_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_settings_commands( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test settings CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api + settings_data = {"name": "users/me/settings", "timeZone": "UTC"} - # settings get - mock_sett = MagicMock(spec=["to_dict"]) - mock_sett.to_dict.return_value = {"name": "users/me/settings", "timeZone": "UTC"} - mock_api.get_settings = AsyncMock(return_value=mock_sett) + async def get_settings_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response(settings_data) + + async def update_settings_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["timeZone"] == "America/New_York" + assert request.query.get("updateMask") == "timeZone" + return aiohttp.web.json_response(body) - run_cli(["settings", "get"]) + # settings get + await run_cli_against_server( + ["settings", "get"], + [("GET", "v4/users/me/settings", get_settings_handler)], + ) captured = capsys.readouterr() assert json.loads(captured.out)["timeZone"] == "UTC" # settings update with json - mock_api.update_settings = AsyncMock(return_value=mock_sett) payload = {"name": "users/me/settings", "timeZone": "America/New_York"} - run_cli( + await run_cli_against_server( [ "--json", json.dumps(payload), @@ -38,10 +45,10 @@ def test_cli_settings_commands( "update", "--update-mask", "timeZone", - ] + ], + [("PATCH", "v4/users/me/settings", update_settings_handler)], ) - mock_api.update_settings.assert_called_once() # settings update missing json with pytest.raises(SystemExit): - run_cli(["settings", "update"]) + await run_cli_against_server(["settings", "update"], []) diff --git a/tests/cli/subcommands/test_subscribers.py b/tests/cli/subcommands/test_subscribers.py index 01bb1bc..0a4f761 100644 --- a/tests/cli/subcommands/test_subscribers.py +++ b/tests/cli/subcommands/test_subscribers.py @@ -1,36 +1,70 @@ """Tests for subscribers CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_subscribers_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_subscribers_commands( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test subscribers CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - mock_sub_api = AsyncMock() - mock_api.subscribers = mock_sub_api + async def list_subscribers_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response({"subscribers": []}) + + async def create_subscriber_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["endpointUri"] == "https://example.com/webhook" + assert body["endpointAuthorization"]["secret"] == "secret123" + return aiohttp.web.json_response( + { + "name": "projects/me/operations/op1", + "done": True, + "response": {"name": "sub1"}, + } + ) + + async def patch_subscriber_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["endpointUri"] == "https://example.com/new" + return aiohttp.web.json_response( + { + "name": "projects/me/operations/op2", + "done": True, + "response": {"name": "sub1"}, + } + ) + + async def delete_subscriber_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + assert request.query.get("force") == "true" + return aiohttp.web.json_response( + { + "name": "projects/me/operations/op3", + "done": True, + } + ) # subscribers list - mock_sub_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscribers": []} + await run_cli_against_server( + ["subscribers", "list"], + [("GET", "v4/projects/me/subscribers", list_subscribers_handler)], ) - run_cli(["subscribers", "list"]) - mock_sub_api.list.assert_called_once() # subscribers create with flags and dry-run with pytest.raises(SystemExit) as exit_info: - run_cli( + await run_cli_against_server( [ "--dry-run", "subscribers", @@ -39,7 +73,8 @@ def test_cli_subscribers_commands( "https://example.com/webhook", "--endpoint-secret", "secret123", - ] + ], + [], ) assert exit_info.value.code == 0 @@ -50,61 +85,55 @@ def test_cli_subscribers_commands( "endpointAuthorization": {"secret": "secret123"}, "subscriberConfigs": [{"dataType": "steps"}], } - mock_sub_api.create.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1"} - ) - run_cli( + await run_cli_against_server( [ "--json", json.dumps(payload), "subscribers", "create", - ] + ], + [("POST", "v4/projects/me/subscribers", create_subscriber_handler)], ) - mock_sub_api.create.assert_called_once() # subscribers create missing endpointUri with pytest.raises(SystemExit): - run_cli(["subscribers", "create"]) + await run_cli_against_server(["subscribers", "create"], []) # subscribers patch - mock_sub_api.patch.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1"} - ) sub_payload = { "name": "projects/me/subscribers/sub1", "endpointUri": "https://example.com/new", "endpointAuthorization": {"secret": "secret123"}, } - run_cli( + await run_cli_against_server( [ "--json", json.dumps(sub_payload), "subscribers", "patch", "projects/me/subscribers/sub1", - ] + ], + [("PATCH", "v4/projects/me/subscribers/sub1", patch_subscriber_handler)], ) - mock_sub_api.patch.assert_called_once() # subscribers patch missing json with pytest.raises(SystemExit): - run_cli( + await run_cli_against_server( [ "subscribers", "patch", "projects/me/subscribers/sub1", - ] + ], + [], ) # subscribers delete - mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) - run_cli( + await run_cli_against_server( [ "subscribers", "delete", "projects/me/subscribers/sub1", "--force", - ] + ], + [("DELETE", "v4/projects/me/subscribers/sub1", delete_subscriber_handler)], ) - mock_sub_api.delete.assert_called_once() diff --git a/tests/cli/subcommands/test_subscriptions.py b/tests/cli/subcommands/test_subscriptions.py index 8650fe2..4cb1c89 100644 --- a/tests/cli/subcommands/test_subscriptions.py +++ b/tests/cli/subcommands/test_subscriptions.py @@ -1,45 +1,68 @@ """Tests for subscriptions CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_subscriptions_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_subscriptions_commands( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test subscriptions CLI subcommands.""" - mock_load_credentials.return_value = ("env", "fake-token") - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - mock_subscriptions_api = AsyncMock() - mock_api.subscribers.subscriptions = mock_subscriptions_api + async def list_subscriptions_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response({"subscriptions": []}) + + async def create_subscription_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["user"] == "users/me" + assert body["dataTypes"] == ["steps"] + return aiohttp.web.json_response( + { + "name": "projects/me/subscribers/sub1/subscriptions/s1", + "user": "users/me", + "dataTypes": ["steps"], + } + ) + + async def patch_subscription_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + body = await request.json() + assert body["dataTypes"] == ["steps", "heart-rate"] + return aiohttp.web.json_response(body) + + async def delete_subscription_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.Response(status=200) # subscriptions list - mock_subscriptions_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscriptions": []} - ) - run_cli( + await run_cli_against_server( [ "subscriptions", "list", "--parent-subscriber", "projects/me/subscribers/sub1", - ] + ], + [ + ( + "GET", + "v4/projects/me/subscribers/sub1/subscriptions", + list_subscriptions_handler, + ) + ], ) - mock_subscriptions_api.list.assert_called_once() # subscriptions create with flags - mock_subscriptions_api.create.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1/subscriptions/s1"} - ) - run_cli( + await run_cli_against_server( [ "subscriptions", "create", @@ -49,13 +72,19 @@ def test_cli_subscriptions_commands( "users/me", "--data-types", "steps", - ] + ], + [ + ( + "POST", + "v4/projects/me/subscribers/sub1/subscriptions", + create_subscription_handler, + ) + ], ) - mock_subscriptions_api.create.assert_called_once() # subscriptions create with json payload = {"user": "users/me", "dataTypes": ["steps"]} - run_cli( + await run_cli_against_server( [ "--json", json.dumps(payload), @@ -63,57 +92,76 @@ def test_cli_subscriptions_commands( "create", "--parent-subscriber", "projects/me/subscribers/sub1", - ] + ], + [ + ( + "POST", + "v4/projects/me/subscribers/sub1/subscriptions", + create_subscription_handler, + ) + ], ) # subscriptions create missing user with pytest.raises(SystemExit): - run_cli( + await run_cli_against_server( [ "subscriptions", "create", "--parent-subscriber", "projects/me/subscribers/sub1", - ] + ], + [], ) # subscriptions patch - mock_subscriptions_api.patch.return_value = MagicMock( - spec=["to_dict"], to_dict=dict - ) sub_patch_payload = { "name": "projects/me/subscribers/sub1/subscriptions/s1", "user": "users/me", "dataTypes": ["steps", "heart-rate"], } - run_cli( + await run_cli_against_server( [ "--json", json.dumps(sub_patch_payload), "subscriptions", "patch", "projects/me/subscribers/sub1/subscriptions/s1", - ] + ], + [ + ( + "PATCH", + "v4/projects/me/subscribers/sub1/subscriptions/s1", + patch_subscription_handler, + ) + ], ) # subscriptions patch missing json with pytest.raises(SystemExit): - run_cli( + await run_cli_against_server( [ "subscriptions", "patch", "projects/me/subscribers/sub1/subscriptions/s1", - ] + ], + [], ) # subscriptions delete - mock_subscriptions_api.delete.return_value = None - run_cli( + await run_cli_against_server( [ "subscriptions", "delete", "projects/me/subscribers/sub1/subscriptions/s1", - ] + ], + [ + ( + "DELETE", + "v4/projects/me/subscribers/sub1/subscriptions/s1", + delete_subscription_handler, + ) + ], ) captured = capsys.readouterr() assert "Deleted subscription" in captured.out diff --git a/tests/cli/subcommands/test_userinfo.py b/tests/cli/subcommands/test_userinfo.py index 451d16f..b8a7a2b 100644 --- a/tests/cli/subcommands/test_userinfo.py +++ b/tests/cli/subcommands/test_userinfo.py @@ -1,35 +1,33 @@ """Tests for userinfo CLI command.""" import json -from unittest.mock import AsyncMock, MagicMock +import aiohttp import pytest -from tests.cli.conftest import run_cli - -def test_cli_userinfo( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, +@pytest.mark.asyncio +async def test_cli_userinfo( + run_cli_against_server, capsys: pytest.CaptureFixture[str], ) -> None: """Test that the userinfo command is correctly routed and executed in the CLI.""" - mock_load_credentials.return_value = ("env", "fake-token") - - mock_api = MagicMock() - mock_setup_client.return_value = mock_api - - mock_userinfo = MagicMock(spec=["to_dict"]) - mock_userinfo.to_dict.return_value = { + userinfo_data = { "sub": "110248495921238986420", "name": "John Doe", "email": "johndoe@example.com", } - mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) - run_cli(["userinfo"]) + async def get_user_info_handler( + request: aiohttp.web.Request, + ) -> aiohttp.web.Response: + return aiohttp.web.json_response(userinfo_data) + + await run_cli_against_server( + ["userinfo"], + [("GET", "oauth2/v3/userinfo", get_user_info_handler)], + ) - mock_api.get_user_info.assert_called_once() captured = capsys.readouterr() res_json = json.loads(captured.out) assert res_json["sub"] == "110248495921238986420"