Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eccb9ca
Reapply "test: Improve test coverage for HTTP client error handling (…
allenporter Jul 26, 2026
396e14d
Reapply "test: Improve test coverage for CLI schema introspection (#4…
allenporter Jul 26, 2026
b9d22d8
test: Rename test_schema.py to test_cli_schema.py to mirror source la…
allenporter Jul 26, 2026
9ad74c9
test: Improve test coverage for pagination async iteration
allenporter Jul 21, 2026
679c4d3
test: Add tests/test_model_pagination.py for pagination coverage
allenporter Jul 26, 2026
3d3e8ad
test: Improve test coverage for CLI commands
allenporter Jul 21, 2026
9e3a8ee
test: Move test files into tests/cli and tests/model to mirror source…
allenporter Jul 26, 2026
7420359
test: Parameterize test_validate_resource_name in test_cli.py
allenporter Jul 26, 2026
fe0a3f8
test: Move local imports to top level in test_cli.py
allenporter Jul 26, 2026
7b7d41a
refactor: Expose public data_type property on DataPointSubApi and upd…
allenporter Jul 26, 2026
e56d828
test: Parameterize test_error_detail_variations in test_client.py
allenporter Jul 26, 2026
3f10b3d
refactor: Convert _error_detail to a module-level function
allenporter Jul 26, 2026
6ef1ac5
refactor: Expose error_detail as a public module-level function and r…
allenporter Jul 26, 2026
20c6035
refactor: Add __all__ export list to client.py
allenporter Jul 26, 2026
cb375da
Revert "refactor: Add __all__ export list to client.py"
allenporter Jul 26, 2026
9a6d1d9
refactor: Revert _error_detail back to private function to keep it ou…
allenporter Jul 26, 2026
0c121f0
Revert "refactor: Revert _error_detail back to private function to ke…
allenporter Jul 26, 2026
c4cdd3d
merge: Merge remote-tracking branch origin/main into improve-test-cod…
allenporter Jul 26, 2026
b559002
refactor: Use pathlib.Path instead of os in validation code and tests
allenporter Jul 26, 2026
1bb58a2
refactor: Split schema tests, add run_cli helper, combine withs, use …
allenporter Jul 26, 2026
865ebec
refactor: Move CLI schema command tests from test_cli.py to test_sche…
allenporter Jul 26, 2026
d1f2bfe
refactor: Split huge CLI commands and tests codebase into submodules
allenporter Jul 26, 2026
b4aaa02
Merge branch 'main' into improve-test-code-coverage
allenporter Jul 26, 2026
bb1f006
chore: Apply Ruff formatting and import fixes to CLI submodules and t…
allenporter Jul 26, 2026
e1dfca9
Merge remote-tracking branch 'origin/main' into improve-test-code-cov…
allenporter Jul 26, 2026
ffc434e
refactor: Eliminate pytest monkeypatch from CLI tests using dependenc…
allenporter Jul 26, 2026
f722838
refactor: Simplify test_login.py with a custom cmd_login test helper …
allenporter Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions google_health_api/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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")
Expand Down
28 changes: 19 additions & 9 deletions google_health_api/cli/subcommands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,37 @@
import json
import os
import sys
from collections.abc import Callable

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:
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
Expand All @@ -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,
)
Expand All @@ -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(
Expand All @@ -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."})
109 changes: 53 additions & 56 deletions tests/cli/subcommands/test_login.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for login and auth flow CLI commands."""

import json
from collections.abc import Callable
from pathlib import Path
from unittest.mock import MagicMock

Expand All @@ -15,21 +16,37 @@
from tests.cli.conftest import run_cli


@pytest.mark.asyncio
async def test_credentials_auth_refresh(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
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."""
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)
auth = CredentialsAuth(session_mock, creds_mock, token_file=str(token_file))

token = await auth.async_get_access_token()
assert token == "refreshed-token"
Expand All @@ -46,20 +63,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 = {
Expand All @@ -68,123 +80,108 @@ 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"

# 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())
run_test_login(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())
run_test_login(client_secret_file=str(secret_file), is_tty=False)
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)
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())
run_test_login(
client_secret_file=str(secret_file),
token_file=str(token_file),
input_func=lambda prompt="": "",
)

# Valid auth code response
monkeypatch.setattr("builtins.input", lambda prompt="": "code=auth123")
cmd_login(MagicMock())
run_test_login(
client_secret_file=str(secret_file),
token_file=str(token_file),
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
mock_creds = MagicMock()
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),
)
captured = capsys.readouterr()
assert "Logged in successfully" in captured.out


def test_unauthenticated_cli_setup(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
mock_load_credentials: MagicMock, 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"
)
mock_load_credentials.return_value = None

with pytest.raises(SystemExit):
run_cli(["userinfo"])
Expand Down