From eccb9caa3fb045e3c8cd50a1512ff1a1984688c4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:15:08 -0700 Subject: [PATCH 01/24] Reapply "test: Improve test coverage for HTTP client error handling (#48)" (#54) This reverts commit 0946f8fc9f5dd8cf2af166d17aa06322fb4e5e60. --- tests/test_client.py | 240 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/test_client.py diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..b4dde71 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,240 @@ +"""Tests for google_health_api/client.py.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest +from aiohttp import ClientError, ClientResponseError + +from google_health_api.auth import AbstractAuth +from google_health_api.client import GoogleHealthSession +from google_health_api.exceptions import ( + HealthApiException, + HealthApiForbiddenException, + HealthAuthException, +) + + +class MockAuth(AbstractAuth): + """Mock auth class for testing.""" + + async def async_get_access_token(self) -> str: + """Return a test access token.""" + return "test-token" + + +@pytest.fixture +def mock_auth() -> AbstractAuth: + """Fixture for mock auth.""" + return MockAuth(AsyncMock(), "http://localhost") + + +@pytest.fixture +def mock_websession() -> MagicMock: + """Fixture for mock aiohttp ClientSession.""" + session = MagicMock(spec=aiohttp.ClientSession) + session.request = AsyncMock() + return session + + +async def test_auth_token_client_error( + mock_websession: MagicMock, +) -> None: + """Test HealthAuthException when async_get_access_token raises ClientError.""" + auth = AsyncMock(spec=AbstractAuth) + auth.async_get_access_token.side_effect = ClientError("Token error") + session = GoogleHealthSession(auth, mock_websession) + + with pytest.raises(HealthAuthException, match="Access token failure: Token error"): + await session.get("v1/user/profile") + + +async def test_request_headers_and_url_formatting( + mock_auth: AbstractAuth, mock_websession: MagicMock +) -> None: + """Test header injection, absolute URL preservation, and HTTP verb helpers.""" + mock_response = AsyncMock(spec=aiohttp.ClientResponse) + mock_response.status = 200 + mock_websession.request.return_value = mock_response + + session = GoogleHealthSession( + mock_auth, mock_websession, host="https://health.googleapis.com" + ) + + # 1. GET with relative URL (no auth header passed) + resp = await session.get("v1/test") + assert resp == mock_response + mock_websession.request.assert_called_with( + "GET", + "https://health.googleapis.com/v1/test", + headers={"Authorization": "Bearer test-token"}, + ) + + # 2. POST with absolute URL, custom headers, and json body + await session.post( + "https://custom.api/v1/test", + headers={"Authorization": "Bearer existing"}, + json={"data": 1}, + ) + mock_websession.request.assert_called_with( + "POST", + "https://custom.api/v1/test", + json={"data": 1}, + headers={"Authorization": "Bearer existing"}, + ) + + # 3. PATCH helper + await session.patch("v1/test", json={"patch": "value"}) + mock_websession.request.assert_called_with( + "PATCH", + "https://health.googleapis.com/v1/test", + json={"patch": "value"}, + headers={"Authorization": "Bearer test-token"}, + ) + + # 4. DELETE helper + await session.delete("v1/test") + mock_websession.request.assert_called_with( + "DELETE", + "https://health.googleapis.com/v1/test", + headers={"Authorization": "Bearer test-token"}, + ) + + +async def test_raise_for_status_unauthorized( + mock_auth: AbstractAuth, mock_websession: MagicMock +) -> None: + """Test 401 Unauthorized response raises HealthAuthException.""" + mock_response = AsyncMock(spec=aiohttp.ClientResponse) + mock_response.status = 401 + mock_response.text.return_value = '{"error": {"code": 401, "message": "Invalid credentials", "status": "UNAUTHENTICATED"}}' + + request_info = MagicMock() + mock_response.raise_for_status.side_effect = ClientResponseError( + request_info=request_info, + history=(), + status=HTTPStatus.UNAUTHORIZED, + message="Unauthorized", + ) + mock_websession.request.return_value = mock_response + + session = GoogleHealthSession(mock_auth, mock_websession) + with pytest.raises( + HealthAuthException, + match="Unauthorized response from API \\(401\\): UNAUTHENTICATED: \\(401\\): Invalid credentials", + ): + await session.get("v1/test") + + +async def test_raise_for_status_forbidden( + mock_auth: AbstractAuth, mock_websession: MagicMock +) -> None: + """Test 403 Forbidden response raises HealthApiForbiddenException.""" + mock_response = AsyncMock(spec=aiohttp.ClientResponse) + mock_response.status = 403 + mock_response.text.return_value = '{"error": {"code": 403, "message": "Permission denied", "status": "PERMISSION_DENIED"}}' + + request_info = MagicMock() + mock_response.raise_for_status.side_effect = ClientResponseError( + request_info=request_info, + history=(), + status=HTTPStatus.FORBIDDEN, + message="Forbidden", + ) + mock_websession.request.return_value = mock_response + + session = GoogleHealthSession(mock_auth, mock_websession) + with pytest.raises( + HealthApiForbiddenException, + match="Forbidden response from API \\(403\\): PERMISSION_DENIED: \\(403\\): Permission denied", + ): + await session.get("v1/test") + + +async def test_raise_for_status_generic_client_response_error( + mock_auth: AbstractAuth, mock_websession: MagicMock +) -> None: + """Test generic status error (e.g. 500) raises HealthApiException.""" + mock_response = AsyncMock(spec=aiohttp.ClientResponse) + mock_response.status = 500 + mock_response.text.return_value = ( + '{"error": {"code": 500, "message": "Server Error", "status": "INTERNAL"}}' + ) + + request_info = MagicMock() + mock_response.raise_for_status.side_effect = ClientResponseError( + request_info=request_info, + history=(), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + message="Internal Server Error", + ) + mock_websession.request.return_value = mock_response + + session = GoogleHealthSession(mock_auth, mock_websession) + with pytest.raises( + HealthApiException, + match="Internal Server Error response from API \\(500\\): INTERNAL: \\(500\\): Server Error", + ): + await session.get("v1/test") + + +async def test_raise_for_status_generic_client_error( + mock_auth: AbstractAuth, mock_websession: MagicMock +) -> None: + """Test generic aiohttp ClientError raises HealthApiException.""" + mock_response = AsyncMock(spec=aiohttp.ClientResponse) + mock_response.status = 200 + mock_response.raise_for_status.side_effect = ClientError("Connection reset") + mock_websession.request.return_value = mock_response + + session = GoogleHealthSession(mock_auth, mock_websession) + with pytest.raises(HealthApiException, match="Error from API: Connection reset"): + await session.get("v1/test") + + +async def test_error_detail_variations() -> None: + """Test _error_detail parsing under various status codes and JSON formats.""" + # 1. Status < 400 -> None + resp_200 = AsyncMock(spec=aiohttp.ClientResponse) + resp_200.status = 200 + assert await GoogleHealthSession._error_detail(resp_200) is None + + # 2. Status >= 400 but text() raises ClientError -> None + resp_error_text = AsyncMock(spec=aiohttp.ClientResponse) + resp_error_text.status = 400 + resp_error_text.text.side_effect = ClientError("Stream closed") + assert await GoogleHealthSession._error_detail(resp_error_text) is None + + # 3. Valid JSON with partial error fields + # Only status + resp_status_only = AsyncMock(spec=aiohttp.ClientResponse) + resp_status_only.status = 400 + resp_status_only.text.return_value = '{"error": {"status": "INVALID"}}' + assert await GoogleHealthSession._error_detail(resp_status_only) == "INVALID" + + # Only code + resp_code_only = AsyncMock(spec=aiohttp.ClientResponse) + resp_code_only.status = 400 + resp_code_only.text.return_value = '{"error": {"code": 404}}' + assert await GoogleHealthSession._error_detail(resp_code_only) == "(404)" + + # Only message + resp_msg_only = AsyncMock(spec=aiohttp.ClientResponse) + resp_msg_only.status = 400 + resp_msg_only.text.return_value = '{"error": {"message": "Resource not found"}}' + assert ( + await GoogleHealthSession._error_detail(resp_msg_only) == "Resource not found" + ) + + # Empty error object -> None + resp_empty_error = AsyncMock(spec=aiohttp.ClientResponse) + resp_empty_error.status = 400 + resp_empty_error.text.return_value = '{"error": {}}' + assert await GoogleHealthSession._error_detail(resp_empty_error) is None + + # 4. Invalid JSON / HTML response -> None + resp_html = AsyncMock(spec=aiohttp.ClientResponse) + resp_html.status = 502 + resp_html.text.return_value = "Bad Gateway" + assert await GoogleHealthSession._error_detail(resp_html) is None From 396e14d1b43f442fa59ebda868f472d36a8517b0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:16:00 -0700 Subject: [PATCH 02/24] Reapply "test: Improve test coverage for CLI schema introspection (#47)" (#55) This reverts commit 892fe724d730bf7d2744e87ca6bbdd6a926e46b1. --- tests/test_schema.py | 168 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/test_schema.py diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..251e8e3 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,168 @@ +"""Unit tests for CLI schema introspection in google_health_api/cli/schema.py.""" + +import sys +import typing +from dataclasses import dataclass, field +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from google_health_api.cli.main import main +from google_health_api.cli.schema import ( + generate_schema, + get_command_schemas, + get_datapoint_schema, + get_datatype_schemas, + get_type_name, +) +from google_health_api.model import Steps + + +def test_get_type_name_union_multi_non_none() -> None: + """Test get_type_name with a Union having multiple non-None types.""" + res = get_type_name(int | str) + assert res == "int | str" + + +def test_get_type_name_bare_typing_list() -> None: + """Test get_type_name with generic typing.List without type arguments (line 64).""" + res = get_type_name(typing.List) # noqa: UP006 + assert res == "List" + + +def test_get_type_name_dict() -> None: + """Test get_type_name with Dict[K, V] and bare typing.Dict (lines 66-69).""" + res_parametrized = get_type_name(typing.Dict[str, int]) # noqa: UP006 + assert res_parametrized == "Dict[str, int]" + + res_bare = get_type_name(typing.Dict) # noqa: UP006 + assert res_bare == "Dict" + + +def test_get_type_name_no_name_attribute() -> None: + """Test get_type_name with objects that do not have a __name__ attribute (line 73).""" + + class CustomObj: + pass + + obj = CustomObj() + res = get_type_name(obj) + assert "CustomObj object" in res or str(obj) == res + + +def test_generate_schema_non_dataclass() -> None: + """Test generate_schema with a non-dataclass type (line 79).""" + res = generate_schema(int) + assert res == {"type": "int"} + + res_str = generate_schema(str) + assert res_str == {"type": "str"} + + +def test_generate_schema_field_alias_attr_and_dict() -> None: + """Test generate_schema handles field_options with alias attribute and dict key (lines 90-94).""" + + @dataclass + class ClassWithAttrAlias: + normal_field: str + aliased_field: int = field( + metadata={"field_options": SimpleNamespace(alias="attr_alias_name")} + ) + + schema_attr = generate_schema(ClassWithAttrAlias) + assert "attr_alias_name" in schema_attr["properties"] + assert "aliased_field" not in schema_attr["properties"] + + @dataclass + class ClassWithDictAlias: + aliased_field: int = field( + metadata={"field_options": {"alias": "dict_alias_name"}} + ) + + schema_dict = generate_schema(ClassWithDictAlias) + assert "dict_alias_name" in schema_dict["properties"] + assert "aliased_field" not in schema_dict["properties"] + + +def test_generate_schema_union_and_nested_list() -> None: + """Test generate_schema handles Optional[List[Dataclass]] and List[List[Dataclass]] (lines 102-110, 118-120).""" + + @dataclass + class SubItem: + val: int + + @dataclass + class ContainerUnion: + items: list[SubItem] | None = None + + schema_union = generate_schema(ContainerUnion) + assert schema_union["properties"]["items"]["type"] == "array" + assert ( + schema_union["properties"]["items"]["items"]["properties"]["val"]["type"] + == "int" + ) + + @dataclass + class ContainerNestedList: + matrix: list[list[SubItem]] + + schema_nested = generate_schema(ContainerNestedList) + assert schema_nested["properties"]["matrix"]["type"] == "array" + assert ( + schema_nested["properties"]["matrix"]["items"]["properties"]["val"]["type"] + == "int" + ) + + +def test_get_datapoint_schema() -> None: + """Test get_datapoint_schema helper function.""" + schema = get_datapoint_schema(Steps, "steps") + assert schema["type"] == "object" + assert "name" in schema["properties"] + assert "dataSource" in schema["properties"] + assert "steps" in schema["properties"] + + +def test_get_datatype_schemas() -> None: + """Test get_datatype_schemas with and without rollup support.""" + schemas_no_rollup = get_datatype_schemas( + "test-type", "testType", Steps, "test description", supports_rollup=False + ) + assert "test-type.list" in schemas_no_rollup + assert "test-type.get" in schemas_no_rollup + assert "test-type.create" in schemas_no_rollup + assert "test-type.patch" in schemas_no_rollup + assert "test-type.delete" in schemas_no_rollup + assert "test-type.rollup" not in schemas_no_rollup + + schemas_rollup = get_datatype_schemas( + "test-type", "testType", Steps, "test description", supports_rollup=True + ) + assert "test-type.rollup" in schemas_rollup + + +def test_get_command_schemas() -> None: + """Test get_command_schemas returns complete registry of CLI command schemas.""" + schemas = get_command_schemas() + assert isinstance(schemas, dict) + assert "steps.list" in schemas + assert "userinfo" in schemas + assert "profile.get" in schemas + assert "settings.get" in schemas + assert "devices.list" in schemas + assert "subscribers.list" in schemas + assert "subscriptions.list" in schemas + + +def test_cli_schema_unknown_command_error(capsys) -> None: + """Test CLI error handling when introspecting an unknown schema command name.""" + with patch.object( + sys, "argv", ["google-health-cli", "schema", "nonexistent.command"] + ): + with pytest.raises(SystemExit) as exit_info: + main() + assert exit_info.value.code == 1 + + captured = capsys.readouterr() + assert "Unknown command for schema lookup: nonexistent.command" in captured.err From b9d22d83d66e512025a154652650bec33ad868d5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:42:55 -0700 Subject: [PATCH 03/24] test: Rename test_schema.py to test_cli_schema.py to mirror source layout --- tests/{test_schema.py => test_cli_schema.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_schema.py => test_cli_schema.py} (100%) diff --git a/tests/test_schema.py b/tests/test_cli_schema.py similarity index 100% rename from tests/test_schema.py rename to tests/test_cli_schema.py From 9ad74c905acbad0185710f215965721ff7f63230 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Jul 2026 07:57:59 -0700 Subject: [PATCH 04/24] test: Improve test coverage for pagination async iteration --- tests/test_pagination.py | 136 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/test_pagination.py diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..a0fb164 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,136 @@ +"""Tests for pagination models and async iteration.""" + +from unittest.mock import AsyncMock + +import pytest + +from google_health_api.model.pagination import ( + ListDataPointResult, + ListReconciledDataPointsResult, + _ListDataPointsModel, + _ListReconciledDataPointsModel, +) + + +@pytest.mark.asyncio +async def test_list_data_point_result_single_page_no_token() -> None: + """Test ListDataPointResult iteration with a single page and no next_page_token.""" + raw_page = _ListDataPointsModel(data_points=["dp1", "dp2"], next_page_token=None) # type: ignore[arg-type] + result = ListDataPointResult(raw_page) + + assert result.data_points == ["dp1", "dp2"] + assert result.next_page_token is None + + pages = [page async for page in result] + assert len(pages) == 1 + assert pages[0].data_points == ["dp1", "dp2"] + + +@pytest.mark.asyncio +async def test_list_data_point_result_single_page_no_get_next_page() -> None: + """Test ListDataPointResult iteration when token exists but get_next_page is None.""" + raw_page = _ListDataPointsModel(data_points=["dp1"], next_page_token="token123") # type: ignore[arg-type] + result = ListDataPointResult(raw_page, get_next_page=None) + + assert result.next_page_token == "token123" + + pages = [page async for page in result] + assert len(pages) == 1 + assert pages[0].data_points == ["dp1"] + + +@pytest.mark.asyncio +async def test_list_data_point_result_multi_page() -> None: + """Test ListDataPointResult async iteration across multiple pages.""" + page1 = _ListDataPointsModel( + data_points=["dp1", "dp2"], next_page_token="token_page_2" + ) # type: ignore[arg-type] + page2 = _ListDataPointsModel( + data_points=["dp3", "dp4"], next_page_token="token_page_3" + ) # type: ignore[arg-type] + page3 = _ListDataPointsModel(data_points=["dp5"], next_page_token=None) # type: ignore[arg-type] + + async def mock_get_next_page(token: str) -> _ListDataPointsModel: + if token == "token_page_2": + return page2 + if token == "token_page_3": + return page3 + raise ValueError(f"Unexpected token: {token}") + + get_next_page_mock = AsyncMock(side_effect=mock_get_next_page) + result = ListDataPointResult(page1, get_next_page=get_next_page_mock) + + pages = [page async for page in result] + + assert len(pages) == 3 + assert pages[0].data_points == ["dp1", "dp2"] + assert pages[1].data_points == ["dp3", "dp4"] + assert pages[2].data_points == ["dp5"] + + assert get_next_page_mock.call_count == 2 + get_next_page_mock.assert_any_call("token_page_2") + get_next_page_mock.assert_any_call("token_page_3") + + +@pytest.mark.asyncio +async def test_list_reconciled_data_points_result_single_page_no_token() -> None: + """Test ListReconciledDataPointsResult iteration with single page and no token.""" + raw_page = _ListReconciledDataPointsModel( + reconciled_data_points=["rdp1"], + next_page_token=None, # type: ignore[arg-type] + ) + result = ListReconciledDataPointsResult(raw_page) + + assert result.reconciled_data_points == ["rdp1"] + assert result.next_page_token is None + + pages = [page async for page in result] + assert len(pages) == 1 + assert pages[0].reconciled_data_points == ["rdp1"] + + +@pytest.mark.asyncio +async def test_list_reconciled_data_points_result_single_page_no_get_next_page() -> ( + None +): + """Test ListReconciledDataPointsResult iteration when token exists but get_next_page is None.""" + raw_page = _ListReconciledDataPointsModel( + reconciled_data_points=["rdp1"], + next_page_token="token123", # type: ignore[arg-type] + ) + result = ListReconciledDataPointsResult(raw_page, get_next_page=None) + + assert result.next_page_token == "token123" + + pages = [page async for page in result] + assert len(pages) == 1 + assert pages[0].reconciled_data_points == ["rdp1"] + + +@pytest.mark.asyncio +async def test_list_reconciled_data_points_result_multi_page() -> None: + """Test ListReconciledDataPointsResult async iteration across multiple pages.""" + page1 = _ListReconciledDataPointsModel( + reconciled_data_points=["rdp1"], + next_page_token="token_page_2", # type: ignore[arg-type] + ) + page2 = _ListReconciledDataPointsModel( + reconciled_data_points=["rdp2", "rdp3"], + next_page_token=None, # type: ignore[arg-type] + ) + + async def mock_get_next_page(token: str) -> _ListReconciledDataPointsModel: + if token == "token_page_2": + return page2 + raise ValueError(f"Unexpected token: {token}") + + get_next_page_mock = AsyncMock(side_effect=mock_get_next_page) + result = ListReconciledDataPointsResult(page1, get_next_page=get_next_page_mock) + + pages = [page async for page in result] + + assert len(pages) == 2 + assert pages[0].reconciled_data_points == ["rdp1"] + assert pages[1].reconciled_data_points == ["rdp2", "rdp3"] + + get_next_page_mock.assert_called_once_with("token_page_2") From 679c4d35c64662599e5e82983f5cfd0feecc6de1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:43:39 -0700 Subject: [PATCH 05/24] test: Add tests/test_model_pagination.py for pagination coverage --- ...pagination.py => test_model_pagination.py} | 93 ++++++++++++------- 1 file changed, 62 insertions(+), 31 deletions(-) rename tests/{test_pagination.py => test_model_pagination.py} (58%) diff --git a/tests/test_pagination.py b/tests/test_model_pagination.py similarity index 58% rename from tests/test_pagination.py rename to tests/test_model_pagination.py index a0fb164..10ac8e3 100644 --- a/tests/test_pagination.py +++ b/tests/test_model_pagination.py @@ -1,9 +1,15 @@ """Tests for pagination models and async iteration.""" +from dataclasses import dataclass from unittest.mock import AsyncMock import pytest +from google_health_api.model.base import ( + DataClassDictMixin, + DataPoint, + ReconciledDataPoint, +) from google_health_api.model.pagination import ( ListDataPointResult, ListReconciledDataPointsResult, @@ -12,45 +18,57 @@ ) +@dataclass +class DummyData(DataClassDictMixin): + """Dummy data class for generic typing tests.""" + + value: int + + @pytest.mark.asyncio async def test_list_data_point_result_single_page_no_token() -> None: """Test ListDataPointResult iteration with a single page and no next_page_token.""" - raw_page = _ListDataPointsModel(data_points=["dp1", "dp2"], next_page_token=None) # type: ignore[arg-type] + dp1 = DataPoint(data=DummyData(1)) + dp2 = DataPoint(data=DummyData(2)) + raw_page = _ListDataPointsModel(data_points=[dp1, dp2], next_page_token=None) result = ListDataPointResult(raw_page) - assert result.data_points == ["dp1", "dp2"] + assert result.data_points == [dp1, dp2] assert result.next_page_token is None pages = [page async for page in result] assert len(pages) == 1 - assert pages[0].data_points == ["dp1", "dp2"] + assert pages[0].data_points == [dp1, dp2] @pytest.mark.asyncio async def test_list_data_point_result_single_page_no_get_next_page() -> None: """Test ListDataPointResult iteration when token exists but get_next_page is None.""" - raw_page = _ListDataPointsModel(data_points=["dp1"], next_page_token="token123") # type: ignore[arg-type] + dp1 = DataPoint(data=DummyData(1)) + raw_page = _ListDataPointsModel(data_points=[dp1], next_page_token="token123") result = ListDataPointResult(raw_page, get_next_page=None) assert result.next_page_token == "token123" pages = [page async for page in result] assert len(pages) == 1 - assert pages[0].data_points == ["dp1"] + assert pages[0].data_points == [dp1] @pytest.mark.asyncio async def test_list_data_point_result_multi_page() -> None: """Test ListDataPointResult async iteration across multiple pages.""" - page1 = _ListDataPointsModel( - data_points=["dp1", "dp2"], next_page_token="token_page_2" - ) # type: ignore[arg-type] - page2 = _ListDataPointsModel( - data_points=["dp3", "dp4"], next_page_token="token_page_3" - ) # type: ignore[arg-type] - page3 = _ListDataPointsModel(data_points=["dp5"], next_page_token=None) # type: ignore[arg-type] - - async def mock_get_next_page(token: str) -> _ListDataPointsModel: + dp1 = DataPoint(data=DummyData(1)) + dp2 = DataPoint(data=DummyData(2)) + dp3 = DataPoint(data=DummyData(3)) + dp4 = DataPoint(data=DummyData(4)) + dp5 = DataPoint(data=DummyData(5)) + + page1 = _ListDataPointsModel(data_points=[dp1, dp2], next_page_token="token_page_2") + page2 = _ListDataPointsModel(data_points=[dp3, dp4], next_page_token="token_page_3") + page3 = _ListDataPointsModel(data_points=[dp5], next_page_token=None) + + async def mock_get_next_page(token: str) -> _ListDataPointsModel[DummyData]: if token == "token_page_2": return page2 if token == "token_page_3": @@ -63,9 +81,9 @@ async def mock_get_next_page(token: str) -> _ListDataPointsModel: pages = [page async for page in result] assert len(pages) == 3 - assert pages[0].data_points == ["dp1", "dp2"] - assert pages[1].data_points == ["dp3", "dp4"] - assert pages[2].data_points == ["dp5"] + assert pages[0].data_points == [dp1, dp2] + assert pages[1].data_points == [dp3, dp4] + assert pages[2].data_points == [dp5] assert get_next_page_mock.call_count == 2 get_next_page_mock.assert_any_call("token_page_2") @@ -75,18 +93,20 @@ async def mock_get_next_page(token: str) -> _ListDataPointsModel: @pytest.mark.asyncio async def test_list_reconciled_data_points_result_single_page_no_token() -> None: """Test ListReconciledDataPointsResult iteration with single page and no token.""" + dp1 = DataPoint(data=DummyData(1)) + rdp1 = ReconciledDataPoint(data_point=dp1) raw_page = _ListReconciledDataPointsModel( - reconciled_data_points=["rdp1"], - next_page_token=None, # type: ignore[arg-type] + reconciled_data_points=[rdp1], + next_page_token=None, ) result = ListReconciledDataPointsResult(raw_page) - assert result.reconciled_data_points == ["rdp1"] + assert result.reconciled_data_points == [rdp1] assert result.next_page_token is None pages = [page async for page in result] assert len(pages) == 1 - assert pages[0].reconciled_data_points == ["rdp1"] + assert pages[0].reconciled_data_points == [rdp1] @pytest.mark.asyncio @@ -94,9 +114,11 @@ async def test_list_reconciled_data_points_result_single_page_no_get_next_page() None ): """Test ListReconciledDataPointsResult iteration when token exists but get_next_page is None.""" + dp1 = DataPoint(data=DummyData(1)) + rdp1 = ReconciledDataPoint(data_point=dp1) raw_page = _ListReconciledDataPointsModel( - reconciled_data_points=["rdp1"], - next_page_token="token123", # type: ignore[arg-type] + reconciled_data_points=[rdp1], + next_page_token="token123", ) result = ListReconciledDataPointsResult(raw_page, get_next_page=None) @@ -104,22 +126,31 @@ async def test_list_reconciled_data_points_result_single_page_no_get_next_page() pages = [page async for page in result] assert len(pages) == 1 - assert pages[0].reconciled_data_points == ["rdp1"] + assert pages[0].reconciled_data_points == [rdp1] @pytest.mark.asyncio async def test_list_reconciled_data_points_result_multi_page() -> None: """Test ListReconciledDataPointsResult async iteration across multiple pages.""" + dp1 = DataPoint(data=DummyData(1)) + dp2 = DataPoint(data=DummyData(2)) + dp3 = DataPoint(data=DummyData(3)) + rdp1 = ReconciledDataPoint(data_point=dp1) + rdp2 = ReconciledDataPoint(data_point=dp2) + rdp3 = ReconciledDataPoint(data_point=dp3) + page1 = _ListReconciledDataPointsModel( - reconciled_data_points=["rdp1"], - next_page_token="token_page_2", # type: ignore[arg-type] + reconciled_data_points=[rdp1], + next_page_token="token_page_2", ) page2 = _ListReconciledDataPointsModel( - reconciled_data_points=["rdp2", "rdp3"], - next_page_token=None, # type: ignore[arg-type] + reconciled_data_points=[rdp2, rdp3], + next_page_token=None, ) - async def mock_get_next_page(token: str) -> _ListReconciledDataPointsModel: + async def mock_get_next_page( + token: str, + ) -> _ListReconciledDataPointsModel[DummyData]: if token == "token_page_2": return page2 raise ValueError(f"Unexpected token: {token}") @@ -130,7 +161,7 @@ async def mock_get_next_page(token: str) -> _ListReconciledDataPointsModel: pages = [page async for page in result] assert len(pages) == 2 - assert pages[0].reconciled_data_points == ["rdp1"] - assert pages[1].reconciled_data_points == ["rdp2", "rdp3"] + assert pages[0].reconciled_data_points == [rdp1] + assert pages[1].reconciled_data_points == [rdp2, rdp3] get_next_page_mock.assert_called_once_with("token_page_2") From 3d3e8ad45bbc7660362bb393c11ff48badda66da Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Jul 2026 08:07:48 -0700 Subject: [PATCH 06/24] test: Improve test coverage for CLI commands --- google_health_api/cli/commands.py | 4 +- tests/test_cli.py | 995 +++++++++++++++++++++++++++++- 2 files changed, 996 insertions(+), 3 deletions(-) diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index 92ad216..5248f88 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -51,7 +51,9 @@ ] -fields_var = contextvars.ContextVar("fields", default=None) +fields_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "fields", default=None +) class CliHealthSession(GoogleHealthSession): diff --git a/tests/test_cli.py b/tests/test_cli.py index d555d07..f67fd03 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ from google_health_api.cli.main import main from google_health_api.cli.validation import validate_resource_name, validate_safe_path +from google_health_api.client import GoogleHealthSession def test_validate_resource_name() -> None: @@ -213,7 +214,7 @@ def test_cli_new_datapoints(mock_load, mock_setup, capsys) -> None: mock_sleep_subapi._data_type.field_name = "sleep" # Setup mock return value for list sleep - mock_sleep_res = MagicMock() + mock_sleep_res = MagicMock(spec=["to_dict"]) mock_sleep_res.to_dict.return_value = { "dataPoints": [], "nextPageToken": None, @@ -300,7 +301,7 @@ def test_cli_userinfo(mock_load, mock_setup, capsys) -> None: mock_api = MagicMock() mock_setup.return_value = mock_api - mock_userinfo = MagicMock() + mock_userinfo = MagicMock(spec=["to_dict"]) mock_userinfo.to_dict.return_value = { "sub": "110248495921238986420", "name": "John Doe", @@ -316,3 +317,993 @@ def test_cli_userinfo(mock_load, mock_setup, capsys) -> None: res_json = json.loads(captured.out) assert res_json["sub"] == "110248495921238986420" assert res_json["name"] == "John Doe" + + +# ===================================================================== +# Additional Comprehensive CLI Tests +# ===================================================================== + + +@pytest.mark.asyncio +async def test_cli_health_session_fields_injection() -> None: + """Test dynamic fields parameter injection in CliHealthSession.""" + from google_health_api.cli.commands import CliHealthSession, fields_var + + auth_mock = AsyncMock() + auth_mock.async_get_access_token = AsyncMock(return_value="fake-token") + mock_session = AsyncMock() + + mock_resp = MagicMock(status=200) + + cli_session = CliHealthSession(auth_mock, mock_session, "https://example.com") + + # When fields_var is None + token_none = fields_var.set(None) + try: + with patch.object( + GoogleHealthSession, "request", new_callable=AsyncMock + ) as mock_super_req: + mock_super_req.return_value = mock_resp + await cli_session.request("GET", "https://example.com/test") + mock_super_req.assert_called_once_with( + "GET", "https://example.com/test", headers=None + ) + finally: + fields_var.reset(token_none) + + # When fields_var is set + token_val = fields_var.set("count,interval") + try: + with patch.object( + GoogleHealthSession, "request", new_callable=AsyncMock + ) as mock_super_req: + mock_super_req.return_value = mock_resp + await cli_session.request("GET", "https://example.com/test") + mock_super_req.assert_called_once_with( + "GET", + "https://example.com/test", + headers=None, + params={"fields": "count,interval"}, + ) + finally: + fields_var.reset(token_val) + + +@pytest.mark.asyncio +async def test_credentials_auth_refresh(tmp_path, monkeypatch) -> None: + """Test CredentialsAuth refreshing expired credentials.""" + from google_health_api.cli.commands import CredentialsAuth + + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) + + creds_mock = MagicMock() + creds_mock.valid = False + creds_mock.token = "refreshed-token" + creds_mock.to_json.return_value = '{"token": "refreshed-token"}' + + session_mock = MagicMock() + auth = CredentialsAuth(session_mock, creds_mock) + + token = await auth.async_get_access_token() + assert token == "refreshed-token" + assert creds_mock.refresh.called + assert token_file.exists() + + +@pytest.mark.asyncio +async def test_env_auth() -> None: + """Test EnvAuth token retrieval.""" + from google_health_api.cli.commands import EnvAuth + + session_mock = MagicMock() + auth = EnvAuth(session_mock, "env-secret-token") + token = await auth.async_get_access_token() + assert token == "env-secret-token" + + +def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: + """Test loading credentials from env vs token file.""" + from google_health_api.cli.commands import load_credentials_or_env + + # 1. Test env var set + monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") + res = load_credentials_or_env() + assert res == ("env", "env-tok") + + # 2. Test no file and no env var + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) + assert load_credentials_or_env() is None + + # 3. Test token file exists with Z expiry + token_data = { + "token": "file-tok", + "refresh_token": "re-tok", + "expiry": "2026-12-31T23:59:59Z", + } + token_file.write_text(json.dumps(token_data)) + res_file = load_credentials_or_env() + assert res_file[0] == "file" + assert res_file[1].token == "file-tok" + + # 4. Token file with non-Z iso string with offset + token_data["expiry"] = "2026-12-31T23:59:59+00:00" + token_file.write_text(json.dumps(token_data)) + res_file2 = load_credentials_or_env() + assert res_file2[0] == "file" + + +def test_cmd_login_missing_client_secret(tmp_path, monkeypatch, capsys) -> None: + """Test login failure when client secret file is missing.""" + from google_health_api.cli.commands import cmd_login + + monkeypatch.setattr( + "google_health_api.cli.commands.CLIENT_SECRET_FILE", + str(tmp_path / "nonexistent.json"), + ) + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "not found" in captured.out + + +def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: + """Test login failure in non-interactive environment.""" + from google_health_api.cli.commands import cmd_login + + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"web": {}})) + monkeypatch.setattr( + "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "headless environment" in captured.out + + +def test_cmd_login_web_flow(tmp_path, monkeypatch, capsys) -> None: + """Test login web OAuth flow.""" + from google_health_api.cli.commands import cmd_login + + secret_file = tmp_path / "client_secret.json" + secret_file.write_text( + json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) + ) + monkeypatch.setattr( + "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_oauth_flow = MagicMock() + mock_flow = MagicMock() + mock_oauth_flow.Flow.from_client_secrets_file.return_value = mock_flow + mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.credentials = mock_creds + + with patch.dict("sys.modules", {"google_auth_oauthlib.flow": mock_oauth_flow}): + # Empty response -> error + monkeypatch.setattr("builtins.input", lambda prompt="": "") + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + + # Valid auth code response + monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") + cmd_login(MagicMock()) + assert mock_flow.fetch_token.called + + +def test_cmd_login_installed_app_flow(tmp_path, monkeypatch, capsys) -> None: + """Test login installed app flow.""" + from google_health_api.cli.commands import cmd_login + + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"installed": {}})) + monkeypatch.setattr( + "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_oauth_flow = MagicMock() + mock_flow = MagicMock() + mock_oauth_flow.InstalledAppFlow.from_client_secrets_file.return_value = mock_flow + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.run_local_server.return_value = mock_creds + + with patch.dict("sys.modules", {"google_auth_oauthlib.flow": mock_oauth_flow}): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "Logged in successfully" in captured.out + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_profile_commands(mock_load, mock_setup, capsys) -> None: + """Test profile CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + # profile get + mock_prof = MagicMock(spec=["to_dict"]) + mock_prof.to_dict.return_value = { + "name": "users/me/profile", + "displayName": "Alice", + } + mock_api.get_profile = AsyncMock(return_value=mock_prof) + + with patch.object(sys, "argv", ["google-health-cli", "profile", "get"]): + main() + captured = capsys.readouterr() + assert json.loads(captured.out)["displayName"] == "Alice" + + # profile update with json & update-mask & dry-run + payload = {"name": "users/me/profile", "displayName": "Bob"} + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--dry-run", + "--json", + json.dumps(payload), + "--params", + json.dumps({"updateMask": "displayName"}), + "profile", + "update", + ], + ): + with pytest.raises(SystemExit) as exit_info: + main() + 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) + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(payload), + "profile", + "update", + "--update-mask", + "displayName", + ], + ): + main() + mock_api.update_profile.assert_called_once() + + # profile update missing json + with ( + patch.object(sys, "argv", ["google-health-cli", "profile", "update"]), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "Please provide raw JSON input" in captured.out + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_settings_commands(mock_load, mock_setup, capsys) -> None: + """Test settings CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + # 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) + + with patch.object(sys, "argv", ["google-health-cli", "settings", "get"]): + main() + 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"} + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(payload), + "settings", + "update", + "--update-mask", + "timeZone", + ], + ): + main() + mock_api.update_settings.assert_called_once() + + # settings update missing json + with ( + patch.object(sys, "argv", ["google-health-cli", "settings", "update"]), + pytest.raises(SystemExit), + ): + main() + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_devices_commands(mock_load, mock_setup, capsys) -> None: + """Test devices CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.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 + with patch.object(sys, "argv", ["google-health-cli", "devices", "list"]): + main() + 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 + with patch.object(sys, "argv", ["google-health-cli", "devices", "get", "dev123"]): + main() + captured = capsys.readouterr() + assert json.loads(captured.out)["id"] == "dev123" + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_subscribers_commands(mock_load, mock_setup, capsys) -> None: + """Test subscribers CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + mock_sub_api = AsyncMock() + mock_api.subscribers = mock_sub_api + + # subscribers list + mock_sub_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscribers": []} + ) + with patch.object(sys, "argv", ["google-health-cli", "subscribers", "list"]): + main() + mock_sub_api.list.assert_called_once() + + # subscribers create with flags and dry-run + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--dry-run", + "subscribers", + "create", + "--endpoint-uri", + "https://example.com/webhook", + "--endpoint-secret", + "secret123", + ], + ): + with pytest.raises(SystemExit) as exit_info: + main() + assert exit_info.value.code == 0 + + # subscribers create with json + payload = { + "name": "projects/me/subscribers/sub1", + "endpointUri": "https://example.com/webhook", + "endpointAuthorization": {"secret": "secret123"}, + "subscriberConfigs": [{"dataType": "steps"}], + } + mock_sub_api.create.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1"} + ) + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(payload), + "subscribers", + "create", + ], + ): + main() + mock_sub_api.create.assert_called_once() + + # subscribers create missing endpointUri + with ( + patch.object(sys, "argv", ["google-health-cli", "subscribers", "create"]), + pytest.raises(SystemExit), + ): + main() + + # 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"}, + } + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(sub_payload), + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ], + ): + main() + mock_sub_api.patch.assert_called_once() + + # subscribers patch missing json + with ( + patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ], + ), + pytest.raises(SystemExit), + ): + main() + + # subscribers delete + mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscribers", + "delete", + "projects/me/subscribers/sub1", + "--force", + ], + ): + main() + mock_sub_api.delete.assert_called_once() + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_subscriptions_commands(mock_load, mock_setup, capsys) -> None: + """Test subscriptions CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + mock_subscriptions_api = AsyncMock() + mock_api.subscribers.subscriptions = mock_subscriptions_api + + # subscriptions list + mock_subscriptions_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscriptions": []} + ) + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "list", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ], + ): + main() + 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"} + ) + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + "--user", + "users/me", + "--data-types", + "steps", + ], + ): + main() + mock_subscriptions_api.create.assert_called_once() + + # subscriptions create with json + payload = {"user": "users/me", "dataTypes": ["steps"]} + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(payload), + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ], + ): + main() + + # subscriptions create missing user + with ( + patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ], + ), + pytest.raises(SystemExit), + ): + main() + + # 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"], + } + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "--json", + json.dumps(sub_patch_payload), + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ], + ): + main() + + # subscriptions patch missing json + with ( + patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ], + ), + pytest.raises(SystemExit), + ): + main() + + # subscriptions delete + mock_subscriptions_api.delete.return_value = None + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "delete", + "projects/me/subscribers/sub1/subscriptions/s1", + ], + ): + main() + captured = capsys.readouterr() + assert "Deleted subscription" in captured.out + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_identity_and_irn(mock_load, mock_setup, capsys) -> None: + """Test identity and irn CLI subcommands.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + mock_api.get_identity = AsyncMock( + return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"subject": "user123"}) + ) + with patch.object(sys, "argv", ["google-health-cli", "identity", "get"]): + main() + mock_api.get_identity.assert_called_once() + + mock_api.get_irn_profile = AsyncMock( + return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) + ) + with patch.object(sys, "argv", ["google-health-cli", "irn", "get"]): + main() + mock_api.get_irn_profile.assert_called_once() + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_additional_datatypes(mock_load, mock_setup, capsys) -> None: + """Test remaining data type CLI subcommands routing.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + datatypes = [ + ("hydration_log", "hydration-log"), + ("nutrition_log", "nutrition-log"), + ("daily_respiratory_rate", "daily-respiratory-rate"), + ("respiratory_rate_sleep_summary", "respiratory-rate-sleep-summary"), + ("active_energy_burned", "active-energy-burned"), + ("total_calories", "total-calories"), + ("floors", "floors"), + ("daily_resting_heart_rate", "daily-resting-heart-rate"), + ("heart_rate_variability", "heart-rate-variability"), + ("daily_heart_rate_variability", "daily-heart-rate-variability"), + ("altitude", "altitude"), + ("body_fat", "body-fat"), + ("active_minutes", "active-minutes"), + ("active_zone_minutes", "active-zone-minutes"), + ("blood_glucose", "blood-glucose"), + ("core_body_temperature", "core-body-temperature"), + ("sedentary_period", "sedentary-period"), + ("swim_lengths_data", "swim-lengths-data"), + ("run_vo2_max", "run-vo2-max"), + ("activity_level", "activity-level"), + ("time_in_heart_rate_zone", "time-in-heart-rate-zone"), + ("calories_in_heart_rate_zone", "calories-in-heart-rate-zone"), + ("electrocardiogram", "electrocardiogram"), + ("irregular_rhythm_notification", "irregular-rhythm-notification"), + ("oxygen_saturation", "oxygen-saturation"), + ("daily_oxygen_saturation", "daily-oxygen-saturation"), + ("daily_vo2_max", "daily-vo2-max"), + ("daily_heart_rate_zones", "daily-heart-rate-zones"), + ("daily_sleep_temperature_derivations", "daily-sleep-temperature-derivations"), + ("height", "height"), + ("bmi", "bmi"), + ("exercise", "exercise"), + ("distance", "distance"), + ("basal_energy_burned", "basal-energy-burned"), + ("vo2_max", "vo2-max"), + ] + + for attr_name, cmd_name in datatypes: + subapi = AsyncMock() + setattr(mock_api, attr_name, subapi) + subapi._data_type = MagicMock() + subapi._data_type.field_name = attr_name + res_mock = MagicMock( + spec=["data_points", "next_page_token"], + data_points=[], + next_page_token=None, + ) + subapi.list.return_value = res_mock + + with patch.object(sys, "argv", ["google-health-cli", cmd_name, "list"]): + main() + subapi.list.assert_called_once() + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: + """Test datatype get, patch, delete, and rollup edge cases.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + mock_steps_subapi = AsyncMock() + mock_api.steps = mock_steps_subapi + mock_steps_subapi._data_type = MagicMock() + mock_steps_subapi._data_type.field_name = "steps" + + # get + mock_dp = MagicMock() + mock_dp.name = "p1" + mock_dp.data_source = None + mock_dp.data.to_dict.return_value = {"count": 100} + mock_steps_subapi.get.return_value = mock_dp + + with patch.object(sys, "argv", ["google-health-cli", "steps", "get", "p1"]): + main() + mock_steps_subapi.get.assert_called_once_with(data_point_id="p1") + + # patch + mock_steps_subapi.patch.return_value = mock_dp + payload = {"steps": {"count": 200}} + with patch.object( + sys, + "argv", + ["google-health-cli", "--json", json.dumps(payload), "steps", "patch", "p1"], + ): + main() + mock_steps_subapi.patch.assert_called_once() + + # delete + mock_steps_subapi.delete.return_value = None + with patch.object(sys, "argv", ["google-health-cli", "steps", "delete", "p1"]): + main() + mock_steps_subapi.delete.assert_called_once_with("p1") + captured = capsys.readouterr() + assert "Deleted steps point p1" in captured.out + + # rollup unsupported error (sub_api has no daily_rollup method) + del mock_steps_subapi.daily_rollup + with ( + patch.object(sys, "argv", ["google-health-cli", "steps", "rollup"]), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "does not support daily rollups" in captured.out + + # Restore daily_rollup method + mock_steps_subapi.daily_rollup = AsyncMock(return_value=[]) + + # rollup without start_date (fetching settings timezone) + mock_sett = MagicMock() + mock_sett.time_zone = "America/Los_Angeles" + mock_api.get_settings = AsyncMock(return_value=mock_sett) + with patch.object( + sys, "argv", ["google-health-cli", "steps", "rollup", "--days", "2"] + ): + main() + mock_steps_subapi.daily_rollup.assert_called_once() + + # list with --params startTime/endTime/pageSize/pageToken + params = { + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-02T00:00:00Z", + "pageSize": 5, + "pageToken": "tok1", + } + mock_steps_subapi.list.return_value = MagicMock( + spec=["data_points", "next_page_token"], data_points=[], next_page_token=None + ) + with patch.object( + sys, + "argv", + ["google-health-cli", "--params", json.dumps(params), "steps", "list"], + ): + main() + assert mock_steps_subapi.list.called + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_execute_all_pages(mock_load, mock_setup, capsys) -> None: + """Test execute_all_pages streaming output format for --all flag across resource types.""" + mock_load.return_value = ("env", "fake-token") + mock_api = MagicMock() + mock_setup.return_value = mock_api + + # 1. dataPoints list --all + mock_dp = MagicMock() + mock_dp.name = "p1" + mock_dp.data_source = None + mock_dp.data.to_dict.return_value = {"count": 10} + page1 = MagicMock(spec=["data_points"], data_points=[mock_dp]) + + async def async_iter(): + yield page1 + + mock_steps_subapi = AsyncMock() + mock_api.steps = mock_steps_subapi + mock_steps_subapi._data_type = MagicMock() + mock_steps_subapi._data_type.field_name = "steps" + mock_steps_subapi.list.return_value = async_iter() + + with patch.object(sys, "argv", ["google-health-cli", "steps", "list", "--all"]): + main() + captured = capsys.readouterr() + assert '"name": "p1"' in captured.out + + # 2. pairedDevices list --all + dev1 = MagicMock(spec=["to_dict"], to_dict=lambda: {"id": "d1"}) + page_dev = MagicMock(spec=["paired_devices"]) + page_dev.paired_devices = [dev1] + + async def async_iter_dev(): + yield page_dev + + mock_dev_api = AsyncMock() + mock_api.paired_devices = mock_dev_api + mock_dev_api.list.return_value = async_iter_dev() + with patch.object(sys, "argv", ["google-health-cli", "devices", "list", "--all"]): + main() + captured = capsys.readouterr() + assert '"id": "d1"' in captured.out + + # 3. subscribers list --all + sub1 = MagicMock(spec=["to_dict"], to_dict=lambda: {"name": "s1"}) + page_sub = MagicMock(spec=["subscribers"]) + page_sub.subscribers = [sub1] + + async def async_iter_sub(): + yield page_sub + + mock_sub_api = AsyncMock() + mock_api.subscribers = mock_sub_api + mock_sub_api.list.return_value = async_iter_sub() + with patch.object( + sys, "argv", ["google-health-cli", "subscribers", "list", "--all"] + ): + main() + captured = capsys.readouterr() + assert '"name": "s1"' in captured.out + + # 4. subscriptions list --all + subscription1 = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1/subscriptions/s1"} + ) + page_subscription = MagicMock(spec=["subscriptions"]) + page_subscription.subscriptions = [subscription1] + + async def async_iter_subscription(): + yield page_subscription + + mock_subscriptions_api = AsyncMock() + mock_api.subscribers.subscriptions = mock_subscriptions_api + mock_subscriptions_api.list.return_value = async_iter_subscription() + with patch.object( + sys, + "argv", + [ + "google-health-cli", + "subscriptions", + "list", + "--parent-subscriber", + "sub1", + "--all", + ], + ): + main() + captured = capsys.readouterr() + assert '"name": "sub1/subscriptions/s1"' in captured.out + + +def test_serialize_response_reconciled_datapoints() -> None: + """Test serialize_response for reconciled data points and generic objects.""" + from google_health_api.cli.commands import serialize_response + + dp = MagicMock() + dp.name = "p1" + dp.data_source = None + dp.data.to_dict.return_value = {"val": 1} + + rdp = MagicMock() + rdp.data_point = dp + + res = MagicMock(spec=["reconciled_data_points", "next_page_token"]) + res.reconciled_data_points = [rdp] + res.next_page_token = "tok" + + output = serialize_response(res, "test_field") + assert output["reconciledDataPoints"][0]["dataPoint"]["name"] == "p1" + assert output["nextPageToken"] == "tok" + + # Fallback response + assert serialize_response("plain string") == "plain string" + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_exceptions_handling(mock_load, mock_setup, capsys) -> None: + """Test HealthApiException and general Exception handling in async_run_cmd.""" + from google_health_api.exceptions import HealthApiException + + mock_load.return_value = ("env", "fake-token") + + # HealthApiException + mock_setup.side_effect = HealthApiException("API Error") + with ( + patch.object(sys, "argv", ["google-health-cli", "userinfo"]), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "API Error" in captured.out + + # General Exception + mock_setup.side_effect = Exception("Unexpected Boom") + with ( + patch.object(sys, "argv", ["google-health-cli", "userinfo"]), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "Unexpected error: Unexpected Boom" in captured.out + + +@patch("google_health_api.cli.commands.setup_client") +@patch("google_health_api.cli.commands.load_credentials_or_env") +def test_cli_invalid_json_payloads(mock_load, mock_setup, capsys) -> None: + """Test invalid JSON strings provided to --json or --params.""" + mock_load.return_value = ("env", "fake-token") + mock_setup.return_value = MagicMock() + + # Invalid --json + with ( + patch.object( + sys, + "argv", + ["google-health-cli", "--json", "{invalid_json", "steps", "create"], + ), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "Invalid raw JSON payload" in captured.out + + # Invalid --params + with ( + patch.object( + sys, + "argv", + ["google-health-cli", "--params", "{invalid_json", "steps", "list"], + ), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "Invalid --params JSON payload" in captured.out + + +def test_unauthenticated_cli_setup(monkeypatch, capsys) -> None: + """Test error when setup_client finds no auth token/credentials.""" + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + monkeypatch.setattr( + "google_health_api.cli.commands.TOKEN_FILE", "non_existent_token.json" + ) + + with ( + patch.object(sys, "argv", ["google-health-cli", "userinfo"]), + pytest.raises(SystemExit), + ): + main() + captured = capsys.readouterr() + assert "Not logged in" in captured.out From 9e3a8eef3e15b46b7e74b3a4927d07fb1c817543 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:44:46 -0700 Subject: [PATCH 07/24] test: Move test files into tests/cli and tests/model to mirror source structure --- tests/{ => cli}/test_cli.py | 0 tests/{test_cli_schema.py => cli/test_schema.py} | 0 tests/{test_model_pagination.py => model/test_pagination.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => cli}/test_cli.py (100%) rename tests/{test_cli_schema.py => cli/test_schema.py} (100%) rename tests/{test_model_pagination.py => model/test_pagination.py} (100%) diff --git a/tests/test_cli.py b/tests/cli/test_cli.py similarity index 100% rename from tests/test_cli.py rename to tests/cli/test_cli.py diff --git a/tests/test_cli_schema.py b/tests/cli/test_schema.py similarity index 100% rename from tests/test_cli_schema.py rename to tests/cli/test_schema.py diff --git a/tests/test_model_pagination.py b/tests/model/test_pagination.py similarity index 100% rename from tests/test_model_pagination.py rename to tests/model/test_pagination.py From 74203590b240ae107953a52fe96b585eaa2793ea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:46:01 -0700 Subject: [PATCH 08/24] test: Parameterize test_validate_resource_name in test_cli.py --- tests/cli/test_cli.py | 57 +++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index f67fd03..24e29cc 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -11,35 +11,34 @@ from google_health_api.client import GoogleHealthSession -def test_validate_resource_name() -> None: - """Test resource name input validation rules.""" - # Safe names should pass without exceptions - validate_resource_name("safe-id-123") - validate_resource_name("users/me/profile") - - # Reject control characters (ASCII < 32) - with pytest.raises(ValueError, match="control characters"): - validate_resource_name("device\x00id") - - with pytest.raises(ValueError, match="control characters"): - validate_resource_name("device\x1fid") - - # Reject forbidden injection characters - with pytest.raises(ValueError, match="forbidden characters"): - validate_resource_name("id?fields=name") - - with pytest.raises(ValueError, match="forbidden characters"): - validate_resource_name("sub#delete") - - with pytest.raises(ValueError, match="forbidden characters"): - validate_resource_name("escape%2e") - - # Reject path traversals - with pytest.raises(ValueError, match="path traversal"): - validate_resource_name("../parent") - - with pytest.raises(ValueError, match="path traversal"): - validate_resource_name("sub\\path") +@pytest.mark.parametrize( + "name", + [ + "safe-id-123", + "users/me/profile", + ], +) +def test_validate_resource_name_success(name: str) -> None: + """Test resource name input validation with valid inputs.""" + validate_resource_name(name) + + +@pytest.mark.parametrize( + ("name", "match"), + [ + ("device\x00id", "control characters"), + ("device\x1fid", "control characters"), + ("id?fields=name", "forbidden characters"), + ("sub#delete", "forbidden characters"), + ("escape%2e", "forbidden characters"), + ("../parent", "path traversal"), + ("sub\\path", "path traversal"), + ], +) +def test_validate_resource_name_failure(name: str, match: str) -> None: + """Test resource name input validation with invalid inputs raising ValueError.""" + with pytest.raises(ValueError, match=match): + validate_resource_name(name) def test_validate_safe_path(tmp_path) -> None: From fe0a3f887450bf39236ee3ba79e316640fc7e172 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:46:39 -0700 Subject: [PATCH 09/24] test: Move local imports to top level in test_cli.py --- tests/cli/test_cli.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 24e29cc..8074468 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,14 +1,25 @@ """Tests for the Google Health CLI.""" import json +import os import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest +from google_health_api.cli.commands import ( + CliHealthSession, + CredentialsAuth, + EnvAuth, + cmd_login, + fields_var, + load_credentials_or_env, + serialize_response, +) from google_health_api.cli.main import main from google_health_api.cli.validation import validate_resource_name, validate_safe_path from google_health_api.client import GoogleHealthSession +from google_health_api.exceptions import HealthApiException @pytest.mark.parametrize( @@ -43,8 +54,6 @@ def test_validate_resource_name_failure(name: str, match: str) -> None: def test_validate_safe_path(tmp_path) -> None: """Test local path sandboxing validation rules.""" - import os - # Sandbox to current directory cwd = os.path.abspath(os.getcwd()) @@ -326,7 +335,6 @@ def test_cli_userinfo(mock_load, mock_setup, capsys) -> None: @pytest.mark.asyncio async def test_cli_health_session_fields_injection() -> None: """Test dynamic fields parameter injection in CliHealthSession.""" - from google_health_api.cli.commands import CliHealthSession, fields_var auth_mock = AsyncMock() auth_mock.async_get_access_token = AsyncMock(return_value="fake-token") @@ -371,7 +379,6 @@ async def test_cli_health_session_fields_injection() -> None: @pytest.mark.asyncio async def test_credentials_auth_refresh(tmp_path, monkeypatch) -> None: """Test CredentialsAuth refreshing expired credentials.""" - from google_health_api.cli.commands import CredentialsAuth token_file = tmp_path / "token.json" monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) @@ -393,7 +400,6 @@ async def test_credentials_auth_refresh(tmp_path, monkeypatch) -> None: @pytest.mark.asyncio async def test_env_auth() -> None: """Test EnvAuth token retrieval.""" - from google_health_api.cli.commands import EnvAuth session_mock = MagicMock() auth = EnvAuth(session_mock, "env-secret-token") @@ -403,7 +409,6 @@ async def test_env_auth() -> None: def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: """Test loading credentials from env vs token file.""" - from google_health_api.cli.commands import load_credentials_or_env # 1. Test env var set monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") @@ -436,7 +441,6 @@ def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: def test_cmd_login_missing_client_secret(tmp_path, monkeypatch, capsys) -> None: """Test login failure when client secret file is missing.""" - from google_health_api.cli.commands import cmd_login monkeypatch.setattr( "google_health_api.cli.commands.CLIENT_SECRET_FILE", @@ -450,7 +454,6 @@ def test_cmd_login_missing_client_secret(tmp_path, monkeypatch, capsys) -> None: def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: """Test login failure in non-interactive environment.""" - from google_health_api.cli.commands import cmd_login secret_file = tmp_path / "client_secret.json" secret_file.write_text(json.dumps({"web": {}})) @@ -467,7 +470,6 @@ def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: def test_cmd_login_web_flow(tmp_path, monkeypatch, capsys) -> None: """Test login web OAuth flow.""" - from google_health_api.cli.commands import cmd_login secret_file = tmp_path / "client_secret.json" secret_file.write_text( @@ -503,7 +505,6 @@ def test_cmd_login_web_flow(tmp_path, monkeypatch, capsys) -> None: def test_cmd_login_installed_app_flow(tmp_path, monkeypatch, capsys) -> None: """Test login installed app flow.""" - from google_health_api.cli.commands import cmd_login secret_file = tmp_path / "client_secret.json" secret_file.write_text(json.dumps({"installed": {}})) @@ -1207,7 +1208,6 @@ async def async_iter_subscription(): def test_serialize_response_reconciled_datapoints() -> None: """Test serialize_response for reconciled data points and generic objects.""" - from google_health_api.cli.commands import serialize_response dp = MagicMock() dp.name = "p1" @@ -1233,7 +1233,6 @@ def test_serialize_response_reconciled_datapoints() -> None: @patch("google_health_api.cli.commands.load_credentials_or_env") def test_cli_exceptions_handling(mock_load, mock_setup, capsys) -> None: """Test HealthApiException and general Exception handling in async_run_cmd.""" - from google_health_api.exceptions import HealthApiException mock_load.return_value = ("env", "fake-token") From 7b7d41a3c5b51678cbaee9ffce3097dcdd213b53 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:47:49 -0700 Subject: [PATCH 10/24] refactor: Expose public data_type property on DataPointSubApi and update CLI/tests to use it --- google_health_api/api.py | 5 +++++ google_health_api/cli/commands.py | 2 +- tests/cli/test_cli.py | 24 ++++++++++++------------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/google_health_api/api.py b/google_health_api/api.py index c7bcb18..67dfed6 100644 --- a/google_health_api/api.py +++ b/google_health_api/api.py @@ -201,6 +201,11 @@ def __init__(self, session: GoogleHealthSession, data_type: DataType[T]) -> None self._session = session self._data_type = data_type + @property + def data_type(self) -> DataType[T]: + """Return the underlying DataType definition.""" + return self._data_type + @property def required_read_scopes(self) -> Sequence[str]: """Return the list of scopes required to read from this API.""" diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index 5248f88..1a2de9c 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -506,7 +506,7 @@ async def handle_datatype_cmd( args.dry_run, "POST" if sub == "create" else "PATCH", path, payload ) - dp = DataPoint.from_api_dict(sub_api._data_type, payload) + dp = DataPoint.from_api_dict(sub_api.data_type, payload) if sub == "create": result = await sub_api.create(dp) else: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 8074468..a283978 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -160,8 +160,8 @@ def test_cli_raw_json_input(mock_load, mock_setup, capsys) -> None: mock_steps_subapi = AsyncMock() mock_api.steps = mock_steps_subapi - mock_steps_subapi._data_type = MagicMock() - mock_steps_subapi._data_type.field_name = "steps" + mock_steps_subapi.data_type = MagicMock() + mock_steps_subapi.data_type.field_name = "steps" # Setup mock return value for create mock_res = MagicMock() @@ -218,8 +218,8 @@ def test_cli_new_datapoints(mock_load, mock_setup, capsys) -> None: # Mock sleep subapi mock_sleep_subapi = AsyncMock() mock_api.sleep = mock_sleep_subapi - mock_sleep_subapi._data_type = MagicMock() - mock_sleep_subapi._data_type.field_name = "sleep" + mock_sleep_subapi.data_type = MagicMock() + mock_sleep_subapi.data_type.field_name = "sleep" # Setup mock return value for list sleep mock_sleep_res = MagicMock(spec=["to_dict"]) @@ -256,8 +256,8 @@ def test_cli_weight_rollup(mock_load, mock_setup, capsys) -> None: mock_weight_subapi = AsyncMock() mock_api.weight = mock_weight_subapi - mock_weight_subapi._data_type = MagicMock() - mock_weight_subapi._data_type.field_name = "weight" + mock_weight_subapi.data_type = MagicMock() + mock_weight_subapi.data_type.field_name = "weight" # Setup mock return value for weight daily_rollup mock_rollup_point = MagicMock() @@ -1014,8 +1014,8 @@ def test_cli_additional_datatypes(mock_load, mock_setup, capsys) -> None: for attr_name, cmd_name in datatypes: subapi = AsyncMock() setattr(mock_api, attr_name, subapi) - subapi._data_type = MagicMock() - subapi._data_type.field_name = attr_name + subapi.data_type = MagicMock() + subapi.data_type.field_name = attr_name res_mock = MagicMock( spec=["data_points", "next_page_token"], data_points=[], @@ -1038,8 +1038,8 @@ def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: mock_steps_subapi = AsyncMock() mock_api.steps = mock_steps_subapi - mock_steps_subapi._data_type = MagicMock() - mock_steps_subapi._data_type.field_name = "steps" + mock_steps_subapi.data_type = MagicMock() + mock_steps_subapi.data_type.field_name = "steps" # get mock_dp = MagicMock() @@ -1133,8 +1133,8 @@ async def async_iter(): mock_steps_subapi = AsyncMock() mock_api.steps = mock_steps_subapi - mock_steps_subapi._data_type = MagicMock() - mock_steps_subapi._data_type.field_name = "steps" + mock_steps_subapi.data_type = MagicMock() + mock_steps_subapi.data_type.field_name = "steps" mock_steps_subapi.list.return_value = async_iter() with patch.object(sys, "argv", ["google-health-cli", "steps", "list", "--all"]): From e56d828338c6fcf869bff295ec4f122c44da249f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:51:45 -0700 Subject: [PATCH 11/24] test: Parameterize test_error_detail_variations in test_client.py --- tests/test_client.py | 87 +++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index b4dde71..12b6c34 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -193,48 +193,45 @@ async def test_raise_for_status_generic_client_error( await session.get("v1/test") -async def test_error_detail_variations() -> None: - """Test _error_detail parsing under various status codes and JSON formats.""" - # 1. Status < 400 -> None - resp_200 = AsyncMock(spec=aiohttp.ClientResponse) - resp_200.status = 200 - assert await GoogleHealthSession._error_detail(resp_200) is None - - # 2. Status >= 400 but text() raises ClientError -> None - resp_error_text = AsyncMock(spec=aiohttp.ClientResponse) - resp_error_text.status = 400 - resp_error_text.text.side_effect = ClientError("Stream closed") - assert await GoogleHealthSession._error_detail(resp_error_text) is None - - # 3. Valid JSON with partial error fields - # Only status - resp_status_only = AsyncMock(spec=aiohttp.ClientResponse) - resp_status_only.status = 400 - resp_status_only.text.return_value = '{"error": {"status": "INVALID"}}' - assert await GoogleHealthSession._error_detail(resp_status_only) == "INVALID" - - # Only code - resp_code_only = AsyncMock(spec=aiohttp.ClientResponse) - resp_code_only.status = 400 - resp_code_only.text.return_value = '{"error": {"code": 404}}' - assert await GoogleHealthSession._error_detail(resp_code_only) == "(404)" - - # Only message - resp_msg_only = AsyncMock(spec=aiohttp.ClientResponse) - resp_msg_only.status = 400 - resp_msg_only.text.return_value = '{"error": {"message": "Resource not found"}}' - assert ( - await GoogleHealthSession._error_detail(resp_msg_only) == "Resource not found" - ) - - # Empty error object -> None - resp_empty_error = AsyncMock(spec=aiohttp.ClientResponse) - resp_empty_error.status = 400 - resp_empty_error.text.return_value = '{"error": {}}' - assert await GoogleHealthSession._error_detail(resp_empty_error) is None - - # 4. Invalid JSON / HTML response -> None - resp_html = AsyncMock(spec=aiohttp.ClientResponse) - resp_html.status = 502 - resp_html.text.return_value = "Bad Gateway" - assert await GoogleHealthSession._error_detail(resp_html) is None +@pytest.mark.asyncio +async def test_error_detail_status_less_than_400() -> None: + """Test that _error_detail returns None for success status codes (< 400).""" + resp = AsyncMock(spec=aiohttp.ClientResponse) + resp.status = 200 + assert await GoogleHealthSession._error_detail(resp) is None + + +@pytest.mark.asyncio +async def test_error_detail_text_raises_client_error() -> None: + """Test that _error_detail returns None when retrieving response text raises ClientError.""" + resp = AsyncMock(spec=aiohttp.ClientResponse) + resp.status = 400 + resp.text.side_effect = ClientError("Stream closed") + assert await GoogleHealthSession._error_detail(resp) is None + + +@pytest.mark.parametrize( + ("status", "payload", "expected"), + [ + (400, '{"error": {"status": "INVALID"}}', "INVALID"), + (400, '{"error": {"code": 404}}', "(404)"), + (400, '{"error": {"message": "Resource not found"}}', "Resource not found"), + ( + 400, + '{"error": {"status": "INVALID", "code": 400, "message": "Bad request"}}', + "INVALID: (400): Bad request", + ), + (400, '{"error": {}}', None), + (502, "Bad Gateway", None), + (400, "not json", None), + ], +) +@pytest.mark.asyncio +async def test_error_detail_json_variations( + status: int, payload: str, expected: str | None +) -> None: + """Test _error_detail parsing with various JSON structures and invalid payloads.""" + resp = AsyncMock(spec=aiohttp.ClientResponse) + resp.status = status + resp.text.return_value = payload + assert await GoogleHealthSession._error_detail(resp) == expected From 3f10b3d7c1aa3c670e946e211d968defba961d65 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:52:44 -0700 Subject: [PATCH 12/24] refactor: Convert _error_detail to a module-level function --- google_health_api/client.py | 56 ++++++++++++++++++------------------- tests/test_client.py | 8 +++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/google_health_api/client.py b/google_health_api/client.py index 30d194d..b9cc557 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -84,7 +84,7 @@ async def _raise_for_status( cls, resp: aiohttp.ClientResponse ) -> aiohttp.ClientResponse: """Raise exceptions on failure methods.""" - error_detail = await cls._error_detail(resp) + error_detail = await _error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: @@ -100,32 +100,32 @@ async def _raise_for_status( raise HealthApiException(f"Error from API: {err}") from err return resp - @classmethod - async def _error_detail(cls, resp: aiohttp.ClientResponse) -> str | None: - """Returns an error message string from the API response.""" - if resp.status < 400: - return None - try: - result = await resp.text() - except ClientError: - return None - try: - error_response = ErrorResponse.from_json(result) - if error_response and error_response.error: - error_obj = error_response.error - msg = error_obj.message - status = error_obj.status - code = error_obj.code - parts = [] - if status: - parts.append(status) - if code: - parts.append(f"({code})") - if msg: - parts.append(msg) - if parts: - return ": ".join(parts) - except (ValueError, TypeError): - pass +async def _error_detail(resp: aiohttp.ClientResponse) -> str | None: + """Returns an error message string from the API response.""" + if resp.status < 400: return None + try: + result = await resp.text() + except ClientError: + return None + + try: + error_response = ErrorResponse.from_json(result) + if error_response and error_response.error: + error_obj = error_response.error + msg = error_obj.message + status = error_obj.status + code = error_obj.code + parts = [] + if status: + parts.append(status) + if code: + parts.append(f"({code})") + if msg: + parts.append(msg) + if parts: + return ": ".join(parts) + except (ValueError, TypeError): + pass + return None diff --git a/tests/test_client.py b/tests/test_client.py index 12b6c34..98281ef 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ from aiohttp import ClientError, ClientResponseError from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession +from google_health_api.client import GoogleHealthSession, _error_detail from google_health_api.exceptions import ( HealthApiException, HealthApiForbiddenException, @@ -198,7 +198,7 @@ async def test_error_detail_status_less_than_400() -> None: """Test that _error_detail returns None for success status codes (< 400).""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 200 - assert await GoogleHealthSession._error_detail(resp) is None + assert await _error_detail(resp) is None @pytest.mark.asyncio @@ -207,7 +207,7 @@ async def test_error_detail_text_raises_client_error() -> None: resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 400 resp.text.side_effect = ClientError("Stream closed") - assert await GoogleHealthSession._error_detail(resp) is None + assert await _error_detail(resp) is None @pytest.mark.parametrize( @@ -234,4 +234,4 @@ async def test_error_detail_json_variations( resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = status resp.text.return_value = payload - assert await GoogleHealthSession._error_detail(resp) == expected + assert await _error_detail(resp) == expected From 6ef1ac5956cf7395f7cd4d5dac1d332ef0f56ebf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:53:14 -0700 Subject: [PATCH 13/24] refactor: Expose error_detail as a public module-level function and resolve shadowing --- google_health_api/client.py | 8 ++++---- tests/test_client.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/google_health_api/client.py b/google_health_api/client.py index b9cc557..d12d175 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -84,13 +84,13 @@ async def _raise_for_status( cls, resp: aiohttp.ClientResponse ) -> aiohttp.ClientResponse: """Raise exceptions on failure methods.""" - error_detail = await _error_detail(resp) + detail = await error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: error_message = f"{err.message} response from API ({resp.status})" - if error_detail: - error_message += f": {error_detail}" + if detail: + error_message += f": {detail}" if err.status == HTTPStatus.FORBIDDEN: raise HealthApiForbiddenException(error_message) if err.status == HTTPStatus.UNAUTHORIZED: @@ -101,7 +101,7 @@ async def _raise_for_status( return resp -async def _error_detail(resp: aiohttp.ClientResponse) -> str | None: +async def error_detail(resp: aiohttp.ClientResponse) -> str | None: """Returns an error message string from the API response.""" if resp.status < 400: return None diff --git a/tests/test_client.py b/tests/test_client.py index 98281ef..28f50a8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ from aiohttp import ClientError, ClientResponseError from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession, _error_detail +from google_health_api.client import GoogleHealthSession, error_detail from google_health_api.exceptions import ( HealthApiException, HealthApiForbiddenException, @@ -195,19 +195,19 @@ async def test_raise_for_status_generic_client_error( @pytest.mark.asyncio async def test_error_detail_status_less_than_400() -> None: - """Test that _error_detail returns None for success status codes (< 400).""" + """Test that error_detail returns None for success status codes (< 400).""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 200 - assert await _error_detail(resp) is None + assert await error_detail(resp) is None @pytest.mark.asyncio async def test_error_detail_text_raises_client_error() -> None: - """Test that _error_detail returns None when retrieving response text raises ClientError.""" + """Test that error_detail returns None when retrieving response text raises ClientError.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 400 resp.text.side_effect = ClientError("Stream closed") - assert await _error_detail(resp) is None + assert await error_detail(resp) is None @pytest.mark.parametrize( @@ -230,8 +230,8 @@ async def test_error_detail_text_raises_client_error() -> None: async def test_error_detail_json_variations( status: int, payload: str, expected: str | None ) -> None: - """Test _error_detail parsing with various JSON structures and invalid payloads.""" + """Test error_detail parsing with various JSON structures and invalid payloads.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = status resp.text.return_value = payload - assert await _error_detail(resp) == expected + assert await error_detail(resp) == expected From 20c603542d13944bbc843d69806035714a402591 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:53:32 -0700 Subject: [PATCH 14/24] refactor: Add __all__ export list to client.py --- google_health_api/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/google_health_api/client.py b/google_health_api/client.py index d12d175..65ac4b3 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) AUTHORIZATION_HEADER = "Authorization" +__all__ = ["GoogleHealthSession", "error_detail"] + class GoogleHealthSession: """Session wrapper that handles authentication and network requests.""" From cb375daa6a9f00b1cab6c7dc55a9d143595e6d1a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:53:51 -0700 Subject: [PATCH 15/24] Revert "refactor: Add __all__ export list to client.py" This reverts commit 20c603542d13944bbc843d69806035714a402591. --- google_health_api/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/google_health_api/client.py b/google_health_api/client.py index 65ac4b3..d12d175 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) AUTHORIZATION_HEADER = "Authorization" -__all__ = ["GoogleHealthSession", "error_detail"] - class GoogleHealthSession: """Session wrapper that handles authentication and network requests.""" From 9a6d1d9f5ab34a2a648f5707cc4e4805db50cdb6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:54:53 -0700 Subject: [PATCH 16/24] refactor: Revert _error_detail back to private function to keep it out of public API docs --- google_health_api/client.py | 4 ++-- tests/test_client.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/google_health_api/client.py b/google_health_api/client.py index d12d175..637e0db 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -84,7 +84,7 @@ async def _raise_for_status( cls, resp: aiohttp.ClientResponse ) -> aiohttp.ClientResponse: """Raise exceptions on failure methods.""" - detail = await error_detail(resp) + detail = await _error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: @@ -101,7 +101,7 @@ async def _raise_for_status( return resp -async def error_detail(resp: aiohttp.ClientResponse) -> str | None: +async def _error_detail(resp: aiohttp.ClientResponse) -> str | None: """Returns an error message string from the API response.""" if resp.status < 400: return None diff --git a/tests/test_client.py b/tests/test_client.py index 28f50a8..98281ef 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ from aiohttp import ClientError, ClientResponseError from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession, error_detail +from google_health_api.client import GoogleHealthSession, _error_detail from google_health_api.exceptions import ( HealthApiException, HealthApiForbiddenException, @@ -195,19 +195,19 @@ async def test_raise_for_status_generic_client_error( @pytest.mark.asyncio async def test_error_detail_status_less_than_400() -> None: - """Test that error_detail returns None for success status codes (< 400).""" + """Test that _error_detail returns None for success status codes (< 400).""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 200 - assert await error_detail(resp) is None + assert await _error_detail(resp) is None @pytest.mark.asyncio async def test_error_detail_text_raises_client_error() -> None: - """Test that error_detail returns None when retrieving response text raises ClientError.""" + """Test that _error_detail returns None when retrieving response text raises ClientError.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 400 resp.text.side_effect = ClientError("Stream closed") - assert await error_detail(resp) is None + assert await _error_detail(resp) is None @pytest.mark.parametrize( @@ -230,8 +230,8 @@ async def test_error_detail_text_raises_client_error() -> None: async def test_error_detail_json_variations( status: int, payload: str, expected: str | None ) -> None: - """Test error_detail parsing with various JSON structures and invalid payloads.""" + """Test _error_detail parsing with various JSON structures and invalid payloads.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = status resp.text.return_value = payload - assert await error_detail(resp) == expected + assert await _error_detail(resp) == expected From 0c121f0ba672e1f5a3dab6bb38b525c0c7e1b8d5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 25 Jul 2026 20:55:40 -0700 Subject: [PATCH 17/24] Revert "refactor: Revert _error_detail back to private function to keep it out of public API docs" This reverts commit 9a6d1d9f5ab34a2a648f5707cc4e4805db50cdb6. --- google_health_api/client.py | 4 ++-- tests/test_client.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/google_health_api/client.py b/google_health_api/client.py index 637e0db..d12d175 100644 --- a/google_health_api/client.py +++ b/google_health_api/client.py @@ -84,7 +84,7 @@ async def _raise_for_status( cls, resp: aiohttp.ClientResponse ) -> aiohttp.ClientResponse: """Raise exceptions on failure methods.""" - detail = await _error_detail(resp) + detail = await error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: @@ -101,7 +101,7 @@ async def _raise_for_status( return resp -async def _error_detail(resp: aiohttp.ClientResponse) -> str | None: +async def error_detail(resp: aiohttp.ClientResponse) -> str | None: """Returns an error message string from the API response.""" if resp.status < 400: return None diff --git a/tests/test_client.py b/tests/test_client.py index 98281ef..28f50a8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ from aiohttp import ClientError, ClientResponseError from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession, _error_detail +from google_health_api.client import GoogleHealthSession, error_detail from google_health_api.exceptions import ( HealthApiException, HealthApiForbiddenException, @@ -195,19 +195,19 @@ async def test_raise_for_status_generic_client_error( @pytest.mark.asyncio async def test_error_detail_status_less_than_400() -> None: - """Test that _error_detail returns None for success status codes (< 400).""" + """Test that error_detail returns None for success status codes (< 400).""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 200 - assert await _error_detail(resp) is None + assert await error_detail(resp) is None @pytest.mark.asyncio async def test_error_detail_text_raises_client_error() -> None: - """Test that _error_detail returns None when retrieving response text raises ClientError.""" + """Test that error_detail returns None when retrieving response text raises ClientError.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = 400 resp.text.side_effect = ClientError("Stream closed") - assert await _error_detail(resp) is None + assert await error_detail(resp) is None @pytest.mark.parametrize( @@ -230,8 +230,8 @@ async def test_error_detail_text_raises_client_error() -> None: async def test_error_detail_json_variations( status: int, payload: str, expected: str | None ) -> None: - """Test _error_detail parsing with various JSON structures and invalid payloads.""" + """Test error_detail parsing with various JSON structures and invalid payloads.""" resp = AsyncMock(spec=aiohttp.ClientResponse) resp.status = status resp.text.return_value = payload - assert await _error_detail(resp) == expected + assert await error_detail(resp) == expected From b5590024015ab08a3bdd0d7e111d6002826e4a0a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 06:54:59 -0700 Subject: [PATCH 18/24] refactor: Use pathlib.Path instead of os in validation code and tests --- google_health_api/cli/validation.py | 8 +++--- tests/cli/test_cli.py | 44 ++++++++++++++--------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/google_health_api/cli/validation.py b/google_health_api/cli/validation.py index 93861c1..c8ea21e 100644 --- a/google_health_api/cli/validation.py +++ b/google_health_api/cli/validation.py @@ -1,8 +1,8 @@ """Input validation and dry-run mechanism for Google Health CLI.""" import json -import os import sys +from pathlib import Path from typing import Any @@ -50,11 +50,11 @@ def validate_safe_path(path: str) -> None: if ".." in path: raise ValueError("Path traversal sequence '..' is forbidden.") - cwd = os.path.abspath(os.getcwd()) - abs_path = os.path.abspath(path) + cwd = Path.cwd().resolve() + abs_path = Path(path).resolve() # Check if the absolute path falls outside the current working directory - if not abs_path.startswith(cwd): + if not abs_path.is_relative_to(cwd): raise ValueError( f"Path '{path}' resolves to '{abs_path}', which falls outside the sandboxed directory '{cwd}'." ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index a283978..6dbdd45 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,8 +1,8 @@ """Tests for the Google Health CLI.""" import json -import os import sys +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -55,7 +55,7 @@ def test_validate_resource_name_failure(name: str, match: str) -> None: def test_validate_safe_path(tmp_path) -> None: """Test local path sandboxing validation rules.""" # Sandbox to current directory - cwd = os.path.abspath(os.getcwd()) + cwd = Path.cwd().resolve() # Safe relative path resolving within cwd validate_safe_path("config.json") @@ -66,7 +66,7 @@ def test_validate_safe_path(tmp_path) -> None: validate_safe_path("../../outside.json") # Reject absolute path pointing outside CWD - outside_dir = os.path.abspath(os.path.join(cwd, "..", "outside_root.json")) + outside_dir = str((cwd.parent / "outside_root.json").resolve()) with pytest.raises(ValueError, match="falls outside"): validate_safe_path(outside_dir) @@ -468,7 +468,8 @@ def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: assert "headless environment" in captured.out -def test_cmd_login_web_flow(tmp_path, monkeypatch, capsys) -> None: +@patch("google_health_api.cli.commands.Flow") +def test_cmd_login_web_flow(mock_flow_cls, tmp_path, monkeypatch, capsys) -> None: """Test login web OAuth flow.""" secret_file = tmp_path / "client_secret.json" @@ -483,27 +484,28 @@ def test_cmd_login_web_flow(tmp_path, monkeypatch, capsys) -> None: ) monkeypatch.setattr("sys.stdin.isatty", lambda: True) - mock_oauth_flow = MagicMock() mock_flow = MagicMock() - mock_oauth_flow.Flow.from_client_secrets_file.return_value = mock_flow + mock_flow_cls.from_client_secrets_file.return_value = mock_flow mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") mock_creds = MagicMock() mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.credentials = mock_creds - with patch.dict("sys.modules", {"google_auth_oauthlib.flow": mock_oauth_flow}): - # Empty response -> error - monkeypatch.setattr("builtins.input", lambda prompt="": "") - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - - # Valid auth code response - monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") + # Empty response -> error + monkeypatch.setattr("builtins.input", lambda prompt="": "") + with pytest.raises(SystemExit): cmd_login(MagicMock()) - assert mock_flow.fetch_token.called + + # Valid auth code response + monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") + cmd_login(MagicMock()) + assert mock_flow.fetch_token.called -def test_cmd_login_installed_app_flow(tmp_path, monkeypatch, capsys) -> None: +@patch("google_health_api.cli.commands.InstalledAppFlow") +def test_cmd_login_installed_app_flow( + mock_flow_cls, tmp_path, monkeypatch, capsys +) -> None: """Test login installed app flow.""" secret_file = tmp_path / "client_secret.json" @@ -516,17 +518,15 @@ def test_cmd_login_installed_app_flow(tmp_path, monkeypatch, capsys) -> None: ) monkeypatch.setattr("sys.stdin.isatty", lambda: True) - mock_oauth_flow = MagicMock() mock_flow = MagicMock() - mock_oauth_flow.InstalledAppFlow.from_client_secrets_file.return_value = mock_flow + mock_flow_cls.from_client_secrets_file.return_value = mock_flow mock_creds = MagicMock() mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.run_local_server.return_value = mock_creds - with patch.dict("sys.modules", {"google_auth_oauthlib.flow": mock_oauth_flow}): - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "Logged in successfully" in captured.out + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "Logged in successfully" in captured.out @patch("google_health_api.cli.commands.setup_client") From 1bb58a22c46033f467bc6b9185a108523fa1c462 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 06:57:40 -0700 Subject: [PATCH 19/24] refactor: Split schema tests, add run_cli helper, combine withs, use fixtures, and type test parameters --- tests/cli/test_cli.py | 754 ++++++++++++++++++++---------------------- 1 file changed, 364 insertions(+), 390 deletions(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 6dbdd45..f42bb05 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -2,6 +2,7 @@ import json import sys +from collections.abc import Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -22,28 +23,71 @@ from google_health_api.exceptions import HealthApiException +def run_cli(args: list[str]) -> None: + """Helper to run the CLI with specific arguments.""" + with patch.object(sys, "argv", ["google-health-cli", *args]): + main() + + +@pytest.fixture +def mock_load_credentials() -> Generator[MagicMock]: + """Fixture to mock load_credentials_or_env.""" + with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: + yield mock + + +@pytest.fixture +def mock_setup_client() -> Generator[MagicMock]: + """Fixture to mock setup_client.""" + with patch("google_health_api.cli.commands.setup_client") as mock: + yield mock + + +@pytest.fixture +def mock_flow_cls() -> Generator[MagicMock]: + """Fixture to mock google_auth_oauthlib.flow.Flow.""" + with patch("google_health_api.cli.commands.Flow") as mock: + yield mock + + +@pytest.fixture +def mock_installed_flow_cls() -> Generator[MagicMock]: + """Fixture to mock google_auth_oauthlib.flow.InstalledAppFlow.""" + with patch("google_health_api.cli.commands.InstalledAppFlow") as mock: + yield mock + + @pytest.mark.parametrize( "name", [ - "safe-id-123", "users/me/profile", + "users/me/settings", + "users/me/dataTypes/steps/dataPoints/p123", + "users/me/dataTypes/weight/dataPoints/w_456-789", + "projects/my-project/subscribers/sub-123", + "projects/my-project/subscribers/sub-123/subscriptions/sub_id_456", + "simple-id", + "id_with_dash-123", + "", ], ) def test_validate_resource_name_success(name: str) -> None: """Test resource name input validation with valid inputs.""" + # Should not raise any exception validate_resource_name(name) @pytest.mark.parametrize( ("name", "match"), [ - ("device\x00id", "control characters"), - ("device\x1fid", "control characters"), - ("id?fields=name", "forbidden characters"), - ("sub#delete", "forbidden characters"), - ("escape%2e", "forbidden characters"), - ("../parent", "path traversal"), - ("sub\\path", "path traversal"), + ("users/me/dataTypes/steps/dataPoints/p123\n", "control characters"), + ("users/me/dataTypes/steps/dataPoints/p123\x00", "control characters"), + ("users/me/dataTypes/steps/dataPoints/p123\r", "control characters"), + ("users/me/dataTypes/steps/dataPoints/p123?", "forbidden characters"), + ("users/me/dataTypes/steps/dataPoints/p123#", "forbidden characters"), + ("users/me/dataTypes/steps/dataPoints/p123%", "forbidden characters"), + ("users/me/dataTypes/steps/dataPoints/../../p123", "path traversal"), + ("users/me\\dataPoints", "path traversal"), ], ) def test_validate_resource_name_failure(name: str, match: str) -> None: @@ -52,7 +96,7 @@ def test_validate_resource_name_failure(name: str, match: str) -> None: validate_resource_name(name) -def test_validate_safe_path(tmp_path) -> None: +def test_validate_safe_path(tmp_path: Path) -> None: """Test local path sandboxing validation rules.""" # Sandbox to current directory cwd = Path.cwd().resolve() @@ -71,37 +115,39 @@ def test_validate_safe_path(tmp_path) -> None: validate_safe_path(outside_dir) -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_schema(mock_load, capsys) -> None: - """Test schema introspection commands.""" - # Test listing all schemas - with patch.object(sys, "argv", ["google-health-cli", "schema"]): - main() +def test_cli_schema_list( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Test listing all schemas.""" + run_cli(["schema"]) captured = capsys.readouterr() schemas = json.loads(captured.out) assert "steps.list" in schemas assert "profile.get" in schemas assert "userinfo" in schemas - # Test retrieving a specific command schema - with patch.object(sys, "argv", ["google-health-cli", "schema", "steps.list"]): - main() + +def test_cli_schema_details( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Test retrieving a specific command schema.""" + run_cli(["schema", "steps.list"]) captured = capsys.readouterr() schema_details = json.loads(captured.out) assert schema_details["method"] == "GET" assert schema_details["endpoint"] == "v4/users/{user}/dataTypes/steps/dataPoints" -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_validation_rejection(mock_load, capsys) -> None: +def test_cli_validation_rejection( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: """Test that CLI validation errors are captured and output as JSON errors.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") # Pass an invalid device ID containing forbidden character "?" - with patch.object(sys, "argv", ["google-health-cli", "devices", "get", "dev?id"]): - with pytest.raises(SystemExit) as exit_info: - main() - assert exit_info.value.code == 1 + with pytest.raises(SystemExit) as exit_info: + run_cli(["devices", "get", "dev?id"]) + assert exit_info.value.code == 1 captured = capsys.readouterr() err_json = json.loads(captured.out) @@ -110,10 +156,11 @@ def test_cli_validation_rejection(mock_load, capsys) -> None: assert "forbidden characters" in err_json["error"]["message"] -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_dry_run_mutations(mock_load, capsys) -> None: +def test_cli_dry_run_mutations( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: """Test dry-run intercepting mutating operations.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") # Test dry run for steps create payload = { @@ -125,21 +172,17 @@ def test_cli_dry_run_mutations(mock_load, capsys) -> None: }, } } - with patch.object( - sys, - "argv", - [ - "google-health-cli", - "--dry-run", - "--json", - json.dumps(payload), - "steps", - "create", - ], - ): - with pytest.raises(SystemExit) as exit_info: - main() - assert exit_info.value.code == 0 + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "--json", + json.dumps(payload), + "steps", + "create", + ] + ) + assert exit_info.value.code == 0 captured = capsys.readouterr() dry_run_out = json.loads(captured.out) @@ -148,15 +191,17 @@ def test_cli_dry_run_mutations(mock_load, capsys) -> None: assert dry_run_out["payload"] == payload -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_raw_json_input(mock_load, mock_setup, capsys) -> None: +def test_cli_raw_json_input( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test passing raw --json input payload is correctly processed.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") # Setup mock API mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_steps_subapi = AsyncMock() mock_api.steps = mock_steps_subapi @@ -186,18 +231,14 @@ def test_cli_raw_json_input(mock_load, mock_setup, capsys) -> None: } } - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(payload), "steps", "create", - ], - ): - main() + ] + ) captured = capsys.readouterr() res_json = json.loads(captured.out) @@ -206,14 +247,16 @@ def test_cli_raw_json_input(mock_load, mock_setup, capsys) -> None: mock_steps_subapi.create.assert_called_once() -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_new_datapoints(mock_load, mock_setup, capsys) -> None: +def test_cli_new_datapoints( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test that the new datapoint commands are correctly routed in the CLI.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api # Mock sleep subapi mock_sleep_subapi = AsyncMock() @@ -229,30 +272,28 @@ def test_cli_new_datapoints(mock_load, mock_setup, capsys) -> None: } mock_sleep_subapi.list.return_value = mock_sleep_res - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "sleep", "list", "--days", "5", - ], - ): - main() + ] + ) mock_sleep_subapi.list.assert_called_once() -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_weight_rollup(mock_load, mock_setup, capsys) -> None: +def test_cli_weight_rollup( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test that the weight rollup command is correctly routed and executed in the CLI.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_weight_subapi = AsyncMock() mock_api.weight = mock_weight_subapi @@ -276,20 +317,16 @@ def test_cli_weight_rollup(mock_load, mock_setup, capsys) -> None: mock_weight_subapi.daily_rollup.return_value = [mock_rollup_point] - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "weight", "rollup", "--start-date", "2026-06-22", "--end-date", "2026-06-23", - ], - ): - main() + ] + ) mock_weight_subapi.daily_rollup.assert_called_once() @@ -300,14 +337,16 @@ def test_cli_weight_rollup(mock_load, mock_setup, capsys) -> None: assert res_json["rollupDataPoints"][0]["weight"]["weightGramsAvg"] == 75000.0 -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_userinfo(mock_load, mock_setup, capsys) -> None: +def test_cli_userinfo( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test that the userinfo command is correctly routed and executed in the CLI.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_userinfo = MagicMock(spec=["to_dict"]) mock_userinfo.to_dict.return_value = { @@ -317,8 +356,7 @@ def test_cli_userinfo(mock_load, mock_setup, capsys) -> None: } mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) - with patch.object(sys, "argv", ["google-health-cli", "userinfo"]): - main() + run_cli(["userinfo"]) mock_api.get_user_info.assert_called_once() captured = capsys.readouterr() @@ -377,7 +415,9 @@ async def test_cli_health_session_fields_injection() -> None: @pytest.mark.asyncio -async def test_credentials_auth_refresh(tmp_path, monkeypatch) -> None: +async def test_credentials_auth_refresh( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Test CredentialsAuth refreshing expired credentials.""" token_file = tmp_path / "token.json" @@ -407,7 +447,9 @@ async def test_env_auth() -> None: assert token == "env-secret-token" -def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: +def test_load_credentials_or_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Test loading credentials from env vs token file.""" # 1. Test env var set @@ -429,6 +471,7 @@ def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: } token_file.write_text(json.dumps(token_data)) res_file = load_credentials_or_env() + assert res_file is not None assert res_file[0] == "file" assert res_file[1].token == "file-tok" @@ -436,10 +479,13 @@ def test_load_credentials_or_env(tmp_path, monkeypatch) -> None: token_data["expiry"] = "2026-12-31T23:59:59+00:00" token_file.write_text(json.dumps(token_data)) res_file2 = load_credentials_or_env() + assert res_file2 is not None assert res_file2[0] == "file" -def test_cmd_login_missing_client_secret(tmp_path, monkeypatch, capsys) -> None: +def test_cmd_login_missing_client_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """Test login failure when client secret file is missing.""" monkeypatch.setattr( @@ -452,7 +498,9 @@ def test_cmd_login_missing_client_secret(tmp_path, monkeypatch, capsys) -> None: assert "not found" in captured.out -def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: +def test_cmd_login_not_tty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """Test login failure in non-interactive environment.""" secret_file = tmp_path / "client_secret.json" @@ -468,8 +516,12 @@ def test_cmd_login_not_tty(tmp_path, monkeypatch, capsys) -> None: assert "headless environment" in captured.out -@patch("google_health_api.cli.commands.Flow") -def test_cmd_login_web_flow(mock_flow_cls, tmp_path, monkeypatch, capsys) -> None: +def test_cmd_login_web_flow( + mock_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: """Test login web OAuth flow.""" secret_file = tmp_path / "client_secret.json" @@ -502,9 +554,11 @@ def test_cmd_login_web_flow(mock_flow_cls, tmp_path, monkeypatch, capsys) -> Non assert mock_flow.fetch_token.called -@patch("google_health_api.cli.commands.InstalledAppFlow") def test_cmd_login_installed_app_flow( - mock_flow_cls, tmp_path, monkeypatch, capsys + mock_installed_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: """Test login installed app flow.""" @@ -519,7 +573,7 @@ def test_cmd_login_installed_app_flow( monkeypatch.setattr("sys.stdin.isatty", lambda: True) mock_flow = MagicMock() - mock_flow_cls.from_client_secrets_file.return_value = mock_flow + mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow mock_creds = MagicMock() mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.run_local_server.return_value = mock_creds @@ -529,13 +583,15 @@ def test_cmd_login_installed_app_flow( assert "Logged in successfully" in captured.out -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_profile_commands(mock_load, mock_setup, capsys) -> None: +def test_cli_profile_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test profile CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api # profile get mock_prof = MagicMock(spec=["to_dict"]) @@ -545,113 +601,97 @@ def test_cli_profile_commands(mock_load, mock_setup, capsys) -> None: } mock_api.get_profile = AsyncMock(return_value=mock_prof) - with patch.object(sys, "argv", ["google-health-cli", "profile", "get"]): - main() + run_cli(["profile", "get"]) captured = capsys.readouterr() assert json.loads(captured.out)["displayName"] == "Alice" # profile update with json & update-mask & dry-run payload = {"name": "users/me/profile", "displayName": "Bob"} - with patch.object( - sys, - "argv", - [ - "google-health-cli", - "--dry-run", - "--json", - json.dumps(payload), - "--params", - json.dumps({"updateMask": "displayName"}), - "profile", - "update", - ], - ): - with pytest.raises(SystemExit) as exit_info: - main() - assert exit_info.value.code == 0 + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "--json", + json.dumps(payload), + "--params", + json.dumps({"updateMask": "displayName"}), + "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) - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(payload), "profile", "update", "--update-mask", "displayName", - ], - ): - main() + ] + ) mock_api.update_profile.assert_called_once() # profile update missing json - with ( - patch.object(sys, "argv", ["google-health-cli", "profile", "update"]), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli(["profile", "update"]) captured = capsys.readouterr() assert "Please provide raw JSON input" in captured.out -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_settings_commands(mock_load, mock_setup, capsys) -> None: +def test_cli_settings_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test settings CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api # 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) - with patch.object(sys, "argv", ["google-health-cli", "settings", "get"]): - main() + run_cli(["settings", "get"]) 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"} - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(payload), "settings", "update", "--update-mask", "timeZone", - ], - ): - main() + ] + ) mock_api.update_settings.assert_called_once() # settings update missing json - with ( - patch.object(sys, "argv", ["google-health-cli", "settings", "update"]), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli(["settings", "update"]) -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_devices_commands(mock_load, mock_setup, capsys) -> None: +def test_cli_devices_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test devices CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_devices_api = AsyncMock() mock_api.paired_devices = mock_devices_api @@ -660,8 +700,7 @@ def test_cli_devices_commands(mock_load, mock_setup, capsys) -> None: mock_dev_res = MagicMock(spec=["to_dict"]) mock_dev_res.to_dict.return_value = {"pairedDevices": []} mock_devices_api.list.return_value = mock_dev_res - with patch.object(sys, "argv", ["google-health-cli", "devices", "list"]): - main() + run_cli(["devices", "list"]) mock_devices_api.list.assert_called_once() capsys.readouterr() @@ -669,19 +708,20 @@ def test_cli_devices_commands(mock_load, mock_setup, capsys) -> None: mock_dev = MagicMock(spec=["to_dict"]) mock_dev.to_dict.return_value = {"id": "dev123"} mock_devices_api.get.return_value = mock_dev - with patch.object(sys, "argv", ["google-health-cli", "devices", "get", "dev123"]): - main() + run_cli(["devices", "get", "dev123"]) captured = capsys.readouterr() assert json.loads(captured.out)["id"] == "dev123" -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_subscribers_commands(mock_load, mock_setup, capsys) -> None: +def test_cli_subscribers_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test subscribers CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_sub_api = AsyncMock() mock_api.subscribers = mock_sub_api @@ -690,28 +730,23 @@ def test_cli_subscribers_commands(mock_load, mock_setup, capsys) -> None: mock_sub_api.list.return_value = MagicMock( spec=["to_dict"], to_dict=lambda: {"subscribers": []} ) - with patch.object(sys, "argv", ["google-health-cli", "subscribers", "list"]): - main() + run_cli(["subscribers", "list"]) mock_sub_api.list.assert_called_once() # subscribers create with flags and dry-run - with patch.object( - sys, - "argv", - [ - "google-health-cli", - "--dry-run", - "subscribers", - "create", - "--endpoint-uri", - "https://example.com/webhook", - "--endpoint-secret", - "secret123", - ], - ): - with pytest.raises(SystemExit) as exit_info: - main() - assert exit_info.value.code == 0 + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "subscribers", + "create", + "--endpoint-uri", + "https://example.com/webhook", + "--endpoint-secret", + "secret123", + ] + ) + assert exit_info.value.code == 0 # subscribers create with json payload = { @@ -723,26 +758,19 @@ def test_cli_subscribers_commands(mock_load, mock_setup, capsys) -> None: mock_sub_api.create.return_value = MagicMock( spec=["to_dict"], to_dict=lambda: {"name": "sub1"} ) - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(payload), "subscribers", "create", - ], - ): - main() + ] + ) mock_sub_api.create.assert_called_once() # subscribers create missing endpointUri - with ( - patch.object(sys, "argv", ["google-health-cli", "subscribers", "create"]), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli(["subscribers", "create"]) # subscribers patch mock_sub_api.patch.return_value = MagicMock( @@ -753,61 +781,49 @@ def test_cli_subscribers_commands(mock_load, mock_setup, capsys) -> None: "endpointUri": "https://example.com/new", "endpointAuthorization": {"secret": "secret123"}, } - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(sub_payload), "subscribers", "patch", "projects/me/subscribers/sub1", - ], - ): - main() + ] + ) mock_sub_api.patch.assert_called_once() # subscribers patch missing json - with ( - patch.object( - sys, - "argv", + with pytest.raises(SystemExit): + run_cli( [ - "google-health-cli", "subscribers", "patch", "projects/me/subscribers/sub1", - ], - ), - pytest.raises(SystemExit), - ): - main() + ] + ) # subscribers delete mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "subscribers", "delete", "projects/me/subscribers/sub1", "--force", - ], - ): - main() + ] + ) mock_sub_api.delete.assert_called_once() -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_subscriptions_commands(mock_load, mock_setup, capsys) -> None: +def test_cli_subscriptions_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test subscriptions CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_subscriptions_api = AsyncMock() mock_api.subscribers.subscriptions = mock_subscriptions_api @@ -816,29 +832,22 @@ def test_cli_subscriptions_commands(mock_load, mock_setup, capsys) -> None: mock_subscriptions_api.list.return_value = MagicMock( spec=["to_dict"], to_dict=lambda: {"subscriptions": []} ) - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "subscriptions", "list", "--parent-subscriber", "projects/me/subscribers/sub1", - ], - ): - main() + ] + ) 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"} ) - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "subscriptions", "create", "--parent-subscriber", @@ -847,44 +856,33 @@ def test_cli_subscriptions_commands(mock_load, mock_setup, capsys) -> None: "users/me", "--data-types", "steps", - ], - ): - main() + ] + ) mock_subscriptions_api.create.assert_called_once() # subscriptions create with json payload = {"user": "users/me", "dataTypes": ["steps"]} - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(payload), "subscriptions", "create", "--parent-subscriber", "projects/me/subscribers/sub1", - ], - ): - main() + ] + ) # subscriptions create missing user - with ( - patch.object( - sys, - "argv", + with pytest.raises(SystemExit): + run_cli( [ - "google-health-cli", "subscriptions", "create", "--parent-subscriber", "projects/me/subscribers/sub1", - ], - ), - pytest.raises(SystemExit), - ): - main() + ] + ) # subscriptions patch mock_subscriptions_api.patch.return_value = MagicMock( @@ -895,83 +893,71 @@ def test_cli_subscriptions_commands(mock_load, mock_setup, capsys) -> None: "user": "users/me", "dataTypes": ["steps", "heart-rate"], } - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "--json", json.dumps(sub_patch_payload), "subscriptions", "patch", "projects/me/subscribers/sub1/subscriptions/s1", - ], - ): - main() + ] + ) # subscriptions patch missing json - with ( - patch.object( - sys, - "argv", + with pytest.raises(SystemExit): + run_cli( [ - "google-health-cli", "subscriptions", "patch", "projects/me/subscribers/sub1/subscriptions/s1", - ], - ), - pytest.raises(SystemExit), - ): - main() + ] + ) # subscriptions delete mock_subscriptions_api.delete.return_value = None - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "subscriptions", "delete", "projects/me/subscribers/sub1/subscriptions/s1", - ], - ): - main() + ] + ) captured = capsys.readouterr() assert "Deleted subscription" in captured.out -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_identity_and_irn(mock_load, mock_setup, capsys) -> None: +def test_cli_identity_and_irn( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test identity and irn CLI subcommands.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_api.get_identity = AsyncMock( return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"subject": "user123"}) ) - with patch.object(sys, "argv", ["google-health-cli", "identity", "get"]): - main() + run_cli(["identity", "get"]) mock_api.get_identity.assert_called_once() mock_api.get_irn_profile = AsyncMock( return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) ) - with patch.object(sys, "argv", ["google-health-cli", "irn", "get"]): - main() + run_cli(["irn", "get"]) mock_api.get_irn_profile.assert_called_once() -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_additional_datatypes(mock_load, mock_setup, capsys) -> None: +def test_cli_additional_datatypes( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test remaining data type CLI subcommands routing.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api datatypes = [ ("hydration_log", "hydration-log"), @@ -1023,18 +1009,19 @@ def test_cli_additional_datatypes(mock_load, mock_setup, capsys) -> None: ) subapi.list.return_value = res_mock - with patch.object(sys, "argv", ["google-health-cli", cmd_name, "list"]): - main() + run_cli([cmd_name, "list"]) subapi.list.assert_called_once() -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: +def test_cli_datatype_crud_and_options( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test datatype get, patch, delete, and rollup edge cases.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api mock_steps_subapi = AsyncMock() mock_api.steps = mock_steps_subapi @@ -1048,36 +1035,34 @@ def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: mock_dp.data.to_dict.return_value = {"count": 100} mock_steps_subapi.get.return_value = mock_dp - with patch.object(sys, "argv", ["google-health-cli", "steps", "get", "p1"]): - main() + run_cli(["steps", "get", "p1"]) mock_steps_subapi.get.assert_called_once_with(data_point_id="p1") # patch mock_steps_subapi.patch.return_value = mock_dp payload = {"steps": {"count": 200}} - with patch.object( - sys, - "argv", - ["google-health-cli", "--json", json.dumps(payload), "steps", "patch", "p1"], - ): - main() + run_cli( + [ + "--json", + json.dumps(payload), + "steps", + "patch", + "p1", + ] + ) mock_steps_subapi.patch.assert_called_once() # delete mock_steps_subapi.delete.return_value = None - with patch.object(sys, "argv", ["google-health-cli", "steps", "delete", "p1"]): - main() + run_cli(["steps", "delete", "p1"]) mock_steps_subapi.delete.assert_called_once_with("p1") captured = capsys.readouterr() assert "Deleted steps point p1" in captured.out # rollup unsupported error (sub_api has no daily_rollup method) del mock_steps_subapi.daily_rollup - with ( - patch.object(sys, "argv", ["google-health-cli", "steps", "rollup"]), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli(["steps", "rollup"]) captured = capsys.readouterr() assert "does not support daily rollups" in captured.out @@ -1088,10 +1073,7 @@ def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: mock_sett = MagicMock() mock_sett.time_zone = "America/Los_Angeles" mock_api.get_settings = AsyncMock(return_value=mock_sett) - with patch.object( - sys, "argv", ["google-health-cli", "steps", "rollup", "--days", "2"] - ): - main() + run_cli(["steps", "rollup", "--days", "2"]) mock_steps_subapi.daily_rollup.assert_called_once() # list with --params startTime/endTime/pageSize/pageToken @@ -1104,22 +1086,26 @@ def test_cli_datatype_crud_and_options(mock_load, mock_setup, capsys) -> None: mock_steps_subapi.list.return_value = MagicMock( spec=["data_points", "next_page_token"], data_points=[], next_page_token=None ) - with patch.object( - sys, - "argv", - ["google-health-cli", "--params", json.dumps(params), "steps", "list"], - ): - main() + run_cli( + [ + "--params", + json.dumps(params), + "steps", + "list", + ] + ) assert mock_steps_subapi.list.called -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_execute_all_pages(mock_load, mock_setup, capsys) -> None: +def test_cli_execute_all_pages( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test execute_all_pages streaming output format for --all flag across resource types.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") mock_api = MagicMock() - mock_setup.return_value = mock_api + mock_setup_client.return_value = mock_api # 1. dataPoints list --all mock_dp = MagicMock() @@ -1137,8 +1123,7 @@ async def async_iter(): mock_steps_subapi.data_type.field_name = "steps" mock_steps_subapi.list.return_value = async_iter() - with patch.object(sys, "argv", ["google-health-cli", "steps", "list", "--all"]): - main() + run_cli(["steps", "list", "--all"]) captured = capsys.readouterr() assert '"name": "p1"' in captured.out @@ -1153,8 +1138,7 @@ async def async_iter_dev(): mock_dev_api = AsyncMock() mock_api.paired_devices = mock_dev_api mock_dev_api.list.return_value = async_iter_dev() - with patch.object(sys, "argv", ["google-health-cli", "devices", "list", "--all"]): - main() + run_cli(["devices", "list", "--all"]) captured = capsys.readouterr() assert '"id": "d1"' in captured.out @@ -1169,10 +1153,7 @@ async def async_iter_sub(): mock_sub_api = AsyncMock() mock_api.subscribers = mock_sub_api mock_sub_api.list.return_value = async_iter_sub() - with patch.object( - sys, "argv", ["google-health-cli", "subscribers", "list", "--all"] - ): - main() + run_cli(["subscribers", "list", "--all"]) captured = capsys.readouterr() assert '"name": "s1"' in captured.out @@ -1189,19 +1170,15 @@ async def async_iter_subscription(): mock_subscriptions_api = AsyncMock() mock_api.subscribers.subscriptions = mock_subscriptions_api mock_subscriptions_api.list.return_value = async_iter_subscription() - with patch.object( - sys, - "argv", + run_cli( [ - "google-health-cli", "subscriptions", "list", "--parent-subscriber", "sub1", "--all", - ], - ): - main() + ] + ) captured = capsys.readouterr() assert '"name": "sub1/subscriptions/s1"' in captured.out @@ -1229,79 +1206,76 @@ def test_serialize_response_reconciled_datapoints() -> None: assert serialize_response("plain string") == "plain string" -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_exceptions_handling(mock_load, mock_setup, capsys) -> None: +def test_cli_exceptions_handling( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test HealthApiException and general Exception handling in async_run_cmd.""" - mock_load.return_value = ("env", "fake-token") + mock_load_credentials.return_value = ("env", "fake-token") # HealthApiException - mock_setup.side_effect = HealthApiException("API Error") - with ( - patch.object(sys, "argv", ["google-health-cli", "userinfo"]), - pytest.raises(SystemExit), - ): - main() + mock_setup_client.side_effect = HealthApiException("API Error") + with pytest.raises(SystemExit): + run_cli(["userinfo"]) captured = capsys.readouterr() assert "API Error" in captured.out # General Exception - mock_setup.side_effect = Exception("Unexpected Boom") - with ( - patch.object(sys, "argv", ["google-health-cli", "userinfo"]), - pytest.raises(SystemExit), - ): - main() + mock_setup_client.side_effect = Exception("Unexpected Boom") + with pytest.raises(SystemExit): + run_cli(["userinfo"]) captured = capsys.readouterr() assert "Unexpected error: Unexpected Boom" in captured.out -@patch("google_health_api.cli.commands.setup_client") -@patch("google_health_api.cli.commands.load_credentials_or_env") -def test_cli_invalid_json_payloads(mock_load, mock_setup, capsys) -> None: +def test_cli_invalid_json_payloads( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + capsys: pytest.CaptureFixture[str], +) -> None: """Test invalid JSON strings provided to --json or --params.""" - mock_load.return_value = ("env", "fake-token") - mock_setup.return_value = MagicMock() + mock_load_credentials.return_value = ("env", "fake-token") + mock_setup_client.return_value = MagicMock() # Invalid --json - with ( - patch.object( - sys, - "argv", - ["google-health-cli", "--json", "{invalid_json", "steps", "create"], - ), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli( + [ + "--json", + "{invalid_json", + "steps", + "create", + ] + ) captured = capsys.readouterr() assert "Invalid raw JSON payload" in captured.out # Invalid --params - with ( - patch.object( - sys, - "argv", - ["google-health-cli", "--params", "{invalid_json", "steps", "list"], - ), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli( + [ + "--params", + "{invalid_json", + "steps", + "list", + ] + ) captured = capsys.readouterr() assert "Invalid --params JSON payload" in captured.out -def test_unauthenticated_cli_setup(monkeypatch, capsys) -> None: +def test_unauthenticated_cli_setup( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """Test error when setup_client finds no auth token/credentials.""" monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) monkeypatch.setattr( "google_health_api.cli.commands.TOKEN_FILE", "non_existent_token.json" ) - with ( - patch.object(sys, "argv", ["google-health-cli", "userinfo"]), - pytest.raises(SystemExit), - ): - main() + with pytest.raises(SystemExit): + run_cli(["userinfo"]) captured = capsys.readouterr() assert "Not logged in" in captured.out From 865ebecb62fcddf87f428007c9773d71e13ea259 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 06:58:33 -0700 Subject: [PATCH 20/24] refactor: Move CLI schema command tests from test_cli.py to test_schema.py --- tests/cli/test_cli.py | 23 ----------------- tests/cli/test_schema.py | 53 ++++++++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index f42bb05..f0fa02d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -115,29 +115,6 @@ def test_validate_safe_path(tmp_path: Path) -> None: validate_safe_path(outside_dir) -def test_cli_schema_list( - mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] -) -> None: - """Test listing all schemas.""" - run_cli(["schema"]) - captured = capsys.readouterr() - schemas = json.loads(captured.out) - assert "steps.list" in schemas - assert "profile.get" in schemas - assert "userinfo" in schemas - - -def test_cli_schema_details( - mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] -) -> None: - """Test retrieving a specific command schema.""" - run_cli(["schema", "steps.list"]) - captured = capsys.readouterr() - schema_details = json.loads(captured.out) - assert schema_details["method"] == "GET" - assert schema_details["endpoint"] == "v4/users/{user}/dataTypes/steps/dataPoints" - - def test_cli_validation_rejection( mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/cli/test_schema.py b/tests/cli/test_schema.py index 251e8e3..f0787c3 100644 --- a/tests/cli/test_schema.py +++ b/tests/cli/test_schema.py @@ -1,10 +1,12 @@ """Unit tests for CLI schema introspection in google_health_api/cli/schema.py.""" +import json import sys import typing +from collections.abc import Generator from dataclasses import dataclass, field from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -19,6 +21,19 @@ from google_health_api.model import Steps +def run_cli(args: list[str]) -> None: + """Helper to run the CLI with specific arguments.""" + with patch.object(sys, "argv", ["google-health-cli", *args]): + main() + + +@pytest.fixture +def mock_load_credentials() -> Generator[MagicMock]: + """Fixture to mock load_credentials_or_env.""" + with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: + yield mock + + def test_get_type_name_union_multi_non_none() -> None: """Test get_type_name with a Union having multiple non-None types.""" res = get_type_name(int | str) @@ -155,14 +170,36 @@ def test_get_command_schemas() -> None: assert "subscriptions.list" in schemas -def test_cli_schema_unknown_command_error(capsys) -> None: +def test_cli_schema_list( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Test listing all schemas via CLI.""" + run_cli(["schema"]) + captured = capsys.readouterr() + schemas = json.loads(captured.out) + assert "steps.list" in schemas + assert "profile.get" in schemas + assert "userinfo" in schemas + + +def test_cli_schema_details( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + """Test retrieving a specific command schema via CLI.""" + run_cli(["schema", "steps.list"]) + captured = capsys.readouterr() + schema_details = json.loads(captured.out) + assert schema_details["method"] == "GET" + assert schema_details["endpoint"] == "v4/users/{user}/dataTypes/steps/dataPoints" + + +def test_cli_schema_unknown_command_error( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: """Test CLI error handling when introspecting an unknown schema command name.""" - with patch.object( - sys, "argv", ["google-health-cli", "schema", "nonexistent.command"] - ): - with pytest.raises(SystemExit) as exit_info: - main() - assert exit_info.value.code == 1 + with pytest.raises(SystemExit) as exit_info: + run_cli(["schema", "nonexistent.command"]) + assert exit_info.value.code == 1 captured = capsys.readouterr() assert "Unknown command for schema lookup: nonexistent.command" in captured.err From d1f2bfeccb98e19d656623abcbf4a2fb3393cb76 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 07:19:40 -0700 Subject: [PATCH 21/24] refactor: Split huge CLI commands and tests codebase into submodules --- google_health_api/cli/auth.py | 131 ++ google_health_api/cli/commands.py | 1328 ++--------------- google_health_api/cli/subcommands/__init__.py | 1 + google_health_api/cli/subcommands/devices.py | 33 + google_health_api/cli/subcommands/identity.py | 19 + google_health_api/cli/subcommands/login.py | 70 + google_health_api/cli/subcommands/profile.py | 42 + google_health_api/cli/subcommands/settings.py | 42 + .../cli/subcommands/subscribers.py | 114 ++ .../cli/subcommands/subscriptions.py | 110 ++ google_health_api/cli/subcommands/userinfo.py | 11 + google_health_api/cli/utils.py | 136 ++ tests/cli/conftest.py | 43 + tests/cli/subcommands/__init__.py | 1 + tests/cli/subcommands/test_devices.py | 38 + tests/cli/subcommands/test_identity.py | 30 + tests/cli/subcommands/test_login.py | 192 +++ tests/cli/subcommands/test_profile.py | 69 + tests/cli/subcommands/test_settings.py | 47 + tests/cli/subcommands/test_subscribers.py | 110 ++ tests/cli/subcommands/test_subscriptions.py | 119 ++ tests/cli/subcommands/test_userinfo.py | 36 + tests/cli/test_cli.py | 628 +------- 23 files changed, 1555 insertions(+), 1795 deletions(-) create mode 100644 google_health_api/cli/auth.py create mode 100644 google_health_api/cli/subcommands/__init__.py create mode 100644 google_health_api/cli/subcommands/devices.py create mode 100644 google_health_api/cli/subcommands/identity.py create mode 100644 google_health_api/cli/subcommands/login.py create mode 100644 google_health_api/cli/subcommands/profile.py create mode 100644 google_health_api/cli/subcommands/settings.py create mode 100644 google_health_api/cli/subcommands/subscribers.py create mode 100644 google_health_api/cli/subcommands/subscriptions.py create mode 100644 google_health_api/cli/subcommands/userinfo.py create mode 100644 google_health_api/cli/utils.py create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/subcommands/__init__.py create mode 100644 tests/cli/subcommands/test_devices.py create mode 100644 tests/cli/subcommands/test_identity.py create mode 100644 tests/cli/subcommands/test_login.py create mode 100644 tests/cli/subcommands/test_profile.py create mode 100644 tests/cli/subcommands/test_settings.py create mode 100644 tests/cli/subcommands/test_subscribers.py create mode 100644 tests/cli/subcommands/test_subscriptions.py create mode 100644 tests/cli/subcommands/test_userinfo.py diff --git a/google_health_api/cli/auth.py b/google_health_api/cli/auth.py new file mode 100644 index 0000000..93ad9bf --- /dev/null +++ b/google_health_api/cli/auth.py @@ -0,0 +1,131 @@ +"""CLI authentication and session wrappers.""" + +import asyncio +import contextvars +from datetime import UTC, datetime +import json +import os +from typing import Any + +import aiohttp +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials + +from google_health_api.auth import AbstractAuth +from google_health_api.client import GoogleHealthSession +from google_health_api.const import HealthApiScope + +TOKEN_FILE = "token.json" +CLIENT_SECRET_FILE = "client_secret.json" +SCOPES = [ + HealthApiScope.ACTIVITY_READ, + HealthApiScope.ACTIVITY_WRITE, + HealthApiScope.MEASUREMENTS_READ, + HealthApiScope.MEASUREMENTS_WRITE, + HealthApiScope.PROFILE_READ, + HealthApiScope.PROFILE_WRITE, + HealthApiScope.SETTINGS_READ, + HealthApiScope.SETTINGS_WRITE, + HealthApiScope.SLEEP_READ, + HealthApiScope.SLEEP_WRITE, + HealthApiScope.NUTRITION_READ, + HealthApiScope.NUTRITION_WRITE, + HealthApiScope.LOCATION_READ, + HealthApiScope.ECG_READ, + HealthApiScope.IRN_READ, + HealthApiScope.USERINFO_PROFILE, + HealthApiScope.USERINFO_EMAIL, +] + +fields_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "fields", default=None +) + + +class CliHealthSession(GoogleHealthSession): + """Subclass of GoogleHealthSession to support dynamically injecting fields parameter.""" + + async def request( + self, + method: str, + url: str, + headers: dict[str, Any] | None = None, + **kwargs: Any, + ) -> aiohttp.ClientResponse: + fields = fields_var.get() + if fields: + params = kwargs.setdefault("params", {}) + params["fields"] = fields + return await super().request(method, url, headers=headers, **kwargs) + + +class CredentialsAuth(AbstractAuth): + """Auth wrapper that uses google-auth credentials.""" + + def __init__( + self, websession: aiohttp.ClientSession, credentials, host: str | None = None + ) -> None: + super().__init__(websession, host) + self._credentials = credentials + + async def async_get_access_token(self) -> str: + if not self._credentials.valid: + loop = asyncio.get_running_loop() + req = Request() + await loop.run_in_executor(None, self._credentials.refresh, req) + save_credentials(self._credentials) + return self._credentials.token + + +class EnvAuth(AbstractAuth): + """Auth wrapper that uses environment variable token directly (Agent DX).""" + + def __init__( + self, websession: aiohttp.ClientSession, token: str, host: str | None = None + ) -> None: + super().__init__(websession, host) + self._token = token + + async def async_get_access_token(self) -> str: + return self._token + + +def save_credentials(credentials) -> None: + """Save credentials to local token file.""" + with open(TOKEN_FILE, "w") as f: + f.write(credentials.to_json()) + + +def load_credentials_or_env(): + """Load credentials from environment or token.json.""" + token_env = os.environ.get("GOOGLE_HEALTH_CLI_TOKEN") + if token_env: + return ("env", token_env) + + if not os.path.exists(TOKEN_FILE): + return None + + with open(TOKEN_FILE, "r") as f: + data = json.load(f) + + expiry_str = data.get("expiry") + expiry = None + if expiry_str: + if expiry_str.endswith("Z"): + expiry = datetime.fromisoformat(expiry_str[:-1]) + else: + dt = datetime.fromisoformat(expiry_str) + if dt.tzinfo is not None: + dt = dt.astimezone(UTC).replace(tzinfo=None) + expiry = dt + + creds = Credentials( + token=data.get("token"), + refresh_token=data.get("refresh_token"), + token_uri=data.get("token_uri"), + client_id=data.get("client_id"), + client_secret=data.get("client_secret"), + scopes=data.get("scopes") or SCOPES, + expiry=expiry, + ) + return ("file", creds) diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index 6c6a048..e247a0e 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -1,288 +1,155 @@ """Command implementations for Google Health CLI.""" -import asyncio -import contextvars -import json import os import sys from datetime import UTC, date, datetime, timedelta -from typing import Any, NoReturn from zoneinfo import ZoneInfo import aiohttp -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from google_auth_oauthlib.flow import Flow, InstalledAppFlow from google_health_api.api import GoogleHealthApi -from google_health_api.auth import AbstractAuth -from google_health_api.client import GoogleHealthSession -from google_health_api.const import HEALTH_API_URL, HealthApiScope +from google_health_api.const import HEALTH_API_URL from google_health_api.exceptions import HealthApiException -from google_health_api.model import ( - DataPoint, - Profile, - ReconciledDataPoint, - Settings, - Subscriber, - SubscriberConfig, - Subscription, -) - -from .validation import check_dry_run, validate_resource_name - -TOKEN_FILE = "token.json" -CLIENT_SECRET_FILE = "client_secret.json" -SCOPES = [ - HealthApiScope.ACTIVITY_READ, - HealthApiScope.ACTIVITY_WRITE, - HealthApiScope.MEASUREMENTS_READ, - HealthApiScope.MEASUREMENTS_WRITE, - HealthApiScope.PROFILE_READ, - HealthApiScope.PROFILE_WRITE, - HealthApiScope.SETTINGS_READ, - HealthApiScope.SETTINGS_WRITE, - HealthApiScope.SLEEP_READ, - HealthApiScope.SLEEP_WRITE, - HealthApiScope.NUTRITION_READ, - HealthApiScope.NUTRITION_WRITE, - HealthApiScope.LOCATION_READ, - HealthApiScope.ECG_READ, - HealthApiScope.IRN_READ, - HealthApiScope.USERINFO_PROFILE, - HealthApiScope.USERINFO_EMAIL, -] - -fields_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( - "fields", default=None +from .auth import ( + CliHealthSession, + CredentialsAuth, + EnvAuth, + fields_var, + load_credentials_or_env, ) +from .subcommands.devices import handle_devices_cmd +from .subcommands.identity import handle_identity_cmd, handle_irn_cmd +from .subcommands.login import cmd_login # noqa: F401 +from .subcommands.profile import handle_profile_cmd +from .subcommands.settings import handle_settings_cmd +from .subcommands.subscribers import handle_subscribers_cmd +from .subcommands.subscriptions import handle_subscriptions_cmd +from .subcommands.userinfo import handle_userinfo_cmd +from .utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_datapoint, + serialize_response, +) +from .validation import check_dry_run, validate_resource_name - -class CliHealthSession(GoogleHealthSession): - """Subclass of GoogleHealthSession to support dynamically injecting fields parameter.""" - - async def request( - self, - method: str, - url: str, - headers: dict[str, Any] | None = None, - **kwargs: Any, - ) -> aiohttp.ClientResponse: - fields = fields_var.get() - if fields: - params = kwargs.setdefault("params", {}) - params["fields"] = fields - return await super().request(method, url, headers=headers, **kwargs) - - -class CredentialsAuth(AbstractAuth): - """Auth wrapper that uses google-auth credentials.""" - - def __init__( - self, websession: aiohttp.ClientSession, credentials, host: str | None = None - ) -> None: - super().__init__(websession, host) - self._credentials = credentials - - async def async_get_access_token(self) -> str: - if not self._credentials.valid: - loop = asyncio.get_running_loop() - req = Request() - await loop.run_in_executor(None, self._credentials.refresh, req) - save_credentials(self._credentials) - return self._credentials.token - - -class EnvAuth(AbstractAuth): - """Auth wrapper that uses environment variable token directly (Agent DX).""" - - def __init__( - self, websession: aiohttp.ClientSession, token: str, host: str | None = None - ) -> None: - super().__init__(websession, host) - self._token = token - - async def async_get_access_token(self) -> str: - return self._token - - -def save_credentials(credentials) -> None: - """Save credentials to local token file.""" - with open(TOKEN_FILE, "w") as f: - f.write(credentials.to_json()) - - -def load_credentials_or_env(): - """Load credentials from environment or token.json.""" - token_env = os.environ.get("GOOGLE_HEALTH_CLI_TOKEN") - if token_env: - return ("env", token_env) - - if not os.path.exists(TOKEN_FILE): - return None - - with open(TOKEN_FILE, "r") as f: - data = json.load(f) - - expiry_str = data.get("expiry") - expiry = None - if expiry_str: - if expiry_str.endswith("Z"): - expiry = datetime.fromisoformat(expiry_str[:-1]) - else: - dt = datetime.fromisoformat(expiry_str) - if dt.tzinfo is not None: - dt = dt.astimezone(UTC).replace(tzinfo=None) - expiry = dt - - creds = Credentials( - token=data.get("token"), - refresh_token=data.get("refresh_token"), - token_uri=data.get("token_uri"), - client_id=data.get("client_id"), - client_secret=data.get("client_secret"), - scopes=data.get("scopes") or SCOPES, - expiry=expiry, - ) - return ("file", creds) - - -def print_json(data: Any, pretty: bool = True) -> None: - """Helper to output JSON data, respect pretty setting.""" - if pretty: - print(json.dumps(data, indent=2)) - else: - print(json.dumps(data)) - - -def print_error_json(message: str, status: str = "INTERNAL") -> NoReturn: - """Print standard JSON error and exit.""" - res = { - "error": { - "status": status, - "message": message, - } - } - print_json(res) - sys.exit(1) - - -def serialize_datapoint(dp: DataPoint, field_name: str) -> dict[str, Any]: - """Serialize generic DataPoint class to dictionary matching API payload structure.""" - res: dict[str, Any] = {} - if dp.name: - res["name"] = dp.name - if dp.data_source: - res["dataSource"] = dp.data_source.to_dict() - res[field_name] = dp.data.to_dict() - return res - - -def serialize_reconciled_datapoint( - rdp: ReconciledDataPoint, field_name: str -) -> dict[str, Any]: - """Serialize generic ReconciledDataPoint class.""" - return {"dataPoint": serialize_datapoint(rdp.data_point, field_name)} - - -def serialize_response(result: Any, field_name: str | None = None) -> Any: - """Serialize generic API result to JSON-compatible data structures.""" - if hasattr(result, "to_dict"): - return result.to_dict() - if hasattr(result, "data_points"): - assert field_name is not None - return { - "dataPoints": [ - serialize_datapoint(dp, field_name) for dp in result.data_points - ], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "reconciled_data_points"): - assert field_name is not None - return { - "reconciledDataPoints": [ - serialize_reconciled_datapoint(rdp, field_name) - for rdp in result.reconciled_data_points - ], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "paired_devices"): - return { - "pairedDevices": [dev.to_dict() for dev in result.paired_devices], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "subscribers"): - return { - "subscribers": [sub.to_dict() for sub in result.subscribers], - "nextPageToken": result.next_page_token, - } - if hasattr(result, "subscriptions"): - return { - "subscriptions": [sub.to_dict() for sub in result.subscriptions], - "nextPageToken": result.next_page_token, - } - return result - - -def cmd_login(args) -> None: - """Execute interactive OAuth login flow.""" - if not os.path.exists(CLIENT_SECRET_FILE): - print_error_json( - f"Client secrets file '{CLIENT_SECRET_FILE}' not found.", - status="NOT_FOUND", - ) - - if not sys.stdin.isatty(): - print_error_json( - "Cannot run interactive login in a headless environment.", - status="FAILED_PRECONDITION", - ) - - with open(CLIENT_SECRET_FILE, "r") as f: - client_secrets_data = json.load(f) - - is_web = "web" in client_secrets_data - - if is_web: - redirect_uris = client_secrets_data["web"].get("redirect_uris", []) - redirect_uri = redirect_uris[0] if redirect_uris else "http://localhost:8080/" - - flow = Flow.from_client_secrets_file( - CLIENT_SECRET_FILE, - scopes=SCOPES, - redirect_uri=redirect_uri, - ) - authorization_url, _ = flow.authorization_url( - access_type="offline", - prompt="consent", - ) - print("Web-based authentication flow:") - print(f"URL: {authorization_url}") - redirect_response = input("Redirected URL or auth code: ").strip() - - if not redirect_response: - print_error_json( - "Redirected URL cannot be empty.", status="INVALID_ARGUMENT" - ) - - os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" - os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" - - if "code=" in redirect_response or redirect_response.startswith("http"): - flow.fetch_token(authorization_response=redirect_response) - else: - flow.fetch_token(code=redirect_response) - credentials = flow.credentials - else: - flow = InstalledAppFlow.from_client_secrets_file( - CLIENT_SECRET_FILE, - scopes=SCOPES, - ) - credentials = flow.run_local_server(port=0) - - save_credentials(credentials) - print_json({"status": "SUCCESS", "message": "Logged in successfully."}) +DATATYPE_COMMANDS = { + "steps": ("steps", "steps", "steps"), + "heart-rate": ("heart_rate", "heartRate", "heart rate"), + "sleep": ("sleep", "sleep", "sleep"), + "distance": ("distance", "distance", "distance"), + "basal-energy-burned": ( + "basal_energy_burned", + "basalEnergyBurned", + "basal energy burned", + ), + "active-energy-burned": ( + "active_energy_burned", + "activeEnergyBurned", + "active energy burned", + ), + "total-calories": ("total_calories", "totalCalories", "total calories"), + "vo2-max": ("vo2_max", "vo2Max", "VO2 max"), + "weight": ("weight", "weight", "weight"), + "height": ("height", "height", "height"), + "bmi": ("bmi", "bmi", "BMI"), + "exercise": ("exercise", "exercise", "exercise"), + "daily-vo2-max": ("daily_vo2_max", "dailyVo2Max", "daily VO2 max"), + "daily-heart-rate-zones": ( + "daily_heart_rate_zones", + "dailyHeartRateZones", + "daily heart rate zones", + ), + "daily-sleep-temperature-derivations": ( + "daily_sleep_temperature_derivations", + "dailySleepTemperatureDerivations", + "daily sleep temperature derivations", + ), + "daily-respiratory-rate": ( + "daily_respiratory_rate", + "dailyRespiratoryRate", + "daily respiratory rate", + ), + "respiratory-rate-sleep-summary": ( + "respiratory_rate_sleep_summary", + "respiratoryRateSleepSummary", + "respiratory rate sleep summary", + ), + "electrocardiogram": ( + "electrocardiogram", + "electrocardiogram", + "electrocardiogram", + ), + "irregular-rhythm-notification": ( + "irregular_rhythm_notification", + "irregularRhythmNotification", + "irregular rhythm notification", + ), + "oxygen-saturation": ( + "oxygen_saturation", + "oxygenSaturation", + "oxygen saturation", + ), + "daily-oxygen-saturation": ( + "daily_oxygen_saturation", + "dailyOxygenSaturation", + "daily oxygen saturation", + ), + "floors": ("floors", "floors", "floors"), + "hydration-log": ("hydration_log", "hydrationLog", "hydration log"), + "nutrition-log": ("nutrition_log", "nutritionLog", "nutrition log"), + "daily-resting-heart-rate": ( + "daily_resting_heart_rate", + "dailyRestingHeartRate", + "daily resting heart rate", + ), + "heart-rate-variability": ( + "heart_rate_variability", + "heartRateVariability", + "heart rate variability", + ), + "daily-heart-rate-variability": ( + "daily_heart_rate_variability", + "dailyHeartRateVariability", + "daily heart rate variability", + ), + "altitude": ("altitude", "altitude", "altitude"), + "body-fat": ("body_fat", "bodyFat", "body fat"), + "active-minutes": ("active_minutes", "activeMinutes", "active minutes"), + "active-zone-minutes": ( + "active_zone_minutes", + "activeZoneMinutes", + "active zone minutes", + ), + "blood-glucose": ("blood_glucose", "bloodGlucose", "blood glucose"), + "core-body-temperature": ( + "core_body_temperature", + "coreBodyTemperature", + "core body temperature", + ), + "sedentary-period": ("sedentary_period", "sedentaryPeriod", "sedentary period"), + "swim-lengths-data": ( + "swim_lengths_data", + "swimLengthsData", + "swim lengths data", + ), + "run-vo2-max": ("run_vo2_max", "runVo2Max", "run VO2 max"), + "activity-level": ("activity_level", "activityLevel", "activity level"), + "time-in-heart-rate-zone": ( + "time_in_heart_rate_zone", + "timeInHeartRateZone", + "time in heart rate zone", + ), + "calories-in-heart-rate-zone": ( + "calories_in_heart_rate_zone", + "caloriesInHeartRateZone", + "calories in heart rate zone", + ), +} async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi: @@ -336,56 +203,6 @@ async def setup_client(session: aiohttp.ClientSession) -> GoogleHealthApi: return api -async def execute_all_pages( - args, result: Any, field_name: str | None, pretty: bool -) -> None: - """Iterate and print items in NDJSON format for streaming output.""" - async for page in result: - if hasattr(page, "data_points"): - for item in page.data_points: - assert field_name is not None - print_json(serialize_datapoint(item, field_name), pretty=False) - elif hasattr(page, "reconciled_data_points"): - for item in page.reconciled_data_points: - assert field_name is not None - print_json( - serialize_reconciled_datapoint(item, field_name), pretty=False - ) - elif hasattr(page, "paired_devices"): - for item in page.paired_devices: - print_json(item.to_dict(), pretty=False) - elif hasattr(page, "subscribers"): - for item in page.subscribers: - print_json(item.to_dict(), pretty=False) - elif hasattr(page, "subscriptions"): - for item in page.subscriptions: - print_json(item.to_dict(), pretty=False) - - -def get_json_payload(args) -> dict[str, Any] | None: - """Extract and parse the raw JSON payload from --json argument if present.""" - if not hasattr(args, "json") or not args.json: - return None - try: - return json.loads(args.json) - except json.JSONDecodeError as err: - print_error_json(f"Invalid raw JSON payload: {err}", status="INVALID_ARGUMENT") - return None - - -def get_params_payload(args) -> dict[str, Any]: - """Extract and parse --params query variables if present.""" - if not hasattr(args, "params") or not args.params: - return {} - try: - return json.loads(args.params) - except json.JSONDecodeError as err: - print_error_json( - f"Invalid --params JSON payload: {err}", status="INVALID_ARGUMENT" - ) - return {} - - async def handle_datatype_cmd( args, api: GoogleHealthApi, @@ -486,7 +303,8 @@ async def handle_datatype_cmd( payload = get_json_payload(args) if payload is None: print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + "Please provide raw JSON input using --json.", + status="INVALID_ARGUMENT", ) assert payload is not None @@ -496,23 +314,24 @@ async def handle_datatype_cmd( validate_resource_name(args.data_point_id) path += f"/{args.data_point_id}" check_dry_run( - args.dry_run, "POST" if sub == "create" else "PATCH", path, payload + args.dry_run, + "POST" if sub == "create" else "PATCH", + path, + payload, ) - dp = DataPoint.from_api_dict(sub_api.data_type, payload) if sub == "create": - result = await sub_api.create(dp) + result = await sub_api.create(payload) else: - result = await sub_api.patch(args.data_point_id, dp) + result = await sub_api.patch(args.data_point_id, payload) print_json(serialize_datapoint(result, field_name), pretty) elif sub == "delete": validate_resource_name(args.data_point_id) check_dry_run( args.dry_run, - "POST", - f"v4/users/me/dataTypes/{key}/dataPoints:batchDelete", - {"names": [f"users/me/dataTypes/{key}/dataPoints/{args.data_point_id}"]}, + "DELETE", + f"v4/users/me/dataTypes/{key}/dataPoints/{args.data_point_id}", ) await sub_api.delete(args.data_point_id) print_json( @@ -524,767 +343,6 @@ async def handle_datatype_cmd( ) -async def handle_steps_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle steps subcommands.""" - await handle_datatype_cmd(args, api, api.steps, "steps", "steps", "steps", pretty) - - -async def handle_heart_rate_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle heart rate subcommands.""" - await handle_datatype_cmd( - args, api, api.heart_rate, "heartRate", "heart-rate", "heart rate", pretty - ) - - -async def handle_sleep_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle sleep subcommands.""" - await handle_datatype_cmd(args, api, api.sleep, "sleep", "sleep", "sleep", pretty) - - -async def handle_distance_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle distance subcommands.""" - await handle_datatype_cmd( - args, api, api.distance, "distance", "distance", "distance", pretty - ) - - -async def handle_basal_energy_burned_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle basal energy burned subcommands.""" - await handle_datatype_cmd( - args, - api, - api.basal_energy_burned, - "basalEnergyBurned", - "basal-energy-burned", - "basal energy burned", - pretty, - ) - - -async def handle_active_energy_burned_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle active energy burned subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_energy_burned, - "activeEnergyBurned", - "active-energy-burned", - "active energy burned", - pretty, - ) - - -async def handle_total_calories_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle total calories subcommands.""" - await handle_datatype_cmd( - args, - api, - api.total_calories, - "totalCalories", - "total-calories", - "total calories", - pretty, - ) - - -async def handle_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle VO2 max subcommands.""" - await handle_datatype_cmd( - args, api, api.vo2_max, "vo2Max", "vo2-max", "VO2 max", pretty - ) - - -async def handle_weight_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle weight subcommands.""" - await handle_datatype_cmd( - args, api, api.weight, "weight", "weight", "weight", pretty - ) - - -async def handle_height_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle height subcommands.""" - await handle_datatype_cmd( - args, api, api.height, "height", "height", "height", pretty - ) - - -async def handle_bmi_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle bmi subcommands.""" - await handle_datatype_cmd(args, api, api.bmi, "bmi", "bmi", "BMI", pretty) - - -async def handle_exercise_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle exercise subcommands.""" - await handle_datatype_cmd( - args, api, api.exercise, "exercise", "exercise", "exercise", pretty - ) - - -async def handle_daily_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle daily VO2 max subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_vo2_max, - "dailyVo2Max", - "daily-vo2-max", - "daily VO2 max", - pretty, - ) - - -async def handle_daily_heart_rate_zones_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily heart rate zones subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_heart_rate_zones, - "dailyHeartRateZones", - "daily-heart-rate-zones", - "daily heart rate zones", - pretty, - ) - - -async def handle_daily_sleep_temperature_derivations_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily sleep temperature derivations subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_sleep_temperature_derivations, - "dailySleepTemperatureDerivations", - "daily-sleep-temperature-derivations", - "daily sleep temperature derivations", - pretty, - ) - - -async def handle_daily_respiratory_rate_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily respiratory rate subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_respiratory_rate, - "dailyRespiratoryRate", - "daily-respiratory-rate", - "daily respiratory rate", - pretty, - ) - - -async def handle_respiratory_rate_sleep_summary_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle respiratory rate sleep summary subcommands.""" - await handle_datatype_cmd( - args, - api, - api.respiratory_rate_sleep_summary, - "respiratoryRateSleepSummary", - "respiratory-rate-sleep-summary", - "respiratory rate sleep summary", - pretty, - ) - - -async def handle_electrocardiogram_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle electrocardiogram subcommands.""" - await handle_datatype_cmd( - args, - api, - api.electrocardiogram, - "electrocardiogram", - "electrocardiogram", - "electrocardiogram", - pretty, - ) - - -async def handle_irregular_rhythm_notification_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle irregular rhythm notification subcommands.""" - await handle_datatype_cmd( - args, - api, - api.irregular_rhythm_notification, - "irregularRhythmNotification", - "irregular-rhythm-notification", - "irregular rhythm notification", - pretty, - ) - - -async def handle_oxygen_saturation_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle oxygen saturation subcommands.""" - await handle_datatype_cmd( - args, - api, - api.oxygen_saturation, - "oxygenSaturation", - "oxygen-saturation", - "oxygen saturation", - pretty, - ) - - -async def handle_daily_oxygen_saturation_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily oxygen saturation subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_oxygen_saturation, - "dailyOxygenSaturation", - "daily-oxygen-saturation", - "daily oxygen saturation", - pretty, - ) - - -async def handle_floors_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle floors subcommands.""" - await handle_datatype_cmd( - args, api, api.floors, "floors", "floors", "floors", pretty - ) - - -async def handle_hydration_log_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle hydration log subcommands.""" - await handle_datatype_cmd( - args, - api, - api.hydration_log, - "hydrationLog", - "hydration-log", - "hydration log", - pretty, - ) - - -async def handle_nutrition_log_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle nutrition log subcommands.""" - await handle_datatype_cmd( - args, - api, - api.nutrition_log, - "nutritionLog", - "nutrition-log", - "nutrition log", - pretty, - ) - - -async def handle_daily_resting_heart_rate_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily resting heart rate subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_resting_heart_rate, - "dailyRestingHeartRate", - "daily-resting-heart-rate", - "daily resting heart rate", - pretty, - ) - - -async def handle_heart_rate_variability_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle heart rate variability subcommands.""" - await handle_datatype_cmd( - args, - api, - api.heart_rate_variability, - "heartRateVariability", - "heart-rate-variability", - "heart rate variability", - pretty, - ) - - -async def handle_daily_heart_rate_variability_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle daily heart rate variability subcommands.""" - await handle_datatype_cmd( - args, - api, - api.daily_heart_rate_variability, - "dailyHeartRateVariability", - "daily-heart-rate-variability", - "daily heart rate variability", - pretty, - ) - - -async def handle_altitude_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle altitude subcommands.""" - await handle_datatype_cmd( - args, api, api.altitude, "altitude", "altitude", "altitude", pretty - ) - - -async def handle_body_fat_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle body fat subcommands.""" - await handle_datatype_cmd( - args, api, api.body_fat, "bodyFat", "body-fat", "body fat", pretty - ) - - -async def handle_active_minutes_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle active minutes subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_minutes, - "activeMinutes", - "active-minutes", - "active minutes", - pretty, - ) - - -async def handle_active_zone_minutes_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle active zone minutes subcommands.""" - await handle_datatype_cmd( - args, - api, - api.active_zone_minutes, - "activeZoneMinutes", - "active-zone-minutes", - "active zone minutes", - pretty, - ) - - -async def handle_blood_glucose_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle blood glucose subcommands.""" - await handle_datatype_cmd( - args, - api, - api.blood_glucose, - "bloodGlucose", - "blood-glucose", - "blood glucose", - pretty, - ) - - -async def handle_core_body_temperature_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle core body temperature subcommands.""" - await handle_datatype_cmd( - args, - api, - api.core_body_temperature, - "coreBodyTemperature", - "core-body-temperature", - "core body temperature", - pretty, - ) - - -async def handle_sedentary_period_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle sedentary period subcommands.""" - await handle_datatype_cmd( - args, - api, - api.sedentary_period, - "sedentaryPeriod", - "sedentary-period", - "sedentary period", - pretty, - ) - - -async def handle_swim_lengths_data_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle swim lengths data subcommands.""" - await handle_datatype_cmd( - args, - api, - api.swim_lengths_data, - "swimLengthsData", - "swim-lengths-data", - "swim lengths data", - pretty, - ) - - -async def handle_run_vo2_max_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle run VO2 max subcommands.""" - await handle_datatype_cmd( - args, - api, - api.run_vo2_max, - "runVo2Max", - "run-vo2-max", - "run VO2 max", - pretty, - ) - - -async def handle_activity_level_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle activity level subcommands.""" - await handle_datatype_cmd( - args, - api, - api.activity_level, - "activityLevel", - "activity-level", - "activity level", - pretty, - ) - - -async def handle_time_in_heart_rate_zone_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle time in heart rate zone subcommands.""" - await handle_datatype_cmd( - args, - api, - api.time_in_heart_rate_zone, - "timeInHeartRateZone", - "time-in-heart-rate-zone", - "time in heart rate zone", - pretty, - ) - - -async def handle_calories_in_heart_rate_zone_cmd( - args, api: GoogleHealthApi, pretty: bool -) -> None: - """Handle calories in heart rate zone subcommands.""" - await handle_datatype_cmd( - args, - api, - api.calories_in_heart_rate_zone, - "caloriesInHeartRateZone", - "calories-in-heart-rate-zone", - "calories in heart rate zone", - pretty, - ) - - -async def handle_profile_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle profile subcommands.""" - sub = args.subcommand - if sub == "get": - result = await api.get_profile() - print_json(serialize_response(result), pretty) - elif sub == "update": - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - "v4/users/me/profile", - {"payload": payload, "updateMask": update_mask}, - ) - - prof = Profile.from_dict(payload) - result = await api.update_profile(prof, update_mask=update_mask) - print_json(serialize_response(result), pretty) - - -async def handle_userinfo_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle userinfo subcommands.""" - result = await api.get_user_info() - print_json(serialize_response(result), pretty) - - -async def handle_settings_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle settings subcommands.""" - sub = args.subcommand - if sub == "get": - result = await api.get_settings() - print_json(serialize_response(result), pretty) - elif sub == "update": - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - "v4/users/me/settings", - {"payload": payload, "updateMask": update_mask}, - ) - - sett = Settings.from_dict(payload) - result = await api.update_settings(sett, update_mask=update_mask) - print_json(serialize_response(result), pretty) - - -async def handle_devices_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle devices subcommands.""" - sub = args.subcommand - if sub == "list": - limit = args.limit - page_token = args.page_token - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - - result = await api.paired_devices.list(page_size=pageSize, page_token=pageToken) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - elif sub == "get": - validate_resource_name(args.device_id) - result = await api.paired_devices.get(device_id=args.device_id) - print_json(serialize_response(result), pretty) - - -async def handle_identity_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle identity subcommands.""" - if args.subcommand == "get": - result = await api.get_identity() - print_json(serialize_response(result), pretty) - - -async def handle_irn_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle IRN subcommands.""" - if args.subcommand == "get": - result = await api.get_irn_profile() - print_json(serialize_response(result), pretty) - - -async def handle_subscribers_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle subscribers subcommands.""" - sub = args.subcommand - if sub == "list": - project = args.project - limit = args.limit - page_token = args.page_token - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - - result = await api.subscribers.list( - project=project, page_size=pageSize, page_token=pageToken - ) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - - elif sub == "create": - payload = get_json_payload(args) - subscriber_id = None - if payload: - endpoint_uri = payload.get("endpointUri") - endpoint_auth = payload.get("endpointAuthorization", {}) - endpoint_secret = endpoint_auth.get("secret") - configs = [ - SubscriberConfig.from_dict(c) - for c in payload.get("subscriberConfigs", []) - ] - else: - endpoint_uri = args.endpoint_uri - endpoint_secret = args.endpoint_secret - configs = [] - - params = get_params_payload(args) - subscriber_id = params.get("subscriberId", args.subscriber_id) - - if endpoint_uri is None or endpoint_secret is None: - print_error_json( - "Missing endpointUri or endpoint secret.", status="INVALID_ARGUMENT" - ) - assert isinstance(endpoint_uri, str) - assert isinstance(endpoint_secret, str) - - payload_dry = { - "endpointUri": endpoint_uri, - "endpointAuthorization": {"secret": endpoint_secret}, - "subscriberConfigs": [c.to_dict() for c in configs], - "subscriberId": subscriber_id, - } - check_dry_run( - args.dry_run, "POST", f"v4/projects/{args.project}/subscribers", payload_dry - ) - - result = await api.subscribers.create( - project=args.project, - endpoint_uri=endpoint_uri, - endpoint_authorization_secret=endpoint_secret, - subscriber_configs=configs if configs else None, - subscriber_id=subscriber_id, - ) - print_json(serialize_response(result), pretty) - - elif sub == "patch": - validate_resource_name(args.name) - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - f"v4/{args.name}", - {"payload": payload, "updateMask": update_mask}, - ) - - sub_obj = Subscriber.from_dict(payload) - result = await api.subscribers.patch( - args.name, sub_obj, update_mask=update_mask - ) - print_json(serialize_response(result), pretty) - - elif sub == "delete": - validate_resource_name(args.name) - params = get_params_payload(args) - force = params.get("force", args.force) - - check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}", {"force": force}) - result = await api.subscribers.delete(args.name, force=force) - print_json(serialize_response(result), pretty) - - -async def handle_subscriptions_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: - """Handle subscriptions subcommands.""" - sub = args.subcommand - if sub == "list": - limit = args.limit - page_token = args.page_token - filter_expr = args.filter - - params = get_params_payload(args) - pageSize = params.get("pageSize", limit) - pageToken = params.get("pageToken", page_token) - filter_str = params.get("filter", filter_expr) - - result = await api.subscribers.subscriptions.list( - parent_subscriber=args.parent_subscriber, - filter=filter_str, - page_size=pageSize, - page_token=pageToken, - ) - if args.all: - await execute_all_pages(args, result, None, pretty) - else: - print_json(serialize_response(result), pretty) - - elif sub == "create": - payload = get_json_payload(args) - subscription_id = None - if payload: - user = payload.get("user") - data_types = payload.get("dataTypes") - else: - user = args.user - data_types = args.data_types - - params = get_params_payload(args) - subscription_id = params.get("subscriptionId", args.subscription_id) - - if user is None: - print_error_json("Missing user parameter.", status="INVALID_ARGUMENT") - assert isinstance(user, str) - - payload_dry = { - "user": user, - "dataTypes": data_types, - "subscriptionId": subscription_id, - } - check_dry_run( - args.dry_run, - "POST", - f"v4/{args.parent_subscriber}/subscriptions", - payload_dry, - ) - - result = await api.subscribers.subscriptions.create( - parent_subscriber=args.parent_subscriber, - user=user, - data_types=data_types, - subscription_id=subscription_id, - ) - print_json(serialize_response(result), pretty) - - elif sub == "patch": - validate_resource_name(args.name) - payload = get_json_payload(args) - if payload is None: - print_error_json( - "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" - ) - assert payload is not None - - params = get_params_payload(args) - update_mask = params.get("updateMask", args.update_mask) - - check_dry_run( - args.dry_run, - "PATCH", - f"v4/{args.name}", - {"payload": payload, "updateMask": update_mask}, - ) - - sub_obj = Subscription.from_dict(payload) - result = await api.subscribers.subscriptions.patch( - args.name, sub_obj, update_mask=update_mask - ) - print_json(serialize_response(result), pretty) - - elif sub == "delete": - validate_resource_name(args.name) - check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}") - await api.subscribers.subscriptions.delete(args.name) - print_json( - {"status": "SUCCESS", "message": f"Deleted subscription {args.name}"}, - pretty, - ) - - async def async_run_cmd(args) -> None: """Async main routine that handles setup, context variables, and routing.""" # Set fields context variable @@ -1296,84 +354,18 @@ async def async_run_cmd(args) -> None: try: api = await setup_client(session) cmd = args.command - if cmd == "steps": - await handle_steps_cmd(args, api, pretty) - elif cmd == "heart-rate": - await handle_heart_rate_cmd(args, api, pretty) - elif cmd == "sleep": - await handle_sleep_cmd(args, api, pretty) - elif cmd == "distance": - await handle_distance_cmd(args, api, pretty) - elif cmd == "basal-energy-burned": - await handle_basal_energy_burned_cmd(args, api, pretty) - elif cmd == "vo2-max": - await handle_vo2_max_cmd(args, api, pretty) - elif cmd == "weight": - await handle_weight_cmd(args, api, pretty) - elif cmd == "height": - await handle_height_cmd(args, api, pretty) - elif cmd == "bmi": - await handle_bmi_cmd(args, api, pretty) - elif cmd == "exercise": - await handle_exercise_cmd(args, api, pretty) - elif cmd == "daily-vo2-max": - await handle_daily_vo2_max_cmd(args, api, pretty) - elif cmd == "daily-heart-rate-zones": - await handle_daily_heart_rate_zones_cmd(args, api, pretty) - elif cmd == "daily-sleep-temperature-derivations": - await handle_daily_sleep_temperature_derivations_cmd(args, api, pretty) - elif cmd == "daily-respiratory-rate": - await handle_daily_respiratory_rate_cmd(args, api, pretty) - elif cmd == "respiratory-rate-sleep-summary": - await handle_respiratory_rate_sleep_summary_cmd(args, api, pretty) - elif cmd == "electrocardiogram": - await handle_electrocardiogram_cmd(args, api, pretty) - elif cmd == "irregular-rhythm-notification": - await handle_irregular_rhythm_notification_cmd(args, api, pretty) - elif cmd == "oxygen-saturation": - await handle_oxygen_saturation_cmd(args, api, pretty) - elif cmd == "daily-oxygen-saturation": - await handle_daily_oxygen_saturation_cmd(args, api, pretty) - elif cmd == "active-energy-burned": - await handle_active_energy_burned_cmd(args, api, pretty) - elif cmd == "total-calories": - await handle_total_calories_cmd(args, api, pretty) - elif cmd == "floors": - await handle_floors_cmd(args, api, pretty) - elif cmd == "hydration-log": - await handle_hydration_log_cmd(args, api, pretty) - elif cmd == "nutrition-log": - await handle_nutrition_log_cmd(args, api, pretty) - elif cmd == "daily-resting-heart-rate": - await handle_daily_resting_heart_rate_cmd(args, api, pretty) - elif cmd == "heart-rate-variability": - await handle_heart_rate_variability_cmd(args, api, pretty) - elif cmd == "daily-heart-rate-variability": - await handle_daily_heart_rate_variability_cmd(args, api, pretty) - elif cmd == "altitude": - await handle_altitude_cmd(args, api, pretty) - elif cmd == "body-fat": - await handle_body_fat_cmd(args, api, pretty) - elif cmd == "active-minutes": - await handle_active_minutes_cmd(args, api, pretty) - elif cmd == "active-zone-minutes": - await handle_active_zone_minutes_cmd(args, api, pretty) - elif cmd == "blood-glucose": - await handle_blood_glucose_cmd(args, api, pretty) - elif cmd == "core-body-temperature": - await handle_core_body_temperature_cmd(args, api, pretty) - elif cmd == "sedentary-period": - await handle_sedentary_period_cmd(args, api, pretty) - elif cmd == "swim-lengths-data": - await handle_swim_lengths_data_cmd(args, api, pretty) - elif cmd == "run-vo2-max": - await handle_run_vo2_max_cmd(args, api, pretty) - elif cmd == "activity-level": - await handle_activity_level_cmd(args, api, pretty) - elif cmd == "time-in-heart-rate-zone": - await handle_time_in_heart_rate_zone_cmd(args, api, pretty) - elif cmd == "calories-in-heart-rate-zone": - await handle_calories_in_heart_rate_zone_cmd(args, api, pretty) + if cmd in DATATYPE_COMMANDS: + api_attr, field_name, display_name = DATATYPE_COMMANDS[cmd] + sub_api = getattr(api, api_attr) + await handle_datatype_cmd( + args, + api, + sub_api, + field_name, + cmd, + display_name, + pretty, + ) elif cmd == "profile": await handle_profile_cmd(args, api, pretty) elif cmd == "userinfo": diff --git a/google_health_api/cli/subcommands/__init__.py b/google_health_api/cli/subcommands/__init__.py new file mode 100644 index 0000000..cacdeb7 --- /dev/null +++ b/google_health_api/cli/subcommands/__init__.py @@ -0,0 +1 @@ +"""Subcommands for Google Health CLI.""" diff --git a/google_health_api/cli/subcommands/devices.py b/google_health_api/cli/subcommands/devices.py new file mode 100644 index 0000000..0fc67f2 --- /dev/null +++ b/google_health_api/cli/subcommands/devices.py @@ -0,0 +1,33 @@ +"""Devices subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import ( + execute_all_pages, + get_params_payload, + print_json, + serialize_response, +) +from ..validation import validate_resource_name + + +async def handle_devices_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle devices subcommands.""" + sub = args.subcommand + if sub == "list": + limit = args.limit + page_token = args.page_token + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + + result = await api.paired_devices.list(page_size=pageSize, page_token=pageToken) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + elif sub == "get": + validate_resource_name(args.device_id) + result = await api.paired_devices.get(device_id=args.device_id) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/identity.py b/google_health_api/cli/subcommands/identity.py new file mode 100644 index 0000000..ee58812 --- /dev/null +++ b/google_health_api/cli/subcommands/identity.py @@ -0,0 +1,19 @@ +"""Identity subcommands for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import print_json, serialize_response + + +async def handle_identity_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle identity subcommands.""" + if args.subcommand == "get": + result = await api.get_identity() + print_json(serialize_response(result), pretty) + + +async def handle_irn_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle IRN subcommands.""" + if args.subcommand == "get": + result = await api.get_irn_profile() + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/login.py b/google_health_api/cli/subcommands/login.py new file mode 100644 index 0000000..2893ca4 --- /dev/null +++ b/google_health_api/cli/subcommands/login.py @@ -0,0 +1,70 @@ +"""Login subcommand for Google Health CLI.""" + +import json +import os +import sys + +from google_auth_oauthlib.flow import Flow, InstalledAppFlow + +from ..auth import CLIENT_SECRET_FILE, SCOPES, save_credentials +from ..utils import print_error_json, print_json + + +def cmd_login(args) -> None: + """Execute interactive OAuth login flow.""" + if not os.path.exists(CLIENT_SECRET_FILE): + print_error_json( + f"Client secrets file '{CLIENT_SECRET_FILE}' not found.", + status="NOT_FOUND", + ) + + if not sys.stdin.isatty(): + print_error_json( + "Cannot run interactive login in a headless environment.", + status="FAILED_PRECONDITION", + ) + + with open(CLIENT_SECRET_FILE, "r") as f: + client_secrets_data = json.load(f) + + is_web = "web" in client_secrets_data + + if is_web: + redirect_uris = client_secrets_data["web"].get("redirect_uris", []) + redirect_uri = redirect_uris[0] if redirect_uris else "http://localhost:8080/" + + flow = Flow.from_client_secrets_file( + CLIENT_SECRET_FILE, + scopes=SCOPES, + redirect_uri=redirect_uri, + ) + authorization_url, _ = flow.authorization_url( + access_type="offline", + prompt="consent", + ) + print("Web-based authentication flow:") + print(f"URL: {authorization_url}") + redirect_response = input("Redirected URL or auth code: ").strip() + + if not redirect_response: + print_error_json( + "Redirected URL cannot be empty.", status="INVALID_ARGUMENT" + ) + + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + + if "code=" in redirect_response or redirect_response.startswith("http"): + flow.fetch_token(authorization_response=redirect_response) + else: + flow.fetch_token(code=redirect_response) + credentials = flow.credentials + else: + flow = InstalledAppFlow.from_client_secrets_file( + CLIENT_SECRET_FILE, + scopes=SCOPES, + ) + credentials = flow.run_local_server(port=0) + + save_credentials(credentials) + print_json({"status": "SUCCESS", "message": "Logged in successfully."}) diff --git a/google_health_api/cli/subcommands/profile.py b/google_health_api/cli/subcommands/profile.py new file mode 100644 index 0000000..18c3693 --- /dev/null +++ b/google_health_api/cli/subcommands/profile.py @@ -0,0 +1,42 @@ +"""Profile subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Profile + +from ..utils import ( + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run + + +async def handle_profile_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle profile subcommands.""" + sub = args.subcommand + if sub == "get": + result = await api.get_profile() + print_json(serialize_response(result), pretty) + elif sub == "update": + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + "v4/users/me/profile", + {"payload": payload, "updateMask": update_mask}, + ) + + prof = Profile.from_dict(payload) + result = await api.update_profile(prof, update_mask=update_mask) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/settings.py b/google_health_api/cli/subcommands/settings.py new file mode 100644 index 0000000..9d1e99d --- /dev/null +++ b/google_health_api/cli/subcommands/settings.py @@ -0,0 +1,42 @@ +"""Settings subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Settings + +from ..utils import ( + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run + + +async def handle_settings_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle settings subcommands.""" + sub = args.subcommand + if sub == "get": + result = await api.get_settings() + print_json(serialize_response(result), pretty) + elif sub == "update": + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + "v4/users/me/settings", + {"payload": payload, "updateMask": update_mask}, + ) + + sett = Settings.from_dict(payload) + result = await api.update_settings(sett, update_mask=update_mask) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/subscribers.py b/google_health_api/cli/subcommands/subscribers.py new file mode 100644 index 0000000..55c9f29 --- /dev/null +++ b/google_health_api/cli/subcommands/subscribers.py @@ -0,0 +1,114 @@ +"""Subscribers subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Subscriber, SubscriberConfig + +from ..utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run, validate_resource_name + + +async def handle_subscribers_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle subscribers subcommands.""" + sub = args.subcommand + if sub == "list": + project = args.project + limit = args.limit + page_token = args.page_token + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + + result = await api.subscribers.list( + project=project, page_size=pageSize, page_token=pageToken + ) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + + elif sub == "create": + payload = get_json_payload(args) + subscriber_id = None + if payload: + endpoint_uri = payload.get("endpointUri") + endpoint_auth = payload.get("endpointAuthorization", {}) + endpoint_secret = endpoint_auth.get("secret") + configs = [ + SubscriberConfig.from_dict(c) + for c in payload.get("subscriberConfigs", []) + ] + else: + endpoint_uri = args.endpoint_uri + endpoint_secret = args.endpoint_secret + configs = [] + + params = get_params_payload(args) + subscriber_id = params.get("subscriberId", args.subscriber_id) + + if endpoint_uri is None or endpoint_secret is None: + print_error_json( + "Missing endpointUri or endpoint secret.", status="INVALID_ARGUMENT" + ) + assert isinstance(endpoint_uri, str) + assert isinstance(endpoint_secret, str) + + payload_dry = { + "endpointUri": endpoint_uri, + "endpointAuthorization": {"secret": endpoint_secret}, + "subscriberConfigs": [c.to_dict() for c in configs], + "subscriberId": subscriber_id, + } + check_dry_run( + args.dry_run, "POST", f"v4/projects/{args.project}/subscribers", payload_dry + ) + + result = await api.subscribers.create( + project=args.project, + endpoint_uri=endpoint_uri, + endpoint_authorization_secret=endpoint_secret, + subscriber_configs=configs if configs else None, + subscriber_id=subscriber_id, + ) + print_json(serialize_response(result), pretty) + + elif sub == "patch": + validate_resource_name(args.name) + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + f"v4/{args.name}", + {"payload": payload, "updateMask": update_mask}, + ) + + sub_obj = Subscriber.from_dict(payload) + result = await api.subscribers.patch( + args.name, sub_obj, update_mask=update_mask + ) + print_json(serialize_response(result), pretty) + + elif sub == "delete": + validate_resource_name(args.name) + params = get_params_payload(args) + force = params.get("force", args.force) + + check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}", {"force": force}) + result = await api.subscribers.delete(args.name, force=force) + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/subcommands/subscriptions.py b/google_health_api/cli/subcommands/subscriptions.py new file mode 100644 index 0000000..8b125dc --- /dev/null +++ b/google_health_api/cli/subcommands/subscriptions.py @@ -0,0 +1,110 @@ +"""Subscriptions subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi +from google_health_api.model import Subscription + +from ..utils import ( + execute_all_pages, + get_json_payload, + get_params_payload, + print_error_json, + print_json, + serialize_response, +) +from ..validation import check_dry_run, validate_resource_name + + +async def handle_subscriptions_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle subscriptions subcommands.""" + sub = args.subcommand + if sub == "list": + limit = args.limit + page_token = args.page_token + filter_expr = args.filter + + params = get_params_payload(args) + pageSize = params.get("pageSize", limit) + pageToken = params.get("pageToken", page_token) + filter_str = params.get("filter", filter_expr) + + result = await api.subscribers.subscriptions.list( + parent_subscriber=args.parent_subscriber, + filter=filter_str, + page_size=pageSize, + page_token=pageToken, + ) + if args.all: + await execute_all_pages(args, result, None, pretty) + else: + print_json(serialize_response(result), pretty) + + elif sub == "create": + payload = get_json_payload(args) + subscription_id = None + if payload: + user = payload.get("user") + data_types = payload.get("dataTypes") + else: + user = args.user + data_types = args.data_types + + params = get_params_payload(args) + subscription_id = params.get("subscriptionId", args.subscription_id) + + if user is None: + print_error_json("Missing user parameter.", status="INVALID_ARGUMENT") + assert isinstance(user, str) + + payload_dry = { + "user": user, + "dataTypes": data_types, + "subscriptionId": subscription_id, + } + check_dry_run( + args.dry_run, + "POST", + f"v4/{args.parent_subscriber}/subscriptions", + payload_dry, + ) + + result = await api.subscribers.subscriptions.create( + parent_subscriber=args.parent_subscriber, + user=user, + data_types=data_types, + subscription_id=subscription_id, + ) + print_json(serialize_response(result), pretty) + + elif sub == "patch": + validate_resource_name(args.name) + payload = get_json_payload(args) + if payload is None: + print_error_json( + "Please provide raw JSON input using --json.", status="INVALID_ARGUMENT" + ) + assert payload is not None + + params = get_params_payload(args) + update_mask = params.get("updateMask", args.update_mask) + + check_dry_run( + args.dry_run, + "PATCH", + f"v4/{args.name}", + {"payload": payload, "updateMask": update_mask}, + ) + + sub_obj = Subscription.from_dict(payload) + result = await api.subscribers.subscriptions.patch( + args.name, sub_obj, update_mask=update_mask + ) + print_json(serialize_response(result), pretty) + + elif sub == "delete": + validate_resource_name(args.name) + check_dry_run(args.dry_run, "DELETE", f"v4/{args.name}") + await api.subscribers.subscriptions.delete(args.name) + print_json( + {"status": "SUCCESS", "message": f"Deleted subscription {args.name}"}, + pretty, + ) diff --git a/google_health_api/cli/subcommands/userinfo.py b/google_health_api/cli/subcommands/userinfo.py new file mode 100644 index 0000000..80f2e22 --- /dev/null +++ b/google_health_api/cli/subcommands/userinfo.py @@ -0,0 +1,11 @@ +"""UserInfo subcommand for Google Health CLI.""" + +from google_health_api.api import GoogleHealthApi + +from ..utils import print_json, serialize_response + + +async def handle_userinfo_cmd(args, api: GoogleHealthApi, pretty: bool) -> None: + """Handle userinfo subcommands.""" + result = await api.get_user_info() + print_json(serialize_response(result), pretty) diff --git a/google_health_api/cli/utils.py b/google_health_api/cli/utils.py new file mode 100644 index 0000000..6b93075 --- /dev/null +++ b/google_health_api/cli/utils.py @@ -0,0 +1,136 @@ +"""Shared CLI utilities.""" + +import json +import sys +from typing import Any, NoReturn + +from google_health_api.model import DataPoint, ReconciledDataPoint + + +def print_json(data: Any, pretty: bool = True) -> None: + """Helper to output JSON data, respect pretty setting.""" + if pretty: + print(json.dumps(data, indent=2)) + else: + print(json.dumps(data)) + + +def print_error_json(message: str, status: str = "INTERNAL") -> NoReturn: + """Print standard JSON error and exit.""" + res = { + "error": { + "status": status, + "message": message, + } + } + print_json(res) + sys.exit(1) + + +def serialize_datapoint(dp: DataPoint, field_name: str) -> dict[str, Any]: + """Serialize generic DataPoint class to dictionary matching API payload structure.""" + res: dict[str, Any] = {} + if dp.name: + res["name"] = dp.name + if dp.data_source: + res["dataSource"] = dp.data_source + if hasattr(dp.data, "to_dict"): + res[field_name] = dp.data.to_dict() + else: + res[field_name] = dp.data + return res + + +def serialize_reconciled_datapoint( + rdp: ReconciledDataPoint, field_name: str +) -> dict[str, Any]: + """Serialize ReconciledDataPoint to dictionary structure.""" + return {"dataPoint": serialize_datapoint(rdp.data_point, field_name)} + + +def serialize_response(result: Any, field_name: str | None = None) -> Any: + """Convert API response object/paginated result to JSON-serializable structure.""" + if hasattr(result, "to_dict"): + return result.to_dict() + if hasattr(result, "data_points"): + return { + "dataPoints": [ + serialize_datapoint(dp, field_name or "") + for dp in result.data_points + ], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "reconciled_data_points"): + return { + "reconciledDataPoints": [ + serialize_reconciled_datapoint(rdp, field_name or "") + for rdp in result.reconciled_data_points + ], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "paired_devices"): + return { + "pairedDevices": [dev.to_dict() for dev in result.paired_devices], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "subscribers"): + return { + "subscribers": [sub.to_dict() for sub in result.subscribers], + "nextPageToken": result.next_page_token, + } + if hasattr(result, "subscriptions"): + return { + "subscriptions": [sub.to_dict() for sub in result.subscriptions], + "nextPageToken": result.next_page_token, + } + return result + + +def get_json_payload(args) -> dict[str, Any] | None: + """Extract and parse raw JSON input payload if present.""" + if not hasattr(args, "json") or not args.json: + return None + try: + return json.loads(args.json) + except json.JSONDecodeError as err: + print_error_json(f"Invalid raw JSON payload: {err}", status="INVALID_ARGUMENT") + return None + + +def get_params_payload(args) -> dict[str, Any]: + """Extract and parse --params query variables if present.""" + if not hasattr(args, "params") or not args.params: + return {} + try: + return json.loads(args.params) + except json.JSONDecodeError as err: + print_error_json( + f"Invalid --params JSON payload: {err}", status="INVALID_ARGUMENT" + ) + return {} + + +async def execute_all_pages( + args, result: Any, field_name: str | None, pretty: bool +) -> None: + """Iterate and print items in NDJSON format for streaming output.""" + async for page in result: + if hasattr(page, "data_points"): + for item in page.data_points: + assert field_name is not None + print_json(serialize_datapoint(item, field_name), pretty=False) + elif hasattr(page, "reconciled_data_points"): + for item in page.reconciled_data_points: + assert field_name is not None + print_json( + serialize_reconciled_datapoint(item, field_name), pretty=False + ) + elif hasattr(page, "paired_devices"): + for item in page.paired_devices: + print_json(item.to_dict(), pretty=False) + elif hasattr(page, "subscribers"): + for item in page.subscribers: + print_json(item.to_dict(), pretty=False) + elif hasattr(page, "subscriptions"): + for item in page.subscriptions: + print_json(item.to_dict(), pretty=False) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..41515d9 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,43 @@ +"""Shared pytest fixtures for CLI tests.""" + +from collections.abc import Generator +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from google_health_api.cli.main import main + + +def run_cli(args: list[str]) -> None: + """Helper to run the CLI with specific arguments.""" + with patch.object(sys, "argv", ["google-health-cli", *args]): + main() + + +@pytest.fixture +def mock_load_credentials() -> Generator[MagicMock, None, None]: + """Fixture to mock load_credentials_or_env.""" + with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: + yield mock + + +@pytest.fixture +def mock_setup_client() -> Generator[MagicMock, None, None]: + """Fixture to mock setup_client.""" + with patch("google_health_api.cli.commands.setup_client") as mock: + yield mock + + +@pytest.fixture +def mock_flow_cls() -> Generator[MagicMock, None, None]: + """Fixture to mock Flow in login subcommand.""" + with patch("google_health_api.cli.subcommands.login.Flow") as mock: + yield mock + + +@pytest.fixture +def mock_installed_flow_cls() -> Generator[MagicMock, None, None]: + """Fixture to mock InstalledAppFlow in login subcommand.""" + with patch("google_health_api.cli.subcommands.login.InstalledAppFlow") as mock: + yield mock diff --git a/tests/cli/subcommands/__init__.py b/tests/cli/subcommands/__init__.py new file mode 100644 index 0000000..b212448 --- /dev/null +++ b/tests/cli/subcommands/__init__.py @@ -0,0 +1 @@ +"""Subcommand tests for Google Health CLI.""" diff --git a/tests/cli/subcommands/test_devices.py b/tests/cli/subcommands/test_devices.py new file mode 100644 index 0000000..d09e3ba --- /dev/null +++ b/tests/cli/subcommands/test_devices.py @@ -0,0 +1,38 @@ +"""Tests for devices CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_devices_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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"]) + captured = capsys.readouterr() + assert json.loads(captured.out)["id"] == "dev123" diff --git a/tests/cli/subcommands/test_identity.py b/tests/cli/subcommands/test_identity.py new file mode 100644 index 0000000..63912a6 --- /dev/null +++ b/tests/cli/subcommands/test_identity.py @@ -0,0 +1,30 @@ +"""Tests for identity and irn CLI commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_identity_and_irn( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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"}) + ) + run_cli(["identity", "get"]) + mock_api.get_identity.assert_called_once() + + mock_api.get_irn_profile = AsyncMock( + return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) + ) + run_cli(["irn", "get"]) + mock_api.get_irn_profile.assert_called_once() diff --git a/tests/cli/subcommands/test_login.py b/tests/cli/subcommands/test_login.py new file mode 100644 index 0000000..25fade7 --- /dev/null +++ b/tests/cli/subcommands/test_login.py @@ -0,0 +1,192 @@ +"""Tests for login and auth flow CLI commands.""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from google_health_api.cli.auth import ( + CredentialsAuth, + EnvAuth, + load_credentials_or_env, +) +from google_health_api.cli.subcommands.login import cmd_login +from tests.cli.conftest import run_cli + + +@pytest.mark.asyncio +async def test_credentials_auth_refresh( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test CredentialsAuth refreshing expired credentials.""" + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) + + creds_mock = MagicMock() + creds_mock.valid = False + creds_mock.token = "refreshed-token" + creds_mock.to_json.return_value = '{"token": "refreshed-token"}' + + session_mock = MagicMock() + auth = CredentialsAuth(session_mock, creds_mock) + + token = await auth.async_get_access_token() + assert token == "refreshed-token" + assert creds_mock.refresh.called + assert token_file.exists() + + +@pytest.mark.asyncio +async def test_env_auth() -> None: + """Test EnvAuth token retrieval.""" + session_mock = MagicMock() + auth = EnvAuth(session_mock, "env-secret-token") + token = await auth.async_get_access_token() + assert token == "env-secret-token" + + +def test_load_credentials_or_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test loading credentials from env vs token file.""" + # 1. Test env var set + monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") + res = load_credentials_or_env() + assert res == ("env", "env-tok") + + # 2. Test no file and no env var + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + token_file = tmp_path / "token.json" + monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) + assert load_credentials_or_env() is None + + # 3. Test token file exists with Z expiry + token_data = { + "token": "file-tok", + "refresh_token": "re-tok", + "expiry": "2026-12-31T23:59:59Z", + } + token_file.write_text(json.dumps(token_data)) + res_file = load_credentials_or_env() + assert res_file is not None + assert res_file[0] == "file" + assert res_file[1].token == "file-tok" + + # 4. Token file with non-Z iso string with offset + token_data["expiry"] = "2026-12-31T23:59:59+00:00" + token_file.write_text(json.dumps(token_data)) + res_file2 = load_credentials_or_env() + assert res_file2 is not None + assert res_file2[0] == "file" + + +def test_cmd_login_missing_client_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test login failure when client secret file is missing.""" + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", + str(tmp_path / "nonexistent.json"), + ) + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "not found" in captured.out + + +def test_cmd_login_not_tty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test login failure in non-interactive environment.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"web": {}})) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "headless environment" in captured.out + + +def test_cmd_login_web_flow( + mock_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test login web OAuth flow.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text( + json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) + ) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_flow = MagicMock() + mock_flow_cls.from_client_secrets_file.return_value = mock_flow + mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.credentials = mock_creds + + # Empty response -> error + monkeypatch.setattr("builtins.input", lambda prompt="": "") + with pytest.raises(SystemExit): + cmd_login(MagicMock()) + + # Valid auth code response + monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") + cmd_login(MagicMock()) + assert mock_flow.fetch_token.called + + +def test_cmd_login_installed_app_flow( + mock_installed_flow_cls: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test login installed app flow.""" + secret_file = tmp_path / "client_secret.json" + secret_file.write_text(json.dumps({"installed": {}})) + monkeypatch.setattr( + "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) + ) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") + ) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + mock_flow = MagicMock() + mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow + mock_creds = MagicMock() + mock_creds.to_json.return_value = '{"token": "abc"}' + mock_flow.run_local_server.return_value = mock_creds + + cmd_login(MagicMock()) + captured = capsys.readouterr() + assert "Logged in successfully" in captured.out + + +def test_unauthenticated_cli_setup( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Test error when setup_client finds no auth token/credentials.""" + monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) + monkeypatch.setattr( + "google_health_api.cli.auth.TOKEN_FILE", "non_existent_token.json" + ) + + with pytest.raises(SystemExit): + run_cli(["userinfo"]) + captured = capsys.readouterr() + assert "Not logged in" in captured.out diff --git a/tests/cli/subcommands/test_profile.py b/tests/cli/subcommands/test_profile.py new file mode 100644 index 0000000..42203ea --- /dev/null +++ b/tests/cli/subcommands/test_profile.py @@ -0,0 +1,69 @@ +"""Tests for profile CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_profile_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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 = { + "name": "users/me/profile", + "displayName": "Alice", + } + mock_api.get_profile = AsyncMock(return_value=mock_prof) + + run_cli(["profile", "get"]) + captured = capsys.readouterr() + assert json.loads(captured.out)["displayName"] == "Alice" + + # profile update with json & update-mask & dry-run + payload = {"name": "users/me/profile", "displayName": "Bob"} + with pytest.raises(SystemExit) as exit_info: + run_cli( + [ + "--dry-run", + "--json", + json.dumps(payload), + "--params", + json.dumps({"updateMask": "displayName"}), + "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( + [ + "--json", + json.dumps(payload), + "profile", + "update", + "--update-mask", + "displayName", + ] + ) + mock_api.update_profile.assert_called_once() + + # profile update missing json + with pytest.raises(SystemExit): + run_cli(["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 new file mode 100644 index 0000000..ae4f75b --- /dev/null +++ b/tests/cli/subcommands/test_settings.py @@ -0,0 +1,47 @@ +"""Tests for settings CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_settings_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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 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) + + run_cli(["settings", "get"]) + 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( + [ + "--json", + json.dumps(payload), + "settings", + "update", + "--update-mask", + "timeZone", + ] + ) + mock_api.update_settings.assert_called_once() + + # settings update missing json + with pytest.raises(SystemExit): + run_cli(["settings", "update"]) diff --git a/tests/cli/subcommands/test_subscribers.py b/tests/cli/subcommands/test_subscribers.py new file mode 100644 index 0000000..01bb1bc --- /dev/null +++ b/tests/cli/subcommands/test_subscribers.py @@ -0,0 +1,110 @@ +"""Tests for subscribers CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_subscribers_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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 + + # subscribers list + mock_sub_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscribers": []} + ) + 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( + [ + "--dry-run", + "subscribers", + "create", + "--endpoint-uri", + "https://example.com/webhook", + "--endpoint-secret", + "secret123", + ] + ) + assert exit_info.value.code == 0 + + # subscribers create with json + payload = { + "name": "projects/me/subscribers/sub1", + "endpointUri": "https://example.com/webhook", + "endpointAuthorization": {"secret": "secret123"}, + "subscriberConfigs": [{"dataType": "steps"}], + } + mock_sub_api.create.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"name": "sub1"} + ) + run_cli( + [ + "--json", + json.dumps(payload), + "subscribers", + "create", + ] + ) + mock_sub_api.create.assert_called_once() + + # subscribers create missing endpointUri + with pytest.raises(SystemExit): + run_cli(["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( + [ + "--json", + json.dumps(sub_payload), + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ] + ) + mock_sub_api.patch.assert_called_once() + + # subscribers patch missing json + with pytest.raises(SystemExit): + run_cli( + [ + "subscribers", + "patch", + "projects/me/subscribers/sub1", + ] + ) + + # subscribers delete + mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) + run_cli( + [ + "subscribers", + "delete", + "projects/me/subscribers/sub1", + "--force", + ] + ) + mock_sub_api.delete.assert_called_once() diff --git a/tests/cli/subcommands/test_subscriptions.py b/tests/cli/subcommands/test_subscriptions.py new file mode 100644 index 0000000..8650fe2 --- /dev/null +++ b/tests/cli/subcommands/test_subscriptions.py @@ -0,0 +1,119 @@ +"""Tests for subscriptions CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_subscriptions_commands( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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 + + # subscriptions list + mock_subscriptions_api.list.return_value = MagicMock( + spec=["to_dict"], to_dict=lambda: {"subscriptions": []} + ) + run_cli( + [ + "subscriptions", + "list", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ] + ) + 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( + [ + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + "--user", + "users/me", + "--data-types", + "steps", + ] + ) + mock_subscriptions_api.create.assert_called_once() + + # subscriptions create with json + payload = {"user": "users/me", "dataTypes": ["steps"]} + run_cli( + [ + "--json", + json.dumps(payload), + "subscriptions", + "create", + "--parent-subscriber", + "projects/me/subscribers/sub1", + ] + ) + + # subscriptions create missing user + with pytest.raises(SystemExit): + run_cli( + [ + "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( + [ + "--json", + json.dumps(sub_patch_payload), + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + + # subscriptions patch missing json + with pytest.raises(SystemExit): + run_cli( + [ + "subscriptions", + "patch", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + + # subscriptions delete + mock_subscriptions_api.delete.return_value = None + run_cli( + [ + "subscriptions", + "delete", + "projects/me/subscribers/sub1/subscriptions/s1", + ] + ) + 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 new file mode 100644 index 0000000..451d16f --- /dev/null +++ b/tests/cli/subcommands/test_userinfo.py @@ -0,0 +1,36 @@ +"""Tests for userinfo CLI command.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.cli.conftest import run_cli + + +def test_cli_userinfo( + mock_load_credentials: MagicMock, + mock_setup_client: MagicMock, + 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 = { + "sub": "110248495921238986420", + "name": "John Doe", + "email": "johndoe@example.com", + } + mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) + + run_cli(["userinfo"]) + + mock_api.get_user_info.assert_called_once() + captured = capsys.readouterr() + res_json = json.loads(captured.out) + assert res_json["sub"] == "110248495921238986420" + assert res_json["name"] == "John Doe" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index f0fa02d..b4f7c32 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,8 +1,6 @@ """Tests for the Google Health CLI.""" import json -import sys -from collections.abc import Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -10,51 +8,13 @@ from google_health_api.cli.commands import ( CliHealthSession, - CredentialsAuth, - EnvAuth, - cmd_login, fields_var, - load_credentials_or_env, serialize_response, ) -from google_health_api.cli.main import main from google_health_api.cli.validation import validate_resource_name, validate_safe_path from google_health_api.client import GoogleHealthSession from google_health_api.exceptions import HealthApiException - - -def run_cli(args: list[str]) -> None: - """Helper to run the CLI with specific arguments.""" - with patch.object(sys, "argv", ["google-health-cli", *args]): - main() - - -@pytest.fixture -def mock_load_credentials() -> Generator[MagicMock]: - """Fixture to mock load_credentials_or_env.""" - with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: - yield mock - - -@pytest.fixture -def mock_setup_client() -> Generator[MagicMock]: - """Fixture to mock setup_client.""" - with patch("google_health_api.cli.commands.setup_client") as mock: - yield mock - - -@pytest.fixture -def mock_flow_cls() -> Generator[MagicMock]: - """Fixture to mock google_auth_oauthlib.flow.Flow.""" - with patch("google_health_api.cli.commands.Flow") as mock: - yield mock - - -@pytest.fixture -def mock_installed_flow_cls() -> Generator[MagicMock]: - """Fixture to mock google_auth_oauthlib.flow.InstalledAppFlow.""" - with patch("google_health_api.cli.commands.InstalledAppFlow") as mock: - yield mock +from tests.cli.conftest import run_cli @pytest.mark.parametrize( @@ -314,43 +274,9 @@ def test_cli_weight_rollup( assert res_json["rollupDataPoints"][0]["weight"]["weightGramsAvg"] == 75000.0 -def test_cli_userinfo( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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 = { - "sub": "110248495921238986420", - "name": "John Doe", - "email": "johndoe@example.com", - } - mock_api.get_user_info = AsyncMock(return_value=mock_userinfo) - - run_cli(["userinfo"]) - - mock_api.get_user_info.assert_called_once() - captured = capsys.readouterr() - res_json = json.loads(captured.out) - assert res_json["sub"] == "110248495921238986420" - assert res_json["name"] == "John Doe" - - -# ===================================================================== -# Additional Comprehensive CLI Tests -# ===================================================================== - - @pytest.mark.asyncio async def test_cli_health_session_fields_injection() -> None: """Test dynamic fields parameter injection in CliHealthSession.""" - auth_mock = AsyncMock() auth_mock.async_get_access_token = AsyncMock(return_value="fake-token") mock_session = AsyncMock() @@ -391,541 +317,6 @@ async def test_cli_health_session_fields_injection() -> None: fields_var.reset(token_val) -@pytest.mark.asyncio -async def test_credentials_auth_refresh( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test CredentialsAuth refreshing expired credentials.""" - - token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) - - creds_mock = MagicMock() - creds_mock.valid = False - creds_mock.token = "refreshed-token" - creds_mock.to_json.return_value = '{"token": "refreshed-token"}' - - session_mock = MagicMock() - auth = CredentialsAuth(session_mock, creds_mock) - - token = await auth.async_get_access_token() - assert token == "refreshed-token" - assert creds_mock.refresh.called - assert token_file.exists() - - -@pytest.mark.asyncio -async def test_env_auth() -> None: - """Test EnvAuth token retrieval.""" - - session_mock = MagicMock() - auth = EnvAuth(session_mock, "env-secret-token") - token = await auth.async_get_access_token() - assert token == "env-secret-token" - - -def test_load_credentials_or_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test loading credentials from env vs token file.""" - - # 1. Test env var set - monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") - res = load_credentials_or_env() - assert res == ("env", "env-tok") - - # 2. Test no file and no env var - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) - token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.commands.TOKEN_FILE", str(token_file)) - assert load_credentials_or_env() is None - - # 3. Test token file exists with Z expiry - token_data = { - "token": "file-tok", - "refresh_token": "re-tok", - "expiry": "2026-12-31T23:59:59Z", - } - token_file.write_text(json.dumps(token_data)) - res_file = load_credentials_or_env() - assert res_file is not None - assert res_file[0] == "file" - assert res_file[1].token == "file-tok" - - # 4. Token file with non-Z iso string with offset - token_data["expiry"] = "2026-12-31T23:59:59+00:00" - token_file.write_text(json.dumps(token_data)) - res_file2 = load_credentials_or_env() - assert res_file2 is not None - assert res_file2[0] == "file" - - -def test_cmd_login_missing_client_secret( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test login failure when client secret file is missing.""" - - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", - str(tmp_path / "nonexistent.json"), - ) - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "not found" in captured.out - - -def test_cmd_login_not_tty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test login failure in non-interactive environment.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text(json.dumps({"web": {}})) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: False) - - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "headless environment" in captured.out - - -def test_cmd_login_web_flow( - mock_flow_cls: MagicMock, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test login web OAuth flow.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text( - json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - - mock_flow = MagicMock() - mock_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") - mock_creds = MagicMock() - mock_creds.to_json.return_value = '{"token": "abc"}' - mock_flow.credentials = mock_creds - - # Empty response -> error - monkeypatch.setattr("builtins.input", lambda prompt="": "") - with pytest.raises(SystemExit): - cmd_login(MagicMock()) - - # Valid auth code response - monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") - cmd_login(MagicMock()) - assert mock_flow.fetch_token.called - - -def test_cmd_login_installed_app_flow( - mock_installed_flow_cls: MagicMock, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test login installed app flow.""" - - secret_file = tmp_path / "client_secret.json" - secret_file.write_text(json.dumps({"installed": {}})) - monkeypatch.setattr( - "google_health_api.cli.commands.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) - - mock_flow = MagicMock() - mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_creds = MagicMock() - mock_creds.to_json.return_value = '{"token": "abc"}' - mock_flow.run_local_server.return_value = mock_creds - - cmd_login(MagicMock()) - captured = capsys.readouterr() - assert "Logged in successfully" in captured.out - - -def test_cli_profile_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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 = { - "name": "users/me/profile", - "displayName": "Alice", - } - mock_api.get_profile = AsyncMock(return_value=mock_prof) - - run_cli(["profile", "get"]) - captured = capsys.readouterr() - assert json.loads(captured.out)["displayName"] == "Alice" - - # profile update with json & update-mask & dry-run - payload = {"name": "users/me/profile", "displayName": "Bob"} - with pytest.raises(SystemExit) as exit_info: - run_cli( - [ - "--dry-run", - "--json", - json.dumps(payload), - "--params", - json.dumps({"updateMask": "displayName"}), - "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( - [ - "--json", - json.dumps(payload), - "profile", - "update", - "--update-mask", - "displayName", - ] - ) - mock_api.update_profile.assert_called_once() - - # profile update missing json - with pytest.raises(SystemExit): - run_cli(["profile", "update"]) - captured = capsys.readouterr() - assert "Please provide raw JSON input" in captured.out - - -def test_cli_settings_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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 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) - - run_cli(["settings", "get"]) - 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( - [ - "--json", - json.dumps(payload), - "settings", - "update", - "--update-mask", - "timeZone", - ] - ) - mock_api.update_settings.assert_called_once() - - # settings update missing json - with pytest.raises(SystemExit): - run_cli(["settings", "update"]) - - -def test_cli_devices_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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"]) - captured = capsys.readouterr() - assert json.loads(captured.out)["id"] == "dev123" - - -def test_cli_subscribers_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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 - - # subscribers list - mock_sub_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscribers": []} - ) - 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( - [ - "--dry-run", - "subscribers", - "create", - "--endpoint-uri", - "https://example.com/webhook", - "--endpoint-secret", - "secret123", - ] - ) - assert exit_info.value.code == 0 - - # subscribers create with json - payload = { - "name": "projects/me/subscribers/sub1", - "endpointUri": "https://example.com/webhook", - "endpointAuthorization": {"secret": "secret123"}, - "subscriberConfigs": [{"dataType": "steps"}], - } - mock_sub_api.create.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"name": "sub1"} - ) - run_cli( - [ - "--json", - json.dumps(payload), - "subscribers", - "create", - ] - ) - mock_sub_api.create.assert_called_once() - - # subscribers create missing endpointUri - with pytest.raises(SystemExit): - run_cli(["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( - [ - "--json", - json.dumps(sub_payload), - "subscribers", - "patch", - "projects/me/subscribers/sub1", - ] - ) - mock_sub_api.patch.assert_called_once() - - # subscribers patch missing json - with pytest.raises(SystemExit): - run_cli( - [ - "subscribers", - "patch", - "projects/me/subscribers/sub1", - ] - ) - - # subscribers delete - mock_sub_api.delete.return_value = MagicMock(spec=["to_dict"], to_dict=dict) - run_cli( - [ - "subscribers", - "delete", - "projects/me/subscribers/sub1", - "--force", - ] - ) - mock_sub_api.delete.assert_called_once() - - -def test_cli_subscriptions_commands( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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 - - # subscriptions list - mock_subscriptions_api.list.return_value = MagicMock( - spec=["to_dict"], to_dict=lambda: {"subscriptions": []} - ) - run_cli( - [ - "subscriptions", - "list", - "--parent-subscriber", - "projects/me/subscribers/sub1", - ] - ) - 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( - [ - "subscriptions", - "create", - "--parent-subscriber", - "projects/me/subscribers/sub1", - "--user", - "users/me", - "--data-types", - "steps", - ] - ) - mock_subscriptions_api.create.assert_called_once() - - # subscriptions create with json - payload = {"user": "users/me", "dataTypes": ["steps"]} - run_cli( - [ - "--json", - json.dumps(payload), - "subscriptions", - "create", - "--parent-subscriber", - "projects/me/subscribers/sub1", - ] - ) - - # subscriptions create missing user - with pytest.raises(SystemExit): - run_cli( - [ - "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( - [ - "--json", - json.dumps(sub_patch_payload), - "subscriptions", - "patch", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - - # subscriptions patch missing json - with pytest.raises(SystemExit): - run_cli( - [ - "subscriptions", - "patch", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - - # subscriptions delete - mock_subscriptions_api.delete.return_value = None - run_cli( - [ - "subscriptions", - "delete", - "projects/me/subscribers/sub1/subscriptions/s1", - ] - ) - captured = capsys.readouterr() - assert "Deleted subscription" in captured.out - - -def test_cli_identity_and_irn( - mock_load_credentials: MagicMock, - mock_setup_client: MagicMock, - 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"}) - ) - run_cli(["identity", "get"]) - mock_api.get_identity.assert_called_once() - - mock_api.get_irn_profile = AsyncMock( - return_value=MagicMock(spec=["to_dict"], to_dict=lambda: {"status": "ok"}) - ) - run_cli(["irn", "get"]) - mock_api.get_irn_profile.assert_called_once() - - def test_cli_additional_datatypes( mock_load_credentials: MagicMock, mock_setup_client: MagicMock, @@ -1162,7 +553,6 @@ async def async_iter_subscription(): def test_serialize_response_reconciled_datapoints() -> None: """Test serialize_response for reconciled data points and generic objects.""" - dp = MagicMock() dp.name = "p1" dp.data_source = None @@ -1189,7 +579,6 @@ def test_cli_exceptions_handling( capsys: pytest.CaptureFixture[str], ) -> None: """Test HealthApiException and general Exception handling in async_run_cmd.""" - mock_load_credentials.return_value = ("env", "fake-token") # HealthApiException @@ -1241,18 +630,3 @@ def test_cli_invalid_json_payloads( ) captured = capsys.readouterr() assert "Invalid --params JSON payload" in captured.out - - -def test_unauthenticated_cli_setup( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Test error when setup_client finds no auth token/credentials.""" - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) - monkeypatch.setattr( - "google_health_api.cli.commands.TOKEN_FILE", "non_existent_token.json" - ) - - with pytest.raises(SystemExit): - run_cli(["userinfo"]) - captured = capsys.readouterr() - assert "Not logged in" in captured.out From bb1f00658dcdc70cbeb2aebffa6f43a0452948e3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 07:23:12 -0700 Subject: [PATCH 22/24] chore: Apply Ruff formatting and import fixes to CLI submodules and tests --- google_health_api/cli/auth.py | 2 +- google_health_api/cli/commands.py | 8 ++------ google_health_api/cli/utils.py | 3 +-- tests/cli/conftest.py | 10 +++++----- tests/cli/subcommands/test_login.py | 2 +- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/google_health_api/cli/auth.py b/google_health_api/cli/auth.py index 93ad9bf..1580d7f 100644 --- a/google_health_api/cli/auth.py +++ b/google_health_api/cli/auth.py @@ -2,9 +2,9 @@ import asyncio import contextvars -from datetime import UTC, datetime import json import os +from datetime import UTC, datetime from typing import Any import aiohttp diff --git a/google_health_api/cli/commands.py b/google_health_api/cli/commands.py index a8492e6..38c1b3c 100644 --- a/google_health_api/cli/commands.py +++ b/google_health_api/cli/commands.py @@ -1,9 +1,8 @@ """Command implementations for Google Health CLI.""" -from datetime import UTC, date, datetime, timedelta import os import sys -from typing import Any +from datetime import UTC, date, datetime, timedelta from zoneinfo import ZoneInfo import aiohttp @@ -351,10 +350,7 @@ async def async_run_cmd(args) -> None: # Set fields context variable fields_var.set(args.fields) - pretty = ( - (args.output == "pretty" and sys.stdout.isatty()) - or args.output == "json" - ) + pretty = (args.output == "pretty" and sys.stdout.isatty()) or args.output == "json" async with aiohttp.ClientSession() as session: try: diff --git a/google_health_api/cli/utils.py b/google_health_api/cli/utils.py index 6b93075..12fc1b0 100644 --- a/google_health_api/cli/utils.py +++ b/google_health_api/cli/utils.py @@ -55,8 +55,7 @@ def serialize_response(result: Any, field_name: str | None = None) -> Any: if hasattr(result, "data_points"): return { "dataPoints": [ - serialize_datapoint(dp, field_name or "") - for dp in result.data_points + serialize_datapoint(dp, field_name or "") for dp in result.data_points ], "nextPageToken": result.next_page_token, } diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 41515d9..b277e94 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -1,7 +1,7 @@ """Shared pytest fixtures for CLI tests.""" -from collections.abc import Generator import sys +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest @@ -16,28 +16,28 @@ def run_cli(args: list[str]) -> None: @pytest.fixture -def mock_load_credentials() -> Generator[MagicMock, None, None]: +def mock_load_credentials() -> Generator[MagicMock]: """Fixture to mock load_credentials_or_env.""" with patch("google_health_api.cli.commands.load_credentials_or_env") as mock: yield mock @pytest.fixture -def mock_setup_client() -> Generator[MagicMock, None, None]: +def mock_setup_client() -> Generator[MagicMock]: """Fixture to mock setup_client.""" with patch("google_health_api.cli.commands.setup_client") as mock: yield mock @pytest.fixture -def mock_flow_cls() -> Generator[MagicMock, None, None]: +def mock_flow_cls() -> Generator[MagicMock]: """Fixture to mock Flow in login subcommand.""" with patch("google_health_api.cli.subcommands.login.Flow") as mock: yield mock @pytest.fixture -def mock_installed_flow_cls() -> Generator[MagicMock, None, None]: +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 diff --git a/tests/cli/subcommands/test_login.py b/tests/cli/subcommands/test_login.py index 25fade7..66575c1 100644 --- a/tests/cli/subcommands/test_login.py +++ b/tests/cli/subcommands/test_login.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest From ffc434e70a6efe4f7d7dd3113288d2ab9b3bbbfc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 07:28:02 -0700 Subject: [PATCH 23/24] refactor: Eliminate pytest monkeypatch from CLI tests using dependency injection --- google_health_api/cli/auth.py | 27 +++-- google_health_api/cli/subcommands/login.py | 28 +++-- tests/cli/subcommands/test_login.py | 117 ++++++++++----------- 3 files changed, 94 insertions(+), 78 deletions(-) diff --git a/google_health_api/cli/auth.py b/google_health_api/cli/auth.py index 1580d7f..b683025 100644 --- a/google_health_api/cli/auth.py +++ b/google_health_api/cli/auth.py @@ -63,17 +63,22 @@ class CredentialsAuth(AbstractAuth): """Auth wrapper that uses google-auth credentials.""" def __init__( - self, websession: aiohttp.ClientSession, credentials, host: str | None = None + self, + websession: aiohttp.ClientSession, + credentials, + host: str | None = None, + token_file: str | None = None, ) -> None: super().__init__(websession, host) self._credentials = credentials + self._token_file = token_file async def async_get_access_token(self) -> str: if not self._credentials.valid: loop = asyncio.get_running_loop() req = Request() await loop.run_in_executor(None, self._credentials.refresh, req) - save_credentials(self._credentials) + save_credentials(self._credentials, token_file=self._token_file) return self._credentials.token @@ -90,22 +95,28 @@ async def async_get_access_token(self) -> str: return self._token -def save_credentials(credentials) -> None: +def save_credentials(credentials, token_file: str | None = None) -> None: """Save credentials to local token file.""" - with open(TOKEN_FILE, "w") as f: + path = token_file or os.environ.get("GOOGLE_HEALTH_CLI_TOKEN_FILE") or TOKEN_FILE + with open(path, "w") as f: f.write(credentials.to_json()) -def load_credentials_or_env(): +def load_credentials_or_env( + token_file: str | None = None, + environ: dict[str, str] | None = None, +): """Load credentials from environment or token.json.""" - token_env = os.environ.get("GOOGLE_HEALTH_CLI_TOKEN") + env = environ if environ is not None else os.environ + token_env = env.get("GOOGLE_HEALTH_CLI_TOKEN") if token_env: return ("env", token_env) - if not os.path.exists(TOKEN_FILE): + path = token_file or env.get("GOOGLE_HEALTH_CLI_TOKEN_FILE") or TOKEN_FILE + if not os.path.exists(path): return None - with open(TOKEN_FILE, "r") as f: + with open(path, "r") as f: data = json.load(f) expiry_str = data.get("expiry") diff --git a/google_health_api/cli/subcommands/login.py b/google_health_api/cli/subcommands/login.py index 2893ca4..ee953b1 100644 --- a/google_health_api/cli/subcommands/login.py +++ b/google_health_api/cli/subcommands/login.py @@ -3,6 +3,7 @@ import json import os import sys +from collections.abc import Callable from google_auth_oauthlib.flow import Flow, InstalledAppFlow @@ -10,21 +11,29 @@ from ..utils import print_error_json, print_json -def cmd_login(args) -> None: +def cmd_login( + args, + client_secret_file: str | None = None, + token_file: str | None = None, + is_tty: bool | None = None, + input_func: Callable[[str], str] | None = None, +) -> None: """Execute interactive OAuth login flow.""" - if not os.path.exists(CLIENT_SECRET_FILE): + secret_path = client_secret_file or CLIENT_SECRET_FILE + if not os.path.exists(secret_path): print_error_json( - f"Client secrets file '{CLIENT_SECRET_FILE}' not found.", + f"Client secrets file '{secret_path}' not found.", status="NOT_FOUND", ) - if not sys.stdin.isatty(): + tty = is_tty if is_tty is not None else sys.stdin.isatty() + if not tty: print_error_json( "Cannot run interactive login in a headless environment.", status="FAILED_PRECONDITION", ) - with open(CLIENT_SECRET_FILE, "r") as f: + with open(secret_path, "r") as f: client_secrets_data = json.load(f) is_web = "web" in client_secrets_data @@ -34,7 +43,7 @@ def cmd_login(args) -> None: redirect_uri = redirect_uris[0] if redirect_uris else "http://localhost:8080/" flow = Flow.from_client_secrets_file( - CLIENT_SECRET_FILE, + secret_path, scopes=SCOPES, redirect_uri=redirect_uri, ) @@ -44,7 +53,8 @@ def cmd_login(args) -> None: ) print("Web-based authentication flow:") print(f"URL: {authorization_url}") - redirect_response = input("Redirected URL or auth code: ").strip() + inp = input_func or input + redirect_response = inp("Redirected URL or auth code: ").strip() if not redirect_response: print_error_json( @@ -61,10 +71,10 @@ def cmd_login(args) -> None: credentials = flow.credentials else: flow = InstalledAppFlow.from_client_secrets_file( - CLIENT_SECRET_FILE, + secret_path, scopes=SCOPES, ) credentials = flow.run_local_server(port=0) - save_credentials(credentials) + save_credentials(credentials, token_file=token_file) print_json({"status": "SUCCESS", "message": "Logged in successfully."}) diff --git a/tests/cli/subcommands/test_login.py b/tests/cli/subcommands/test_login.py index 66575c1..5fc7c9d 100644 --- a/tests/cli/subcommands/test_login.py +++ b/tests/cli/subcommands/test_login.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -16,12 +16,9 @@ @pytest.mark.asyncio -async def test_credentials_auth_refresh( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_credentials_auth_refresh(tmp_path: Path) -> None: """Test CredentialsAuth refreshing expired credentials.""" token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) creds_mock = MagicMock() creds_mock.valid = False @@ -29,7 +26,7 @@ async def test_credentials_auth_refresh( creds_mock.to_json.return_value = '{"token": "refreshed-token"}' session_mock = MagicMock() - auth = CredentialsAuth(session_mock, creds_mock) + auth = CredentialsAuth(session_mock, creds_mock, token_file=str(token_file)) token = await auth.async_get_access_token() assert token == "refreshed-token" @@ -46,20 +43,15 @@ async def test_env_auth() -> None: assert token == "env-secret-token" -def test_load_credentials_or_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_load_credentials_or_env(tmp_path: Path) -> None: """Test loading credentials from env vs token file.""" # 1. Test env var set - monkeypatch.setenv("GOOGLE_HEALTH_CLI_TOKEN", "env-tok") - res = load_credentials_or_env() + res = load_credentials_or_env(environ={"GOOGLE_HEALTH_CLI_TOKEN": "env-tok"}) assert res == ("env", "env-tok") # 2. Test no file and no env var - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) token_file = tmp_path / "token.json" - monkeypatch.setattr("google_health_api.cli.auth.TOKEN_FILE", str(token_file)) - assert load_credentials_or_env() is None + assert load_credentials_or_env(token_file=str(token_file), environ={}) is None # 3. Test token file exists with Z expiry token_data = { @@ -68,7 +60,7 @@ def test_load_credentials_or_env( "expiry": "2026-12-31T23:59:59Z", } token_file.write_text(json.dumps(token_data)) - res_file = load_credentials_or_env() + res_file = load_credentials_or_env(token_file=str(token_file), environ={}) assert res_file is not None assert res_file[0] == "file" assert res_file[1].token == "file-tok" @@ -76,38 +68,35 @@ def test_load_credentials_or_env( # 4. Token file with non-Z iso string with offset token_data["expiry"] = "2026-12-31T23:59:59+00:00" token_file.write_text(json.dumps(token_data)) - res_file2 = load_credentials_or_env() + res_file2 = load_credentials_or_env(token_file=str(token_file), environ={}) assert res_file2 is not None assert res_file2[0] == "file" def test_cmd_login_missing_client_secret( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """Test login failure when client secret file is missing.""" - monkeypatch.setattr( - "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", - str(tmp_path / "nonexistent.json"), - ) with pytest.raises(SystemExit): - cmd_login(MagicMock()) + cmd_login( + MagicMock(), + client_secret_file=str(tmp_path / "nonexistent.json"), + ) captured = capsys.readouterr() assert "not found" in captured.out -def test_cmd_login_not_tty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: +def test_cmd_login_not_tty(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """Test login failure in non-interactive environment.""" secret_file = tmp_path / "client_secret.json" secret_file.write_text(json.dumps({"web": {}})) - monkeypatch.setattr( - "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: False) with pytest.raises(SystemExit): - cmd_login(MagicMock()) + cmd_login( + MagicMock(), + client_secret_file=str(secret_file), + is_tty=False, + ) captured = capsys.readouterr() assert "headless environment" in captured.out @@ -115,7 +104,6 @@ def test_cmd_login_not_tty( def test_cmd_login_web_flow( mock_flow_cls: MagicMock, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Test login web OAuth flow.""" @@ -123,48 +111,48 @@ def test_cmd_login_web_flow( secret_file.write_text( json.dumps({"web": {"redirect_uris": ["http://localhost:8080/"]}}) ) - monkeypatch.setattr( - "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) + token_file = tmp_path / "token.json" mock_flow = MagicMock() mock_flow_cls.from_client_secrets_file.return_value = mock_flow - mock_flow.authorization_url.return_value = ("https://auth.example.com", "state") + mock_flow.authorization_url.return_value = ( + "https://auth.example.com", + "state", + ) mock_creds = MagicMock() mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.credentials = mock_creds # Empty response -> error - monkeypatch.setattr("builtins.input", lambda prompt="": "") with pytest.raises(SystemExit): - cmd_login(MagicMock()) + cmd_login( + MagicMock(), + client_secret_file=str(secret_file), + token_file=str(token_file), + is_tty=True, + input_func=lambda prompt="": "", + ) # Valid auth code response - monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123") - cmd_login(MagicMock()) + cmd_login( + MagicMock(), + client_secret_file=str(secret_file), + token_file=str(token_file), + is_tty=True, + input_func=lambda prompt="": "code=auth123", + ) assert mock_flow.fetch_token.called def test_cmd_login_installed_app_flow( mock_installed_flow_cls: MagicMock, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """Test login installed app flow.""" secret_file = tmp_path / "client_secret.json" secret_file.write_text(json.dumps({"installed": {}})) - monkeypatch.setattr( - "google_health_api.cli.subcommands.login.CLIENT_SECRET_FILE", str(secret_file) - ) - monkeypatch.setattr( - "google_health_api.cli.auth.TOKEN_FILE", str(tmp_path / "token.json") - ) - monkeypatch.setattr("sys.stdin.isatty", lambda: True) + token_file = tmp_path / "token.json" mock_flow = MagicMock() mock_installed_flow_cls.from_client_secrets_file.return_value = mock_flow @@ -172,21 +160,28 @@ def test_cmd_login_installed_app_flow( mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.run_local_server.return_value = mock_creds - cmd_login(MagicMock()) + cmd_login( + MagicMock(), + client_secret_file=str(secret_file), + token_file=str(token_file), + is_tty=True, + ) captured = capsys.readouterr() assert "Logged in successfully" in captured.out -def test_unauthenticated_cli_setup( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: +def test_unauthenticated_cli_setup(capsys: pytest.CaptureFixture[str]) -> None: """Test error when setup_client finds no auth token/credentials.""" - monkeypatch.delenv("GOOGLE_HEALTH_CLI_TOKEN", raising=False) - monkeypatch.setattr( - "google_health_api.cli.auth.TOKEN_FILE", "non_existent_token.json" - ) - - with pytest.raises(SystemExit): + with ( + patch.dict( + "os.environ", + { + "GOOGLE_HEALTH_CLI_TOKEN_FILE": "non_existent_token.json", + }, + clear=True, + ), + pytest.raises(SystemExit), + ): run_cli(["userinfo"]) captured = capsys.readouterr() assert "Not logged in" in captured.out From f72283839bf166e9605ac7df27efbbd3e294a747 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jul 2026 07:31:06 -0700 Subject: [PATCH 24/24] refactor: Simplify test_login.py with a custom cmd_login test helper wrapper and use mock_load_credentials fixture --- tests/cli/subcommands/test_login.py | 62 +++++++++++++++-------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/tests/cli/subcommands/test_login.py b/tests/cli/subcommands/test_login.py index 5fc7c9d..81d3502 100644 --- a/tests/cli/subcommands/test_login.py +++ b/tests/cli/subcommands/test_login.py @@ -1,8 +1,9 @@ """Tests for login and auth flow CLI commands.""" import json +from collections.abc import Callable from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -15,6 +16,25 @@ from tests.cli.conftest import run_cli +def run_test_login( + args: MagicMock | None = None, + client_secret_file: str | None = None, + token_file: str | None = None, + is_tty: bool = True, + input_func: Callable[[str], str] | None = None, +) -> None: + """Helper wrapper to run cmd_login with default test settings.""" + if args is None: + args = MagicMock() + cmd_login( + args, + client_secret_file=client_secret_file, + token_file=token_file, + is_tty=is_tty, + input_func=input_func, + ) + + @pytest.mark.asyncio async def test_credentials_auth_refresh(tmp_path: Path) -> None: """Test CredentialsAuth refreshing expired credentials.""" @@ -78,10 +98,7 @@ def test_cmd_login_missing_client_secret( ) -> None: """Test login failure when client secret file is missing.""" with pytest.raises(SystemExit): - cmd_login( - MagicMock(), - client_secret_file=str(tmp_path / "nonexistent.json"), - ) + run_test_login(client_secret_file=str(tmp_path / "nonexistent.json")) captured = capsys.readouterr() assert "not found" in captured.out @@ -92,11 +109,7 @@ def test_cmd_login_not_tty(tmp_path: Path, capsys: pytest.CaptureFixture[str]) - secret_file.write_text(json.dumps({"web": {}})) with pytest.raises(SystemExit): - cmd_login( - MagicMock(), - client_secret_file=str(secret_file), - is_tty=False, - ) + run_test_login(client_secret_file=str(secret_file), is_tty=False) captured = capsys.readouterr() assert "headless environment" in captured.out @@ -125,20 +138,16 @@ def test_cmd_login_web_flow( # Empty response -> error with pytest.raises(SystemExit): - cmd_login( - MagicMock(), + run_test_login( client_secret_file=str(secret_file), token_file=str(token_file), - is_tty=True, input_func=lambda prompt="": "", ) # Valid auth code response - cmd_login( - MagicMock(), + run_test_login( client_secret_file=str(secret_file), token_file=str(token_file), - is_tty=True, input_func=lambda prompt="": "code=auth123", ) assert mock_flow.fetch_token.called @@ -160,28 +169,21 @@ def test_cmd_login_installed_app_flow( mock_creds.to_json.return_value = '{"token": "abc"}' mock_flow.run_local_server.return_value = mock_creds - cmd_login( - MagicMock(), + run_test_login( client_secret_file=str(secret_file), token_file=str(token_file), - is_tty=True, ) captured = capsys.readouterr() assert "Logged in successfully" in captured.out -def test_unauthenticated_cli_setup(capsys: pytest.CaptureFixture[str]) -> None: +def test_unauthenticated_cli_setup( + mock_load_credentials: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: """Test error when setup_client finds no auth token/credentials.""" - with ( - patch.dict( - "os.environ", - { - "GOOGLE_HEALTH_CLI_TOKEN_FILE": "non_existent_token.json", - }, - clear=True, - ), - pytest.raises(SystemExit), - ): + mock_load_credentials.return_value = None + + with pytest.raises(SystemExit): run_cli(["userinfo"]) captured = capsys.readouterr() assert "Not logged in" in captured.out