diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 32354fa..d8c85ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,3 +11,17 @@ jobs: tests: uses: djachenko/repokit/.github/workflows/python-tests.yml@0.9 secrets: inherit + + style: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: No ternaries + run: python scripts/no_ternary.py src tests diff --git a/CLAUDE.md b/CLAUDE.md index 4b1b8c0..d0ddd37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,11 @@ goodoc/ ├── CLAUDE.md ├── install.sh # полная установка: pipx + workflow + shell.sh ├── pyproject.toml +├── docs/ +│ └── oauth-client.md # как получить credentials.json (с нуля и для существующего клиента) +├── scripts/ +│ └── no_ternary.py # AST-проверка на тернарники, job style в CI +├── tests/ # pytest: conftest с фикстурами, файл на модуль + test_integration ├── workflow/ # шаблон Automator Quick Action (копируется install.sh) │ └── Contents/ │ ├── document.wflow @@ -101,6 +106,8 @@ Scope: `https://www.googleapis.com/auth/drive.file` — доступ тольк Если Drive API не включён в Cloud Console проекте — включить в APIs & Services → Library. +Пошаговый гайд по созданию клиента — [docs/oauth-client.md](docs/oauth-client.md). Отдельно разобран случай существующего клиента: секрет повторно не скачивается, нужен Client secrets → Add secret. + --- ## Automator Quick Action diff --git a/README.md b/README.md index 02da94f..a440924 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,18 @@ pipx install git+https://github.com/djachenko/goodoc.git ## First run -goodoc talks to Google Drive through your own Google Cloud project. On the first run, a setup wizard starts automatically: +goodoc talks to Google Drive through your own Google Cloud project. + +Google gates Drive access behind an OAuth client, and shipping a single shared client for everybody requires app verification — a domain, a privacy policy, a review. Until that happens, each user creates their own client. It's a one-time step. + +On the first run, a setup wizard starts automatically: 1. **Google Cloud credentials** — opens the browser, walks you through creating an OAuth client, prompts for the downloaded JSON file + — step by step, including the case of a client that already exists: [docs/oauth-client.md](docs/oauth-client.md) 2. **Authorization** — opens the browser for Google sign-in, saves the token +Google shows an "unverified app" warning during authorization. It refers to the client you just created — proceed past it. + After that, every run is silent. There is also a shared client maintained by the author, with limited capacity. If you have an access key, skip the wizard entirely: diff --git a/docs/oauth-client.md b/docs/oauth-client.md new file mode 100644 index 0000000..566d063 --- /dev/null +++ b/docs/oauth-client.md @@ -0,0 +1,61 @@ +# Getting `credentials.json` + +goodoc needs an OAuth client of type **Desktop app** from a Google Cloud project you +control. The wizard asks for the JSON file Google gives you for that client. + +--- + +## From scratch + +1. [Create a project](https://console.cloud.google.com/projectcreate) — any name, no + organization needed +2. Enable the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) + → **Enable** +3. Fill in [Branding](https://console.cloud.google.com/auth/branding): + app name, your email as support and developer contact +4. Set [Audience](https://console.cloud.google.com/auth/audience) to **External**, + add your own account under **Test users** +5. Press **Publish app** on the same page — in testing mode Google expires the refresh + token after 7 days +6. Create the client in [Credentials](https://console.cloud.google.com/apis/credentials): + **+ Create Credentials → OAuth client ID**, application type **Desktop app** +7. Download the JSON from the dialog shown right after creation — it lands in + `~/Downloads/client_secret_*.json` + +Check the project picker in the top bar before each step — the console likes to switch +projects on you. + +**Publishing is not verification.** It needs no review, no approval, no domain, no privacy +policy — the switch takes effect immediately. Google only requires verification for +sensitive and restricted scopes; goodoc asks for `drive.file`, which is neither. It grants +access to files the app itself created, and nothing else in your Drive. + +--- + +## Existing client + +Google no longer lets you view or download the secret of a client after creation, so the +[Credentials](https://console.cloud.google.com/apis/credentials) list has no download +button — only ✏️ and 🗑. Add a second secret instead: the client ID stays the same, so +tokens already issued keep working. + +1. Open the client from [Clients](https://console.cloud.google.com/auth/clients) — + click its name; type must be **Desktop** +2. **Client secrets → Add secret** +3. Download the JSON from that dialog — the secret is shown once +4. Check that the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) + is enabled in this project +5. Check [Audience](https://console.cloud.google.com/auth/audience) — if it still says + Testing, press **Publish app** +6. Run goodoc; once it works, disable and delete the old secret in + [Clients](https://console.cloud.google.com/auth/clients) + +Creating a fresh Desktop client (steps 6–7 above) is equally fine — old tokens are then +invalid, clear them with `goodoc logout`. + +--- + +## Where it goes + +The wizard copies the file to `~/.config/goodoc/credentials.json`. The token it obtains +afterwards is stored next to it as `token.json`. diff --git a/pyproject.toml b/pyproject.toml index 8673605..fa2e3d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,9 @@ goodoc = "goodoc.main:app" [tool.mypy] ignore_missing_imports = true +[tool.ruff.lint.isort] +known-first-party = ["conftest", "goodoc"] + [tool.ruff.lint.per-file-ignores] # B008: typer.Argument/Option in defaults is the intended Typer pattern "src/goodoc/main.py" = ["B008"] diff --git a/scripts/no_ternary.py b/scripts/no_ternary.py new file mode 100755 index 0000000..4ef1039 --- /dev/null +++ b/scripts/no_ternary.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Fail the build on ternary conditional expressions.""" + +import ast +import sys +from pathlib import Path + +DEFAULT_ROOTS = ["src", "tests"] + + +def find_ternaries(path: Path) -> list[ast.IfExp]: + tree = ast.parse(path.read_text(), filename=str(path)) + + return [node for node in ast.walk(tree) if isinstance(node, ast.IfExp)] + + +def main() -> int: + roots = sys.argv[1:] + + if not roots: + roots = DEFAULT_ROOTS + + found = 0 + + for root in roots: + for path in sorted(Path(root).rglob("*.py")): + for node in find_ternaries(path): + print(f"{path}:{node.lineno}:{node.col_offset + 1}: ternary conditional expression") + + found += 1 + + if not found: + return 0 + + print(f"\n{found} ternary expression(s) found — use an if/else block instead.", file=sys.stderr) + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/goodoc/setup.py b/src/goodoc/setup.py index a3d9415..b645b08 100644 --- a/src/goodoc/setup.py +++ b/src/goodoc/setup.py @@ -53,35 +53,55 @@ def _acquire_credentials(self) -> None: typer.echo(" 4. Create OAuth credentials:") typer.echo(" Credentials → + Create Credentials → OAuth client ID") typer.echo(" Application type: Desktop app") - typer.echo(" 5. Download the JSON file") + typer.echo(" 5. Download the JSON file from the dialog shown right after creation") + typer.echo() + typer.echo("Client already exists? Its secret can no longer be downloaded —") + typer.echo("open the client and use Client secrets -> Add secret, then download that JSON.") + typer.echo("Details: https://github.com/djachenko/goodoc/blob/master/docs/oauth-client.md") typer.echo() typer.echo("Opening Google Cloud Console in browser...") webbrowser.open("https://console.cloud.google.com/apis/credentials") typer.echo() - downloads = Path.home() / "Downloads" - suggestions = sorted(downloads.glob("client_secret_*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + while True: + if suggestion := self._latest_download(): + hint = f" [{suggestion}]" + else: + hint = "" - hint = f" [{suggestions[0]}]" if suggestions else "" + raw = typer.prompt(f"Path to downloaded credentials JSON{hint}", default="", show_default=False).strip() - raw = typer.prompt(f"Path to downloaded credentials JSON{hint}").strip() + src: Path | None - if raw: - src = Path(raw).expanduser() - elif suggestions: - src = suggestions[0] - else: - typer.echo("No path provided.", err=True) + if raw: + src = Path(raw).expanduser() + else: + src = suggestion - raise typer.Exit(1) + if src is None: + continue - if not src.exists(): - typer.echo(f"File not found: {src}", err=True) + if not src.exists(): + typer.echo(f"File not found: {src}", err=True) - raise typer.Exit(1) + continue + + break self._config.goodoc_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(src, self._config.credentials_path) typer.echo(f"Saved to {self._config.credentials_path}") + + def _latest_download(self) -> Path | None: + downloads = sorted( + (Path.home() / "Downloads").glob("client_secret_*.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + if not downloads: + return None + + return downloads[0] diff --git a/tests/conftest.py b/tests/conftest.py index 98a7bf9..8c53478 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,64 +1,74 @@ +from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock import pytest +from pytest import MonkeyPatch from typer.testing import CliRunner +FileTree = dict[str, "FileTree | str | None"] +CreateFiles = Callable[[Path, FileTree], None] + @pytest.fixture -def create_files(): - def _create(root: Path, structure: dict): +def create_files() -> CreateFiles: + def _create(root: Path, structure: FileTree) -> None: for key, value in structure.items(): path = root / key if value is None: path.touch() + elif isinstance(value, str): + path.write_text(value) elif isinstance(value, dict): path.mkdir(parents=True, exist_ok=True) + _create(path, value) return _create @pytest.fixture -def runner(): +def runner() -> CliRunner: return CliRunner() @pytest.fixture -def mock_creds(): +def mock_creds() -> MagicMock: return MagicMock() @pytest.fixture -def docx_file(tmp_path, create_files): - create_files(tmp_path, {"doc.docx": None}) +def docx_file(tmp_path: Path, create_files: CreateFiles) -> Path: + create_files(tmp_path, { + "doc.docx": None, + }) return tmp_path / "doc.docx" @pytest.fixture -def mock_get_credentials(monkeypatch): +def mock_get_credentials(monkeypatch: MonkeyPatch) -> MagicMock: mock = MagicMock(return_value=MagicMock()) monkeypatch.setattr("goodoc.auth.Auth.get_credentials", mock) return mock @pytest.fixture -def mock_upload(monkeypatch): +def mock_upload(monkeypatch: MonkeyPatch) -> MagicMock: mock = MagicMock(return_value="https://docs.google.com/doc") monkeypatch.setattr("goodoc.drive.Drive.upload", mock) return mock @pytest.fixture -def mock_browser(monkeypatch): +def mock_browser(monkeypatch: MonkeyPatch) -> MagicMock: mock = MagicMock() monkeypatch.setattr("goodoc.app.webbrowser.open", mock) return mock @pytest.fixture -def mock_drive_build(monkeypatch): +def mock_drive_build(monkeypatch: MonkeyPatch) -> MagicMock: mock_service = MagicMock() mock_service.files.return_value.create.return_value.execute.return_value = { "webViewLink": "https://docs.google.com/doc" diff --git a/tests/test_app.py b/tests/test_app.py index d28a30d..c648ad9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ import pytest import typer +from conftest import CreateFiles from goodoc.app import App from goodoc.auth import Auth from goodoc.config import Config @@ -11,74 +12,106 @@ @pytest.fixture -def config(tmp_path): +def config(tmp_path: Path) -> Config: return Config(goodoc_dir=tmp_path, scopes=[]) @pytest.fixture -def mock_auth(): +def mock_auth() -> MagicMock: return MagicMock(spec=Auth) @pytest.fixture -def mock_drive(): +def mock_drive() -> MagicMock: mock = MagicMock(spec=Drive) mock.upload.return_value = "https://docs.google.com/doc" return mock @pytest.fixture -def app(config, mock_auth, mock_drive): +def app(config: Config, mock_auth: MagicMock, mock_drive: MagicMock) -> App: return App(config, mock_auth, mock_drive) class TestValidateFile: - def test_returns_none_on_valid_file(self, docx_file): + def test_returns_none_on_valid_file(self, docx_file: Path) -> None: assert App.validate(docx_file) is None - def test_returns_error_on_missing_file(self): + def test_returns_error_on_missing_file(self) -> None: assert App.validate(Path("missing.docx")) is not None - def test_returns_error_on_unsupported_format(self, tmp_path, create_files): - create_files(tmp_path, {"data.txt": None}) + def test_returns_error_on_unsupported_format( + self, + tmp_path: Path, + create_files: CreateFiles, + ) -> None: + create_files(tmp_path, { + "data.txt": None, + }) assert App.validate(tmp_path / "data.txt") is not None - def test_missing_takes_priority_over_format(self): + def test_missing_takes_priority_over_format(self) -> None: assert App.validate(Path("missing.txt")) is not None class TestUpload: - def test_uploads_file(self, app, mock_drive, docx_file): + def test_uploads_file(self, app: App, mock_drive: MagicMock, docx_file: Path) -> None: app.upload([docx_file], open_browser=False) mock_drive.upload.assert_called_once_with(docx_file) - def test_uploads_each_file(self, app, mock_drive, tmp_path, create_files): - create_files(tmp_path, {"a.docx": None, "b.docx": None}) + def test_uploads_each_file( + self, + app: App, + mock_drive: MagicMock, + tmp_path: Path, + create_files: CreateFiles, + ) -> None: + create_files(tmp_path, { + "a.docx": None, + "b.docx": None, + }) files = [tmp_path / "a.docx", tmp_path / "b.docx"] app.upload(files, open_browser=False) assert mock_drive.upload.call_count == 2 - def test_opens_browser(self, app, mock_drive, docx_file, mock_browser): + def test_opens_browser( + self, + app: App, + mock_drive: MagicMock, + docx_file: Path, + mock_browser: MagicMock, + ) -> None: app.upload([docx_file], open_browser=True) mock_browser.assert_called_once_with("https://docs.google.com/doc") - def test_skips_browser(self, app, mock_drive, docx_file, mock_browser): + def test_skips_browser( + self, + app: App, + mock_drive: MagicMock, + docx_file: Path, + mock_browser: MagicMock, + ) -> None: app.upload([docx_file], open_browser=False) mock_browser.assert_not_called() - def test_missing_file_exits(self, app, mock_drive): + def test_missing_file_exits(self, app: App, mock_drive: MagicMock) -> None: with pytest.raises(typer.Exit): app.upload([Path("missing.docx")], open_browser=False) mock_drive.upload.assert_not_called() - def test_validates_all_before_uploading(self, app, mock_drive, docx_file): + def test_validates_all_before_uploading( + self, + app: App, + mock_drive: MagicMock, + docx_file: Path, + ) -> None: with pytest.raises(typer.Exit): app.upload([docx_file, Path("missing.docx")], open_browser=False) @@ -86,17 +119,17 @@ def test_validates_all_before_uploading(self, app, mock_drive, docx_file): class TestLogin: - def test_no_key_calls_get_credentials(self, app, mock_auth): + def test_no_key_calls_get_credentials(self, app: App, mock_auth: MagicMock) -> None: app.login() mock_auth.get_credentials.assert_called_once() - def test_key_calls_login_shared(self, app, mock_auth): + def test_key_calls_login_shared(self, app: App, mock_auth: MagicMock) -> None: app.login(key="my-key") mock_auth.login_shared.assert_called_once_with("my-key") - def test_invalid_key_exits(self, app, mock_auth): + def test_invalid_key_exits(self, app: App, mock_auth: MagicMock) -> None: mock_auth.login_shared.side_effect = ValueError("Invalid access key.") with pytest.raises(typer.Exit): @@ -104,7 +137,7 @@ def test_invalid_key_exits(self, app, mock_auth): class TestLogout: - def test_removes_token(self, app, config): + def test_removes_token(self, app: App, config: Config) -> None: config.token_path.parent.mkdir(parents=True, exist_ok=True) config.token_path.write_text("{}") @@ -112,5 +145,5 @@ def test_removes_token(self, app, config): assert not config.token_path.exists() - def test_no_token_no_error(self, app): + def test_no_token_no_error(self, app: App) -> None: app.logout() diff --git a/tests/test_auth.py b/tests/test_auth.py index a9aa4cf..b07d962 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,5 @@ import hashlib +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -9,22 +10,22 @@ @pytest.fixture -def config(tmp_path): +def config(tmp_path: Path) -> Config: return Config(goodoc_dir=tmp_path, scopes=[]) @pytest.fixture -def mock_setup(): +def mock_setup() -> MagicMock: return MagicMock(spec=Setup) @pytest.fixture -def auth(config, mock_setup): +def auth(config: Config, mock_setup: MagicMock) -> Auth: return Auth(config, mock_setup) class TestGetCredentials: - def test_no_credentials_runs_wizard(self, auth, mock_setup): + def test_no_credentials_runs_wizard(self, auth: Auth, mock_setup: MagicMock) -> None: wizard_creds = MagicMock() wizard_creds.to_json.return_value = "{}" mock_setup.first_run_wizard.return_value = wizard_creds @@ -34,7 +35,7 @@ def test_no_credentials_runs_wizard(self, auth, mock_setup): mock_setup.first_run_wizard.assert_called_once() assert result is wizard_creds - def test_valid_token_loaded_from_file(self, auth, config): + def test_valid_token_loaded_from_file(self, auth: Auth, config: Config) -> None: config.credentials_path.write_text("{}") config.token_path.write_text("{}") @@ -51,7 +52,7 @@ def test_valid_token_loaded_from_file(self, auth, config): flow_factory.assert_not_called() assert config.token_path.read_text() == "{}" - def test_expired_token_refreshed_and_written(self, auth, config): + def test_expired_token_refreshed_and_written(self, auth: Auth, config: Config) -> None: config.credentials_path.write_text("{}") config.token_path.write_text("{}") @@ -70,7 +71,7 @@ def test_expired_token_refreshed_and_written(self, auth, config): assert result is creds assert config.token_path.read_text() == refreshed_token - def test_no_token_runs_flow_and_writes(self, auth, config): + def test_no_token_runs_flow_and_writes(self, auth: Auth, config: Config) -> None: config.credentials_path.write_text("{}") fresh_token = '{"token": "fresh"}' @@ -89,13 +90,18 @@ def test_no_token_runs_flow_and_writes(self, auth, config): class TestLoginShared: - def test_invalid_key_rejected(self, auth, mock_setup): + def test_invalid_key_rejected(self, auth: Auth, mock_setup: MagicMock) -> None: with pytest.raises(ValueError): auth.login_shared("wrong-key") mock_setup.authorize_shared.assert_not_called() - def test_valid_key_authorizes_and_writes(self, auth, config, mock_setup): + def test_valid_key_authorizes_and_writes( + self, + auth: Auth, + config: Config, + mock_setup: MagicMock, + ) -> None: shared_token = '{"token": "shared"}' creds = MagicMock() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..6479e68 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,25 @@ +import re + +from goodoc.client import ACCESS_KEY_HASH, CLIENT_ID, client_config + + +class TestClientConfig: + def test_uses_installed_app_section(self) -> None: + assert set(client_config("key")) == {"installed"} + + def test_carries_bundled_client_id(self) -> None: + assert client_config("key")["installed"]["client_id"] == CLIENT_ID + + def test_access_key_becomes_client_secret(self) -> None: + assert client_config("the-key")["installed"]["client_secret"] == "the-key" + + def test_points_at_google_endpoints(self) -> None: + installed = client_config("key")["installed"] + + assert installed["auth_uri"] == "https://accounts.google.com/o/oauth2/auth" + assert installed["token_uri"] == "https://oauth2.googleapis.com/token" + + +class TestAccessKeyHash: + def test_is_sha256_digest(self) -> None: + assert re.fullmatch(r"[0-9a-f]{64}", ACCESS_KEY_HASH) diff --git a/tests/test_config.py b/tests/test_config.py index 6b96d9b..9e78112 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,27 +1,29 @@ from pathlib import Path +from pytest import MonkeyPatch + from goodoc.config import Config class TestConfig: - def test_default_goodoc_dir(self, monkeypatch): + def test_default_goodoc_dir(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) config = Config.default() assert config.goodoc_dir == Path.home() / ".config" / "goodoc" - def test_xdg_config_home(self, monkeypatch, tmp_path): + def test_xdg_config_home(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) config = Config.default() assert config.goodoc_dir == tmp_path / "goodoc" - def test_credentials_path(self): + def test_credentials_path(self) -> None: config = Config(goodoc_dir=Path("/custom"), scopes=[]) assert config.credentials_path == Path("/custom/credentials.json") - def test_token_path(self): + def test_token_path(self) -> None: config = Config(goodoc_dir=Path("/custom"), scopes=[]) assert config.token_path == Path("/custom/token.json") diff --git a/tests/test_drive.py b/tests/test_drive.py index 6f64890..c9757fd 100644 --- a/tests/test_drive.py +++ b/tests/test_drive.py @@ -1,13 +1,15 @@ +from pathlib import Path from unittest.mock import MagicMock import pytest import typer +from conftest import CreateFiles from goodoc.drive import MIME_MAP, Drive @pytest.fixture -def drive(mock_creds): +def drive(mock_creds: MagicMock) -> Drive: mock_auth = MagicMock() mock_auth.get_credentials.return_value = mock_creds return Drive(mock_auth) @@ -20,7 +22,7 @@ class TestMimeMap: (".pptx", "application/vnd.google-apps.presentation"), (".pptm", "application/vnd.google-apps.presentation"), ]) - def test_extensions_map_to_google_formats(self, extension, expected_target): + def test_extensions_map_to_google_formats(self, extension: str, expected_target: str) -> None: _, target_mime = MIME_MAP[extension] assert target_mime == expected_target @@ -28,8 +30,15 @@ def test_extensions_map_to_google_formats(self, extension, expected_target): @pytest.mark.usefixtures("mock_drive_build") class TestUpload: - def test_unsupported_extension_exits(self, drive, tmp_path, create_files): - create_files(tmp_path, {"doc.pdf": None}) + def test_unsupported_extension_exits( + self, + drive: Drive, + tmp_path: Path, + create_files: CreateFiles, + ) -> None: + create_files(tmp_path, { + "doc.pdf": None, + }) with pytest.raises(typer.Exit) as exc_info: drive.upload(tmp_path / "doc.pdf") @@ -37,18 +46,39 @@ def test_unsupported_extension_exits(self, drive, tmp_path, create_files): assert exc_info.value.exit_code == 1 @pytest.mark.parametrize("extension", MIME_MAP.keys()) - def test_supported_extension_returns_url(self, drive, extension, tmp_path, create_files): - create_files(tmp_path, {f"doc{extension}": None}) + def test_supported_extension_returns_url( + self, + drive: Drive, + extension: str, + tmp_path: Path, + create_files: CreateFiles, + ) -> None: + create_files(tmp_path, { + f"doc{extension}": None, + }) assert drive.upload(tmp_path / f"doc{extension}") == "https://docs.google.com/doc" @pytest.mark.parametrize("filename", ["DOC.DOCX", "Doc.Docx", "sheet.XLSX"]) - def test_uppercase_extension_accepted(self, drive, filename, tmp_path, create_files): - create_files(tmp_path, {filename: None}) + def test_uppercase_extension_accepted( + self, + drive: Drive, + filename: str, + tmp_path: Path, + create_files: CreateFiles, + ) -> None: + create_files(tmp_path, { + filename: None, + }) assert drive.upload(tmp_path / filename) == "https://docs.google.com/doc" - def test_creates_with_stem_name_and_target_mime(self, drive, docx_file, mock_drive_build): + def test_creates_with_stem_name_and_target_mime( + self, + drive: Drive, + docx_file: Path, + mock_drive_build: MagicMock, + ) -> None: drive.upload(docx_file) _, kwargs = mock_drive_build.files.return_value.create.call_args diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..5bd4a85 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,142 @@ +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from pytest import MonkeyPatch +from typer.testing import CliRunner + +from conftest import CreateFiles +from goodoc.app import App +from goodoc.auth import Auth +from goodoc.drive import Drive +from goodoc.main import _create_app, app +from goodoc.setup import Setup + + +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + return tmp_path / "goodoc" + + +@pytest.fixture +def goodoc(config_dir: Path, monkeypatch: MonkeyPatch) -> App: + instance = _create_app() + monkeypatch.setattr("goodoc.main._app", instance) + + return instance + + +@pytest.fixture +def authorized(tmp_path: Path, config_dir: Path, create_files: CreateFiles) -> Iterator[None]: + create_files(tmp_path, { + "goodoc": { + "credentials.json": "{}", + "token.json": "{}", + }, + }) + + with patch( + "goodoc.auth.Credentials.from_authorized_user_file", + return_value=MagicMock(valid=True), + ): + yield + + +class TestWiring: + def test_builds_full_dependency_graph(self, goodoc: App) -> None: + assert isinstance(goodoc, App) + assert isinstance(goodoc._drive, Drive) + assert isinstance(goodoc._drive._auth, Auth) + assert isinstance(goodoc._drive._auth._setup, Setup) + + def test_shares_single_config(self, goodoc: App) -> None: + assert goodoc._config is goodoc._auth._config + assert goodoc._config is goodoc._drive._auth._config + + def test_config_points_at_xdg_dir(self, goodoc: App, config_dir: Path) -> None: + assert goodoc._config.goodoc_dir == config_dir + + +@pytest.mark.usefixtures("goodoc", "authorized", "mock_drive_build") +class TestUploadEndToEnd: + def test_uploads_and_opens_browser( + self, + runner: CliRunner, + docx_file: Path, + mock_drive_build: MagicMock, + mock_browser: MagicMock, + ) -> None: + result = runner.invoke(app, [str(docx_file)]) + + _, kwargs = mock_drive_build.files.return_value.create.call_args + + assert result.exit_code == 0 + assert kwargs["body"]["mimeType"] == "application/vnd.google-apps.document" + assert "https://docs.google.com/doc" in result.stdout + mock_browser.assert_called_once_with("https://docs.google.com/doc") + + def test_no_open_flag_skips_browser( + self, + runner: CliRunner, + docx_file: Path, + mock_browser: MagicMock, + ) -> None: + result = runner.invoke(app, [str(docx_file), "--no-open"]) + + assert result.exit_code == 0 + mock_browser.assert_not_called() + + def test_unsupported_format_stops_before_upload( + self, + runner: CliRunner, + tmp_path: Path, + create_files: CreateFiles, + mock_drive_build: MagicMock, + ) -> None: + create_files(tmp_path, { + "notes.txt": None, + }) + + result = runner.invoke(app, [str(tmp_path / "notes.txt")]) + + assert result.exit_code == 1 + mock_drive_build.files.return_value.create.assert_not_called() + + +class TestAuthEndToEnd: + def test_login_without_credentials_runs_wizard( + self, + runner: CliRunner, + goodoc: App, + config_dir: Path, + ) -> None: + creds = MagicMock() + creds.to_json.return_value = '{"token": "wizard"}' + + with patch.object(Setup, "first_run_wizard", return_value=creds) as wizard: + result = runner.invoke(app, ["login"]) + + wizard.assert_called_once() + assert result.exit_code == 0 + assert (config_dir / "token.json").read_text() == '{"token": "wizard"}' + + def test_logout_removes_stored_token( + self, + runner: CliRunner, + goodoc: App, + config_dir: Path, + authorized: None, + ) -> None: + result = runner.invoke(app, ["logout"]) + + assert result.exit_code == 0 + assert not (config_dir / "token.json").exists() + + def test_logout_without_token_reports_state(self, runner: CliRunner, goodoc: App) -> None: + result = runner.invoke(app, ["logout"]) + + assert result.exit_code == 0 + assert "Not logged in." in result.stdout diff --git a/tests/test_main.py b/tests/test_main.py index d3e12c1..eed5272 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,45 +1,58 @@ +from pathlib import Path from unittest.mock import MagicMock import pytest +from pytest import MonkeyPatch +from typer.testing import CliRunner from goodoc.app import App from goodoc.main import app @pytest.fixture -def mock_app(monkeypatch): +def mock_app(monkeypatch: MonkeyPatch) -> MagicMock: mock = MagicMock(spec=App) monkeypatch.setattr("goodoc.main._app", mock) return mock class TestCLI: - def test_upload_is_default_command(self, runner, mock_app, docx_file): + def test_upload_is_default_command( + self, + runner: CliRunner, + mock_app: MagicMock, + docx_file: Path, + ) -> None: runner.invoke(app, [str(docx_file)]) mock_app.upload.assert_called_once() - def test_open_browser_by_default(self, runner, mock_app, docx_file): + def test_open_browser_by_default( + self, + runner: CliRunner, + mock_app: MagicMock, + docx_file: Path, + ) -> None: runner.invoke(app, [str(docx_file)]) mock_app.upload.assert_called_once_with([docx_file], open_browser=True) - def test_no_open_flag(self, runner, mock_app, docx_file): + def test_no_open_flag(self, runner: CliRunner, mock_app: MagicMock, docx_file: Path) -> None: runner.invoke(app, [str(docx_file), "--no-open"]) mock_app.upload.assert_called_once_with([docx_file], open_browser=False) - def test_login_command(self, runner, mock_app): + def test_login_command(self, runner: CliRunner, mock_app: MagicMock) -> None: runner.invoke(app, ["login"]) mock_app.login.assert_called_once_with(None) - def test_login_with_key(self, runner, mock_app): + def test_login_with_key(self, runner: CliRunner, mock_app: MagicMock) -> None: runner.invoke(app, ["login", "--key", "abc"]) mock_app.login.assert_called_once_with("abc") - def test_logout_command(self, runner, mock_app): + def test_logout_command(self, runner: CliRunner, mock_app: MagicMock) -> None: runner.invoke(app, ["logout"]) mock_app.logout.assert_called_once() diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..9412118 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,208 @@ +import os +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from pytest import MonkeyPatch + +from conftest import CreateFiles +from goodoc.config import Config +from goodoc.setup import Setup + + +@pytest.fixture +def config(tmp_path: Path) -> Config: + return Config(goodoc_dir=tmp_path / "goodoc", scopes=["scope"]) + + +@pytest.fixture +def setup(config: Config) -> Setup: + return Setup(config) + + +@pytest.fixture +def source_credentials(tmp_path: Path, create_files: CreateFiles) -> Path: + create_files(tmp_path, { + "downloaded.json": '{"installed": {}}', + }) + + return tmp_path / "downloaded.json" + + +@pytest.fixture +def downloads(tmp_path: Path, monkeypatch: MonkeyPatch, create_files: CreateFiles) -> Path: + create_files(tmp_path, { + "home": { + "Downloads": {}, + }, + }) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + + return tmp_path / "home" / "Downloads" + + +@pytest.fixture +def mock_prompt() -> Iterator[MagicMock]: + with patch("goodoc.setup.typer.prompt") as prompt: + yield prompt + + +@pytest.fixture(autouse=True) +def mock_setup_browser() -> Iterator[MagicMock]: + with patch("goodoc.setup.webbrowser.open") as browser: + yield browser + + +class TestAuthorizeShared: + def test_builds_flow_from_shared_client(self, setup: Setup, config: Config) -> None: + creds = MagicMock() + flow = MagicMock() + flow.run_local_server.return_value = creds + + with patch( + "goodoc.setup.InstalledAppFlow.from_client_config", + return_value=flow, + ) as flow_factory: + result = setup.authorize_shared("the-key") + + client_config, scopes = flow_factory.call_args.args + + assert client_config["installed"]["client_secret"] == "the-key" + assert scopes == config.scopes + assert result is creds + + +class TestFirstRunWizard: + def test_non_tty_exits(self, setup: Setup) -> None: + with ( + patch("goodoc.setup.sys.stdin.isatty", return_value=False), + pytest.raises(typer.Exit) as exc_info, + ): + setup.first_run_wizard() + + assert exc_info.value.exit_code == 1 + + def test_acquires_credentials_then_runs_flow(self, setup: Setup, config: Config) -> None: + creds = MagicMock() + flow = MagicMock() + flow.run_local_server.return_value = creds + + with ( + patch("goodoc.setup.sys.stdin.isatty", return_value=True), + patch.object(Setup, "_acquire_credentials") as acquire, + patch( + "goodoc.setup.InstalledAppFlow.from_client_secrets_file", + return_value=flow, + ) as flow_factory, + ): + result = setup.first_run_wizard() + + acquire.assert_called_once() + flow_factory.assert_called_once_with(str(config.credentials_path), config.scopes) + assert result is creds + + +class TestAcquireCredentials: + def test_copies_entered_file_to_config( + self, + setup: Setup, + config: Config, + mock_prompt: MagicMock, + source_credentials: Path, + ) -> None: + mock_prompt.return_value = str(source_credentials) + + setup._acquire_credentials() + + assert config.credentials_path.read_text() == '{"installed": {}}' + + def test_opens_console_in_browser( + self, + setup: Setup, + mock_prompt: MagicMock, + mock_setup_browser: MagicMock, + source_credentials: Path, + ) -> None: + mock_prompt.return_value = str(source_credentials) + + setup._acquire_credentials() + + mock_setup_browser.assert_called_once() + + def test_missing_file_reprompts( + self, + setup: Setup, + config: Config, + mock_prompt: MagicMock, + tmp_path: Path, + source_credentials: Path, + ) -> None: + mock_prompt.side_effect = [str(tmp_path / "nope.json"), str(source_credentials)] + + setup._acquire_credentials() + + assert mock_prompt.call_count == 2 + assert config.credentials_path.exists() + + def test_empty_input_takes_suggestion( + self, + setup: Setup, + config: Config, + mock_prompt: MagicMock, + downloads: Path, + create_files: CreateFiles, + ) -> None: + create_files(downloads, { + "client_secret_1.json": '{"suggested": true}', + }) + mock_prompt.return_value = "" + + setup._acquire_credentials() + + assert config.credentials_path.read_text() == '{"suggested": true}' + + def test_empty_input_without_suggestion_reprompts( + self, + setup: Setup, + mock_prompt: MagicMock, + downloads: Path, + source_credentials: Path, + ) -> None: + mock_prompt.side_effect = ["", str(source_credentials)] + + setup._acquire_credentials() + + assert mock_prompt.call_count == 2 + + +class TestLatestDownload: + def test_returns_none_when_nothing_downloaded(self, setup: Setup, downloads: Path) -> None: + assert setup._latest_download() is None + + def test_ignores_unrelated_files( + self, + setup: Setup, + downloads: Path, + create_files: CreateFiles, + ) -> None: + create_files(downloads, { + "report.json": None, + }) + + assert setup._latest_download() is None + + def test_returns_most_recent( + self, + setup: Setup, + downloads: Path, + create_files: CreateFiles, + ) -> None: + create_files(downloads, { + "client_secret_old.json": None, + "client_secret_new.json": None, + }) + os.utime(downloads / "client_secret_old.json", (1, 1)) + + assert setup._latest_download() == downloads / "client_secret_new.json"