diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..f9776fd --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,93 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + # Parity check runs on every PR and push: confirms every scenario in + # www.hotdata.dev/api/test-scenarios.yaml that is NOT opted out for the CLI + # has a matching test file here. www.hotdata.dev is private, so we fetch the + # manifest via the GitHub App token. hotdata-cli convention: tests/.rs. + # Scenarios listing `cli` in optional_for are skipped (the CLI's surface + # doesn't cover them, e.g. datasets/secrets/saved-queries). + scenario-parity: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.HOTDATA_AUTOMATION_APP_ID }} + private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }} + owner: hotdata-dev + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + - name: Install PyYAML + run: pip install --quiet pyyaml + - name: Fetch scenarios manifest + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + curl -sS -f -L \ + -H "Accept: application/vnd.github.v3.raw" \ + -H "Authorization: Bearer $GH_TOKEN" \ + https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \ + -o test-scenarios.yaml + - name: Check parity + run: | + python3 - <<'PY' + import sys, pathlib, yaml + scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"] + missing = [] + required = 0 + for s in scenarios: + if "cli" in (s.get("optional_for") or []): + continue + required += 1 + expected = pathlib.Path("tests") / f"{s['name']}.rs" + if not expected.exists(): + missing.append(str(expected)) + if missing: + print(f"::error::hotdata-cli is missing tests for {len(missing)} scenarios:") + for m in missing: + print(f" - {m}") + sys.exit(1) + print(f"All {required} required scenarios have corresponding test files (of {len(scenarios)} total).") + PY + rm -f test-scenarios.yaml + + # Integration tests run against production. The shared harness + # (tests/common/mod.rs) skips cleanly when HOTDATA_SDK_TEST_API_KEY / + # HOTDATA_SDK_TEST_WORKSPACE_ID are absent (e.g. PRs from forks where secrets + # aren't injected), so this job stays green without credentials. + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - name: Cache cargo + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Run integration tests + env: + HOTDATA_SDK_TEST_API_URL: ${{ vars.HOTDATA_SDK_TEST_API_URL }} + HOTDATA_SDK_TEST_API_KEY: ${{ secrets.HOTDATA_SDK_TEST_API_KEY }} + HOTDATA_SDK_TEST_WORKSPACE_ID: ${{ vars.HOTDATA_SDK_TEST_WORKSPACE_ID }} + HOTDATA_SDK_TEST_CONNECTION_ID: ${{ vars.HOTDATA_SDK_TEST_CONNECTION_ID }} + # --no-fail-fast runs every scenario binary even after one fails, so a + # red run surfaces all failing scenarios at once. + run: cargo test --test '*' --no-fail-fast -- --nocapture diff --git a/tests/auth_missing_token_401.rs b/tests/auth_missing_token_401.rs new file mode 100644 index 0000000..7d8082e --- /dev/null +++ b/tests/auth_missing_token_401.rs @@ -0,0 +1,48 @@ +//! Scenario: auth_missing_token_401. +//! +//! A request with no credentials must be denied. The SDKs assert a literal 401 +//! from the server because they can construct an unauthenticated client; the +//! CLI instead refuses *client-side* (it has no session, no api key, and +//! nothing to mint a JWT from), so the meaningful CLI equivalent is: an +//! authenticated command run with no credentials exits non-zero, reports an +//! auth/not-configured error, and never prints a workspace listing. +//! +//! Although this scenario sends no credentials, it still gates on the standard +//! test env (like sdk-python's `env` fixture) so `cargo test` with no secrets +//! configured does not run a live, misleading path. + +mod common; + +#[test] +fn auth_missing_token_401() { + // Gate on creds so offline CI skips cleanly (mirrors the SDK env fixture). + let _cli = skip_if_no_creds!(); + let env = common::load_env(); + + // No api key, no session (isolated empty config), no workspace lock. + let output = + common::unauthenticated_output(&env.api_url, &["workspaces", "list", "-o", "json"]); + + assert!( + !output.status.success(), + "workspaces list without credentials must fail; stdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); + assert!( + stderr.contains("auth") || stderr.contains("log in") || stderr.contains("not configured"), + "expected an auth/not-configured error on stderr, got:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + // Defensive: must not have leaked a successful JSON listing on stdout. + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + serde_json::from_str::(stdout.trim()) + .ok() + .and_then(|v| v.as_array().map(|a| !a.is_empty())) + != Some(true), + "unauthenticated call leaked a workspace listing:\n{stdout}" + ); +} diff --git a/tests/auth_unknown_workspace.rs b/tests/auth_unknown_workspace.rs new file mode 100644 index 0000000..15cb414 --- /dev/null +++ b/tests/auth_unknown_workspace.rs @@ -0,0 +1,44 @@ +//! Scenario: auth_unknown_workspace. +//! +//! A valid api key combined with a fabricated workspace id must be rejected and +//! must never leak data from another workspace. The CLI mints a JWT from the +//! real api key, then sends the fabricated id as the gateway-enforced +//! `X-Workspace-Id`; the server responds 4xx (403/404). We assert the command +//! exits non-zero and never prints a successful listing. + +mod common; + +#[test] +fn auth_unknown_workspace() { + let cli = skip_if_no_creds!(); + + let fake_workspace = format!( + "ws_{:08x}{:08x}", + rand::random::(), + rand::random::() + ); + + // Real api key (no HOTDATA_WORKSPACE lock) + fabricated workspace via -w. + let output = cli + .cmd_unlocked_workspace() + .args(["connections", "list", "-w", &fake_workspace, "-o", "json"]) + .output() + .expect("failed to spawn hotdata binary"); + + assert!( + !output.status.success(), + "connections list with fabricated workspace {fake_workspace} must fail \ + (potential cross-workspace leak); stdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + + // Defensive: must not have leaked a successful JSON listing on stdout. + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + serde_json::from_str::(stdout.trim()) + .ok() + .and_then(|v| v.as_array().map(|a| !a.is_empty())) + != Some(true), + "fabricated workspace {fake_workspace} leaked a connection listing:\n{stdout}" + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..097a803 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,296 @@ +//! Shared support for the CLI's env-gated integration tests. +//! +//! These tests drive the compiled `hotdata` binary (via `CARGO_BIN_EXE_hotdata`) +//! against production, mirroring the scenario contract in +//! www.hotdata.dev/api/test-scenarios.yaml. The harness centralizes the +//! env-driven gating the SDKs express elsewhere (sdk-python's conftest fixtures, +//! sdk-rust's `tests/common/mod.rs`). +//! +//! Every test reads the same `HOTDATA_SDK_TEST_*` env vars and SKIPS cleanly +//! (early-returns with a notice on stderr) when they are unset, so `cargo test` +//! passes offline / in CI without secrets configured. +//! +//! The shared env vars are translated into the CLI's own configuration surface: +//! HOTDATA_SDK_TEST_API_KEY -> HOTDATA_API_KEY (CLI mints a JWT from it) +//! HOTDATA_SDK_TEST_API_URL -> HOTDATA_API_URL +//! HOTDATA_SDK_TEST_WORKSPACE_ID -> HOTDATA_WORKSPACE +//! plus an isolated `HOTDATA_CONFIG_DIR` (a tempdir) so the test never reads or +//! writes the developer's real `~/.hotdata` config. + +#![allow(dead_code)] + +use std::process::{Command, Output}; + +use tempfile::TempDir; + +/// Default API host (matches the SDK harnesses and the `--api-url` default). +pub const DEFAULT_API_URL: &str = "https://api.hotdata.dev"; + +/// Name of the shared database that query-scoped scenarios target. Databases +/// persist (no auto-expiry), so — mirroring sdk-python's conftest — we reuse one +/// stable database keyed by name across runs rather than creating one per test. +pub const SHARED_DATABASE_NAME: &str = "sdkci-shared"; + +/// SQL catalog alias for [`SHARED_DATABASE_NAME`]. Must match `[a-z_][a-z0-9_]*` +/// and be globally unique; find-or-create keys on the name, so re-runs reuse it. +pub const SHARED_DATABASE_CATALOG: &str = "sdkci_shared"; + +/// Resolved test environment. Mirrors sdk-rust's `TestEnv`. +/// +/// GitHub Actions sets `env:` keys even when the underlying secret/var is unset, +/// producing empty strings rather than absent keys. We treat empty strings as +/// absent (see [`load_env`]). +#[derive(Clone, Debug)] +pub struct TestEnv { + pub api_key: Option, + pub workspace_id: Option, + pub api_url: String, + pub connection_id: Option, +} + +impl TestEnv { + /// True when both required credentials (api key + workspace id) are present. + pub fn has_creds(&self) -> bool { + self.api_key.is_some() && self.workspace_id.is_some() + } +} + +fn non_empty(name: &str) -> Option { + std::env::var(name).ok().filter(|s| !s.is_empty()) +} + +/// Read the test environment. Empty strings are treated as absent; `api_url` +/// falls back to [`DEFAULT_API_URL`]. +pub fn load_env() -> TestEnv { + TestEnv { + api_key: non_empty("HOTDATA_SDK_TEST_API_KEY"), + workspace_id: non_empty("HOTDATA_SDK_TEST_WORKSPACE_ID"), + api_url: non_empty("HOTDATA_SDK_TEST_API_URL") + .unwrap_or_else(|| DEFAULT_API_URL.to_string()), + connection_id: non_empty("HOTDATA_SDK_TEST_CONNECTION_ID"), + } +} + +/// `sdkci--<8 hex>` so any orphaned resources are identifiable and can +/// be swept. See www.hotdata.dev/api/README.md — every test-created resource +/// must use this prefix. +pub fn sdkci_name(scenario: &str) -> String { + let id: u32 = rand::random(); + format!("sdkci-{scenario}-{id:08x}") +} + +/// A configured CLI under test: resolved credentials plus an isolated config +/// directory. Owns a [`TempDir`] so config stays out of `~/.hotdata` for the +/// lifetime of the test. +pub struct Cli { + pub env: TestEnv, + config_dir: TempDir, +} + +impl Cli { + fn new(env: TestEnv) -> Self { + let config_dir = tempfile::tempdir().expect("create temp config dir"); + Cli { env, config_dir } + } + + /// The seeded workspace id (creds are guaranteed present by the skip macros). + pub fn workspace_id(&self) -> &str { + self.env + .workspace_id + .as_deref() + .expect("creds checked by skip macro") + } + + /// Base command: the binary, an isolated config dir, the test API URL, and a + /// cleared environment so an ambient sandbox/database token can't leak in. + /// Does NOT set credentials or a workspace. + fn base(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hotdata")); + cmd.env("HOTDATA_CONFIG_DIR", self.config_dir.path()) + .env("HOTDATA_API_URL", &self.env.api_url) + .env_remove("HOTDATA_API_KEY") + .env_remove("HOTDATA_WORKSPACE") + .env_remove("HOTDATA_SANDBOX") + .env_remove("HOTDATA_SANDBOX_TOKEN") + .env_remove("HOTDATA_DATABASE") + .env_remove("HOTDATA_DATABASE_TOKEN") + .arg("--no-input"); + cmd + } + + /// Authenticated command locked to the seeded workspace. `HOTDATA_WORKSPACE` + /// is set, so commands resolve it automatically and any conflicting `-w` + /// flag is rejected by the CLI. + pub fn cmd(&self) -> Command { + let mut cmd = self.base(); + cmd.env( + "HOTDATA_API_KEY", + self.env.api_key.as_deref().expect("creds checked"), + ) + .env("HOTDATA_WORKSPACE", self.workspace_id()); + cmd + } + + /// Authenticated, but with no `HOTDATA_WORKSPACE` lock so a `-w ` flag + /// takes effect (used by `auth_unknown_workspace`). + pub fn cmd_unlocked_workspace(&self) -> Command { + let mut cmd = self.base(); + cmd.env( + "HOTDATA_API_KEY", + self.env.api_key.as_deref().expect("creds checked"), + ); + cmd + } + + /// Run `hotdata ` (authenticated, workspace-locked) and return raw output. + pub fn run(&self, args: &[&str]) -> Output { + self.cmd() + .args(args) + .output() + .expect("failed to spawn hotdata binary") + } + + /// Run `hotdata -o json`, assert success, and parse stdout as JSON. + pub fn json(&self, args: &[&str]) -> serde_json::Value { + let output = self.run(args); + assert_success(&output, args); + parse_json(&output.stdout, args) + } +} + +/// Build an [`Output`] runner for a command that should NOT carry credentials — +/// isolated config + API URL only. Used by `auth_missing_token_401`. +pub fn unauthenticated_output(api_url: &str, args: &[&str]) -> Output { + let dir = tempfile::tempdir().expect("create temp config dir"); + Command::new(env!("CARGO_BIN_EXE_hotdata")) + .env("HOTDATA_CONFIG_DIR", dir.path()) + .env("HOTDATA_API_URL", api_url) + .env_remove("HOTDATA_API_KEY") + .env_remove("HOTDATA_WORKSPACE") + .env_remove("HOTDATA_SANDBOX") + .env_remove("HOTDATA_SANDBOX_TOKEN") + .env_remove("HOTDATA_DATABASE") + .env_remove("HOTDATA_DATABASE_TOKEN") + .arg("--no-input") + .args(args) + .output() + .expect("failed to spawn hotdata binary") +} + +/// Assert a command succeeded, surfacing stdout+stderr on failure. +pub fn assert_success(output: &Output, args: &[&str]) { + assert!( + output.status.success(), + "`hotdata {}` failed ({})\n--- stdout ---\n{}\n--- stderr ---\n{}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Parse bytes as JSON, panicking with context (the args + raw output) on failure. +pub fn parse_json(bytes: &[u8], args: &[&str]) -> serde_json::Value { + serde_json::from_slice(bytes).unwrap_or_else(|e| { + panic!( + "`hotdata {}` stdout was not valid JSON ({e}):\n{}", + args.join(" "), + String::from_utf8_lossy(bytes) + ) + }) +} + +/// Find-or-create the shared `sdkci-shared` managed database and return its id. +/// +/// Queries require a database scope (the `-d` flag / `X-Database-Id` header); +/// a bare query returns 400 "a database is required". Mirroring sdk-python's +/// conftest, we reuse one stable database keyed by name rather than creating and +/// deleting one per test (which would leak on failure). +pub fn shared_database_id(cli: &Cli) -> String { + let listing = cli.json(&["databases", "list", "-o", "json"]); + if let Some(arr) = listing.as_array() { + for db in arr { + if db.get("name").and_then(|v| v.as_str()) == Some(SHARED_DATABASE_NAME) { + if let Some(id) = db.get("id").and_then(|v| v.as_str()) { + return id.to_string(); + } + } + } + } + + let created = cli.json(&[ + "databases", + "create", + "--name", + SHARED_DATABASE_NAME, + "--catalog", + SHARED_DATABASE_CATALOG, + "-o", + "json", + ]); + created + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("databases create returned no id: {created}")) + .to_string() +} + +/// Build a [`Cli`] from the environment, or `None` if required creds are missing. +pub fn cli_or_skip() -> Option { + let env = load_env(); + if !env.has_creds() { + return None; + } + Some(Cli::new(env)) +} + +/// Early-return out of a `#[test]` when credentials are unavailable, printing a +/// notice to stderr. Mirrors sdk-rust's macro of the same name. Evaluates to the +/// bound [`Cli`] when creds are present. +#[macro_export] +macro_rules! skip_if_no_creds { + () => {{ + match $crate::common::cli_or_skip() { + Some(cli) => cli, + None => { + eprintln!( + "SKIP {}: set HOTDATA_SDK_TEST_API_KEY and \ + HOTDATA_SDK_TEST_WORKSPACE_ID to run this scenario", + module_path!() + ); + return; + } + } + }}; +} + +/// Like [`skip_if_no_creds!`] but also requires `HOTDATA_SDK_TEST_CONNECTION_ID`. +/// Returns `(Cli, connection_id)`. +#[macro_export] +macro_rules! skip_if_no_connection { + () => {{ + match $crate::common::cli_or_skip() { + Some(cli) => { + let connection_id = cli.env.connection_id.clone(); + match connection_id { + Some(connection_id) => (cli, connection_id), + None => { + eprintln!( + "SKIP {}: set HOTDATA_SDK_TEST_CONNECTION_ID to run this scenario", + module_path!() + ); + return; + } + } + } + None => { + eprintln!( + "SKIP {}: set HOTDATA_SDK_TEST_API_KEY and \ + HOTDATA_SDK_TEST_WORKSPACE_ID to run this scenario", + module_path!() + ); + return; + } + } + }}; +} diff --git a/tests/connections_read.rs b/tests/connections_read.rs new file mode 100644 index 0000000..60ffec9 --- /dev/null +++ b/tests/connections_read.rs @@ -0,0 +1,49 @@ +//! Scenario: connections_read. +//! +//! Read-only lifecycle on the seeded connection. The CLI exposes `connections +//! list` and `connections ` (the latter also runs a health check and folds +//! the result into its output); it has no separate cache-purge surface, so this +//! mirror covers get + list + health. Does not create or delete connections in +//! prod (would require real datastore credentials). + +mod common; + +#[test] +fn connections_read() { + let (cli, connection_id) = skip_if_no_connection!(); + + // list_connections — the seeded connection must be present. + let listing = cli.json(&["connections", "list", "-o", "json"]); + let connections = listing + .as_array() + .expect("connections list -o json should be a JSON array"); + let ids: Vec<&str> = connections + .iter() + .filter_map(|c| c.get("id").and_then(|v| v.as_str())) + .collect(); + assert!( + ids.contains(&connection_id.as_str()), + "seeded connection {connection_id} not in connections list, got {ids:?}" + ); + + // get_connection (+ health) — the detail view reports the same id and a + // health block. + let detail = cli.json(&["connections", &connection_id, "-o", "json"]); + assert_eq!( + detail.get("id").and_then(|v| v.as_str()), + Some(connection_id.as_str()), + "connections returned the wrong id: {detail}" + ); + assert!( + !detail + .get("source_type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .is_empty(), + "expected a non-empty source_type: {detail}" + ); + assert!( + detail.get("health").is_some(), + "expected a health block in connections output: {detail}" + ); +} diff --git a/tests/query_async_polling.rs b/tests/query_async_polling.rs new file mode 100644 index 0000000..7b9e4cb --- /dev/null +++ b/tests/query_async_polling.rs @@ -0,0 +1,67 @@ +//! Scenario: query_async_polling. +//! +//! The CLI's `query` command submits asynchronously (async_after_ms) and polls +//! the query run to a terminal state internally before printing — so a single +//! `hotdata query` invocation exercises the full async submit + poll + fetch +//! path. We then confirm the run and its result surface via `results list`, +//! `queries list`, and `results `. +//! +//! Queries require a database scope, so we target the shared `sdkci-shared` +//! managed database (otherwise the server returns 400 "a database is required"). + +mod common; + +#[test] +fn query_async_polling() { + let cli = skip_if_no_creds!(); + let database_id = common::shared_database_id(&cli); + + // Submit + poll + fetch, all inside `query`. + let result = cli.json(&["query", "SELECT 1 AS x", "-d", &database_id, "-o", "json"]); + assert_eq!( + result.get("row_count").and_then(|v| v.as_u64()), + Some(1), + "expected row_count 1: {result}" + ); + assert_eq!( + result.get("rows"), + Some(&serde_json::json!([[1]])), + "expected rows [[1]]: {result}" + ); + let result_id = result + .get("result_id") + .and_then(|v| v.as_str()) + .expect("query result should expose a result_id") + .to_string(); + + // list_results — the new result is surfaced. + let results = cli.json(&["results", "list", "-o", "json"]); + let result_ids: Vec<&str> = results + .as_array() + .expect("results list -o json should be a JSON array") + .iter() + .filter_map(|r| r.get("id").and_then(|v| v.as_str())) + .collect(); + assert!( + result_ids.contains(&result_id.as_str()), + "result {result_id} not surfaced by results list, got {result_ids:?}" + ); + + // list_query_runs — at least one run exists after submitting a query. + let runs = cli.json(&["queries", "list", "-o", "json"]); + assert!( + !runs + .as_array() + .expect("queries list -o json should be a JSON array") + .is_empty(), + "expected at least one query run after submitting a query" + ); + + // get_result — fetching by id round-trips the single row. + let fetched = cli.json(&["results", &result_id, "-o", "json"]); + assert_eq!( + fetched.get("row_count").and_then(|v| v.as_u64()), + Some(1), + "expected row_count 1 from results : {fetched}" + ); +} diff --git a/tests/results_arrow.rs b/tests/results_arrow.rs new file mode 100644 index 0000000..5898415 --- /dev/null +++ b/tests/results_arrow.rs @@ -0,0 +1,48 @@ +//! Scenario: results_arrow. +//! +//! The CLI always fetches stored results as Arrow IPC over the wire +//! (`Accept: application/vnd.apache.arrow.stream`, see `src/query.rs` +//! `fetch_arrow_result`) and decodes them locally before rendering. So any +//! `query`/`results` invocation that prints rows exercises the Arrow round-trip +//! end to end. We verify schema + values survive both the JSON and CSV +//! renderers. +//! +//! `ORDER BY x` makes the row order deterministic — a bare UNION ALL has no +//! guaranteed order, so the assertions below would otherwise be flaky. +//! +//! Note: the CLI's `results ` has no offset/limit flags, so the Arrow +//! offset/limit pagination the SDK test covers is out of scope here. + +mod common; + +const SQL: &str = "SELECT 1 AS x, 'hello' AS msg UNION ALL SELECT 2, 'world' ORDER BY x"; + +#[test] +fn results_arrow() { + let cli = skip_if_no_creds!(); + let database_id = common::shared_database_id(&cli); + + // JSON renderer: schema + values round-trip through the Arrow decode. + let result = cli.json(&["query", SQL, "-d", &database_id, "-o", "json"]); + assert_eq!( + result.get("columns"), + Some(&serde_json::json!(["x", "msg"])), + "expected columns [x, msg]: {result}" + ); + assert_eq!( + result.get("rows"), + Some(&serde_json::json!([[1, "hello"], [2, "world"]])), + "expected rows [[1, hello], [2, world]]: {result}" + ); + + // CSV renderer: the same Arrow data, formatted as CSV. + let output = cli.run(&["query", SQL, "-d", &database_id, "-o", "csv"]); + common::assert_success(&output, &["query", "", "-d", "", "-o", "csv"]); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!( + lines, + vec!["x,msg", "1,hello", "2,world"], + "unexpected CSV output:\n{stdout}" + ); +} diff --git a/tests/workspaces_list.rs b/tests/workspaces_list.rs new file mode 100644 index 0000000..99400eb --- /dev/null +++ b/tests/workspaces_list.rs @@ -0,0 +1,27 @@ +//! Scenario: workspaces_list. +//! +//! `hotdata workspaces list -o json` returns the workspaces visible to the +//! seeded credentials and includes the seeded HOTDATA_SDK_TEST_WORKSPACE_ID. +//! Read-only — never creates or deletes workspaces against prod. + +mod common; + +#[test] +fn workspaces_list() { + let cli = skip_if_no_creds!(); + let workspace_id = cli.workspace_id().to_string(); + + let value = cli.json(&["workspaces", "list", "-o", "json"]); + let workspaces = value + .as_array() + .expect("workspaces list -o json should be a JSON array"); + + let ids: Vec<&str> = workspaces + .iter() + .filter_map(|w| w.get("public_id").and_then(|v| v.as_str())) + .collect(); + assert!( + ids.contains(&workspace_id.as_str()), + "expected seeded workspace {workspace_id} in list, got {ids:?}" + ); +}