diff --git a/docs/aliases.md b/docs/aliases.md index 3bb6f7c..8399085 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -1,17 +1,40 @@ # Aliases -You can assign an alias to a Datasette database using the `dclient alias` command: +You can assign an alias to a Datasette instance using the `dclient alias add` command: - dclient alias add content https://datasette.io/content + dclient alias add latest https://latest.datasette.io You can list aliases with `dclient alias list`: $ dclient alias list - content = https://datasette.io/content + latest = https://latest.datasette.io -Once registered, you can pass an alias to commands such as `dclient query`: +Once registered, you can pass an alias to commands using the `-i` flag: - dclient query content "select * from news limit 1" + dclient query fixtures "select * from news limit 1" -i latest + +## Default instance + +Set a default instance so you don't need `-i` every time: + + dclient alias default latest + +Now commands will use `latest` automatically: + + dclient databases + dclient tables -d fixtures + +## Default database + +Set a default database for an alias: + + dclient alias default-db latest fixtures + +Now you can run bare SQL queries directly: + + dclient "select * from facetable limit 5" + +This uses the default instance and default database. ## dclient alias --help @@ -56,10 +81,6 @@ Usage: dclient alias list [OPTIONS] List aliases - Example usage: - - dclient aliases list - Options: --json Output raw JSON --help Show this message and exit. @@ -80,15 +101,11 @@ cog.out( ``` Usage: dclient alias add [OPTIONS] NAME URL - Add an alias + Add an alias for a Datasette instance Example usage: - dclient alias add content https://datasette.io/content - - Then: - - dclient query content 'select * from news limit 3' + dclient alias add prod https://myapp.datasette.cloud Options: --help Show this message and exit. @@ -113,10 +130,66 @@ Usage: dclient alias remove [OPTIONS] NAME Example usage: - dclient alias remove content + dclient alias remove prod Options: --help Show this message and exit. ``` + +## dclient alias default --help + + +``` +Usage: dclient alias default [OPTIONS] [NAME] + + Set or show the default instance + + Example usage: + + dclient alias default prod + dclient alias default + dclient alias default --clear + +Options: + --clear Clear default instance + --help Show this message and exit. + +``` + + +## dclient alias default-db --help + + +``` +Usage: dclient alias default-db [OPTIONS] ALIAS_NAME [DB] + + Set or show the default database for an alias + + Example usage: + + dclient alias default-db prod main + dclient alias default-db prod + dclient alias default-db prod --clear + +Options: + --clear Clear default database for this alias + --help Show this message and exit. + +``` + diff --git a/docs/authentication.md b/docs/authentication.md index caadba7..cd3961c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -4,35 +4,57 @@ `dclient` can handle API tokens for Datasette instances that require authentication. -You can pass an API token to `query` using `-t/--token` like this: +You can pass an API token to any command using `--token` like this: ```bash -dclient query https://latest.datasette.io/fixtures "select * from facetable" -t dstok_mytoken +dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest ``` -A more convenient way to handle this is to store tokens to be used with different URL prefixes. +A more convenient way to handle this is to store tokens for your aliases. ## Using stored tokens -To always use `dstok_mytoken` for any URL on the `https://latest.datasette.io/` instance you can run this: +To store a token for an alias: ```bash -dclient auth add https://latest.datasette.io/ +dclient auth add latest ``` Then paste in the token and hit enter when prompted to do so. -To list which URLs you have set tokens for, run the `auth list` command: +Tokens can also be stored for direct URLs: +```bash +dclient auth add https://latest.datasette.io +``` + +To list which aliases/URLs you have set tokens for, run the `auth list` command: ```bash dclient auth list ``` -To delete the token for a specific URL, run `auth remove`: +To delete the token for a specific alias or URL, run `auth remove`: ```bash -dclient auth remove https://latest.datasette.io/ +dclient auth remove latest ``` + +## Token resolution order + +When making a request, dclient resolves the token in this order: + +1. `--token` CLI flag (highest priority) +2. Token stored by alias name in `auth.json` +3. Token stored by URL prefix in `auth.json` +4. `DATASETTE_TOKEN` environment variable (lowest priority) + ## Testing a token -The `dclient actor` command can be used to test a token, retrieving the actor that the token represents. +The `dclient auth status` command can be used to verify authentication by calling `/-/actor.json`: ```bash -dclient actor https://latest.datasette.io/content +dclient auth status +dclient auth status -i prod +``` + +The `dclient actor` command also shows the actor: +```bash +dclient actor +dclient actor -i prod ``` The output looks like this: ```json @@ -68,6 +90,7 @@ Commands: add Add an authentication token for an alias or URL list List stored API tokens remove Remove the API token for an alias or URL + status Verify authentication by calling /-/actor.json ``` @@ -89,7 +112,8 @@ Usage: dclient auth add [OPTIONS] ALIAS_OR_URL Example usage: - dclient auth add https://datasette.io/content + dclient auth add prod + dclient auth add https://datasette.io Paste in the token when prompted. @@ -142,7 +166,7 @@ Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL Example usage: - dclient auth remove https://datasette.io/content + dclient auth remove prod Options: --help Show this message and exit. @@ -150,6 +174,34 @@ Options: ``` +## dclient auth status --help + + +``` +Usage: dclient auth status [OPTIONS] + + Verify authentication by calling /-/actor.json + + Example usage: + + dclient auth status + dclient auth status -i prod + +Options: + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + --help Show this message and exit. + +``` + + ## dclient actor --help ``` -Usage: dclient actor [OPTIONS] [URL_OR_ALIAS] +Usage: dclient actor [OPTIONS] Show the actor represented by an API token Example usage: - dclient actor https://latest.datasette.io/fixtures + dclient actor + dclient actor -i prod Options: - --token TEXT API token - --help Show this message and exit. + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + --help Show this message and exit. ``` - \ No newline at end of file + diff --git a/tests/test_alias_v2.py b/tests/test_alias_v2.py new file mode 100644 index 0000000..e93902d --- /dev/null +++ b/tests/test_alias_v2.py @@ -0,0 +1,111 @@ +"""Tests for v2 alias command: default, default-db subcommands.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + + +def test_alias_default_workflow(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + # Add an alias + result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + assert result.exit_code == 0 + + # No default yet + result = runner.invoke(cli, ["alias", "default"]) + assert result.exit_code == 0 + assert "No default instance set" in result.output + + # Set default + result = runner.invoke(cli, ["alias", "default", "prod"]) + assert result.exit_code == 0 + + # Show default + result = runner.invoke(cli, ["alias", "default"]) + assert result.exit_code == 0 + assert result.output.strip() == "prod" + + # List should show * marker + result = runner.invoke(cli, ["alias", "list"]) + assert result.exit_code == 0 + assert "* prod" in result.output + + # Clear default + result = runner.invoke(cli, ["alias", "default", "--clear"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["alias", "default"]) + assert "No default instance set" in result.output + + +def test_alias_default_unknown_alias(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + result = runner.invoke(cli, ["alias", "default", "nonexistent"]) + assert result.exit_code == 1 + assert "No such alias" in result.output + + +def test_alias_default_db_workflow(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + # Add an alias + result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + assert result.exit_code == 0 + + # No default database yet + result = runner.invoke(cli, ["alias", "default-db", "prod"]) + assert result.exit_code == 0 + assert "No default database set" in result.output + + # Set default database + result = runner.invoke(cli, ["alias", "default-db", "prod", "main"]) + assert result.exit_code == 0 + + # Show default database + result = runner.invoke(cli, ["alias", "default-db", "prod"]) + assert result.exit_code == 0 + assert result.output.strip() == "main" + + # List should show db info + result = runner.invoke(cli, ["alias", "list"]) + assert "(db: main)" in result.output + + # Clear default database + result = runner.invoke(cli, ["alias", "default-db", "prod", "--clear"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["alias", "default-db", "prod"]) + assert "No default database set" in result.output + + +def test_alias_default_db_unknown_alias(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + result = runner.invoke(cli, ["alias", "default-db", "nonexistent", "main"]) + assert result.exit_code == 1 + assert "No such alias" in result.output + + +def test_alias_remove_clears_default(mocker, tmpdir): + """Removing the default alias also clears default_instance.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + runner.invoke(cli, ["alias", "default", "prod"]) + + # Verify it's set + config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) + assert config["default_instance"] == "prod" + + # Remove alias + runner.invoke(cli, ["alias", "remove", "prod"]) + + config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) + assert config["default_instance"] is None + assert "prod" not in config["instances"] diff --git a/tests/test_commands_v2.py b/tests/test_commands_v2.py new file mode 100644 index 0000000..2077a45 --- /dev/null +++ b/tests/test_commands_v2.py @@ -0,0 +1,538 @@ +"""Tests for v2 commands: databases, tables, plugins, schema, default_query, upsert.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib +import pytest + + +# -- databases command -- + + +def test_databases_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "databases": [ + {"name": "main", "tables_count": 12}, + {"name": "extra", "tables_count": 3}, + ] + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["databases", "-i", "https://example.com", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + assert data[0]["name"] == "main" + + +def test_databases_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "databases": [ + {"name": "main", "tables_count": 12}, + {"name": "extra", "tables_count": 3}, + ] + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["databases", "-i", "https://example.com"]) + assert result.exit_code == 0 + assert "main\n" in result.output + assert "extra\n" in result.output + + +def test_databases_url(httpx_mock, mocker, tmpdir): + """databases command hits /.json on the instance.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"databases": [{"name": "db1"}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["databases", "-i", "https://example.com"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/.json" + + +# -- tables command -- + + +def test_tables_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "facetable", "count": 15, "hidden": False}, + {"name": "facet_cities", "count": 4, "hidden": False}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "fixtures", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + + +def test_tables_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "facetable", "count": 15, "hidden": False}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "fixtures"] + ) + assert result.exit_code == 0 + assert "facetable" in result.output + assert "15 rows" in result.output + + +def test_tables_url(httpx_mock, mocker, tmpdir): + """tables command hits /.json.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"tables": [], "views": []}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "fixtures"] + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/fixtures.json" + + +def test_tables_with_views(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [{"name": "t1", "count": 5, "hidden": False}], + "views": [{"name": "v1"}], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "db", "--views"] + ) + assert result.exit_code == 0 + assert "t1" in result.output + assert "v1" in result.output + + +def test_tables_views_only(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [{"name": "t1", "count": 5, "hidden": False}], + "views": [{"name": "v1"}], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "db", "--views-only"], + ) + assert result.exit_code == 0 + assert "t1" not in result.output + assert "v1" in result.output + + +def test_tables_hidden(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "visible", "count": 5, "hidden": False}, + {"name": "hidden_t", "count": 2, "hidden": True}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + # Without --hidden + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "db"] + ) + assert "visible" in result.output + assert "hidden_t" not in result.output + + # With --hidden + httpx_mock.add_response( + json={ + "tables": [ + {"name": "visible", "count": 5, "hidden": False}, + {"name": "hidden_t", "count": 2, "hidden": True}, + ], + "views": [], + }, + status_code=200, + ) + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "db", "--hidden"], + ) + assert "visible" in result.output + assert "hidden_t" in result.output + + +# -- plugins command -- + + +def test_plugins_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json=[ + {"name": "datasette-files", "version": "0.3.1"}, + {"name": "datasette-auth-tokens", "version": "0.4"}, + ], + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["plugins", "-i", "https://example.com", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + + +def test_plugins_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json=[ + {"name": "datasette-files", "version": "0.3.1"}, + {"name": "datasette-auth-tokens", "version": "0.4"}, + ], + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["plugins", "-i", "https://example.com"]) + assert result.exit_code == 0 + assert "datasette-files\n" in result.output + assert "datasette-auth-tokens\n" in result.output + + +def test_plugins_url(httpx_mock, mocker, tmpdir): + """plugins command hits /-/plugins.json.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json=[], status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["plugins", "-i", "https://example.com"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/-/plugins.json" + + +# -- schema command -- + + +def test_schema_all_tables(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + full_schema = ( + "CREATE TABLE users (id integer primary key, name text);\n" + "CREATE VIEW user_count AS SELECT count(*) FROM users;" + ) + httpx_mock.add_response( + json={"database": "main", "schema": full_schema}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["schema", "-i", "https://example.com", "-d", "main"] + ) + assert result.exit_code == 0 + assert "CREATE TABLE users" in result.output + assert "CREATE VIEW user_count" in result.output + request = httpx_mock.get_request() + assert request.url.path == "/main/-/schema.json" + + +def test_schema_specific_table(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "database": "main", + "table": "users", + "schema": "CREATE TABLE users (id integer primary key, name text)", + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["schema", "users", "-i", "https://example.com", "-d", "main"] + ) + assert result.exit_code == 0 + assert "CREATE TABLE users" in result.output + request = httpx_mock.get_request() + assert request.url.path == "/main/users/-/schema.json" + + +def test_schema_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + full_schema = "CREATE TABLE users (id integer primary key);" + httpx_mock.add_response( + json={"database": "main", "schema": full_schema}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["schema", "-i", "https://example.com", "-d", "main", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "schema" in data + + +# -- default_query (bare SQL shortcut) -- + + +def test_default_query_with_defaults(httpx_mock, mocker, tmpdir): + """Bare SQL uses default instance + default database.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 42}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["select count(*) from users"]) + assert result.exit_code == 0 + assert json.loads(result.output) == [{"count(*)": 42}] + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/main.json" + + +def test_default_query_with_database_override(httpx_mock, mocker, tmpdir): + """Bare SQL with -d flag overrides the database.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 100}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["select count(*) from events", "-d", "analytics"] + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/analytics.json" + + +def test_default_query_with_instance_override(httpx_mock, mocker, tmpdir): + """Bare SQL with -i flag overrides the instance.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + }, + "staging": { + "url": "https://staging.example.com", + "default_database": "main", + }, + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 5}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["select count(*) from users", "-i", "staging"] + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.host == "staging.example.com" + + +def test_default_query_no_database_error(mocker, tmpdir): + """Bare SQL without a default database gives an error.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": None, + } + }, + } + ) + ) + runner = CliRunner() + result = runner.invoke(cli, ["select 1"]) + assert result.exit_code == 1 + assert "No database specified" in result.output + + +# -- upsert command -- + + +def test_upsert_mocked(httpx_mock, tmpdir, mocker): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "ok": True, + } + ) + path = pathlib.Path(tmpdir) / "data.csv" + path.write_text("a,b,c\n1,2,3\n") + runner = CliRunner() + result = runner.invoke( + cli, + [ + "upsert", + "data", + "table1", + str(path), + "--csv", + "--token", + "x", + "-i", + "https://datasette.example.com", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.headers["authorization"] == "Bearer x" + # Should hit /-/upsert endpoint + assert "/table1/-/upsert" in str(request.url) + assert json.loads(request.read()) == {"rows": [{"a": 1, "b": 2, "c": 3}]} + + +# -- auth status command -- + + +def test_auth_status(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"actor": {"id": "root"}}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["auth", "status", "-i", "https://example.com", "--token", "tok"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["actor"]["id"] == "root" + request = httpx_mock.get_request() + assert request.url.path == "/-/actor.json" + + +# -- actor command -- + + +def test_actor_with_instance_flag(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"actor": {"id": "root"}}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["actor", "-i", "https://example.com", "--token", "tok"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["actor"]["id"] == "root" + request = httpx_mock.get_request() + assert request.url.path == "/-/actor.json" + assert request.headers["authorization"] == "Bearer tok" + + +# -- get command -- + + +def test_get_command(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"hello": "world"}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["get", "/-/plugins.json", "-i", "https://example.com"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == {"hello": "world"} + request = httpx_mock.get_request() + assert request.url.path == "/-/plugins.json"