//! Output-shape invariants — the contracts downstream `jq`/`awk` //! pipelines depend on: column headers, stdout-vs-stderr separation, //! and RFC3339 timestamps. use serde_json::Value; use crate::common; use crate::common::cleanup::AppGuard; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn apps_ls_header_columns() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let out = common::pic_as(&env) .args(["apps", "ls"]) .output() .expect("apps ls"); assert!(out.status.success()); let stdout = String::from_utf8(out.stdout).unwrap(); let header = stdout.lines().next().expect("header row"); assert_eq!( common::cells(header), vec!["slug", "name", "my_role", "created_at"] ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn scripts_ls_header_columns() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("out-ls"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); let out = common::pic_as(&env) .args(["scripts", "ls", "--app", &slug]) .output() .expect("scripts ls"); assert!(out.status.success()); let stdout = String::from_utf8(out.stdout).unwrap(); let header = stdout.lines().next().expect("header row"); assert_eq!( common::cells(header), vec!["id", "app_slug", "name", "version", "updated_at"] ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn invoke_separates_stdout_and_stderr() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai"); let out = common::pic_as(&env) .args(["scripts", "invoke", &id]) .output() .expect("invoke"); assert!(out.status.success()); let stderr = String::from_utf8(out.stderr).unwrap(); assert!( stderr.starts_with("<- HTTP 200"), "stderr should announce HTTP status: {stderr:?}" ); let parsed: Value = serde_json::from_slice(&out.stdout) .expect("stdout should be JSON only, with no status prefix"); assert_eq!(parsed["ok"], true, "body: {parsed}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn error_goes_to_stderr_not_stdout() { let Some(_fx) = common::fixture_or_skip() else { return; }; // Use a pristine env (no credentials file) so `whoami` is guaranteed // to fail at the `config::load` step — `admin_env` would pre-seed // creds and the command would succeed. let env = common::TestEnv { url: String::new(), token: String::new(), config_dir: tempfile::TempDir::new().unwrap(), home: tempfile::TempDir::new().unwrap(), }; let out = common::pic_no_env(&env) .args(["whoami"]) .output() .expect("whoami"); assert!(!out.status.success(), "expected failure, got: {out:?}"); assert!( out.stdout.is_empty(), "stdout should be empty on error, got: {:?}", String::from_utf8_lossy(&out.stdout), ); let stderr = String::from_utf8(out.stderr).unwrap(); assert!( stderr.contains("error:"), "stderr should be prefixed with `error:`: {stderr}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn apps_ls_created_at_is_rfc3339() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("out-date"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); let out = common::pic_as(&env) .args(["apps", "ls"]) .output() .expect("apps ls"); let stdout = String::from_utf8(out.stdout).unwrap(); let row = stdout .lines() .map(common::cells) .find(|c| c.first().copied() == Some(slug.as_str())) .unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}")); let created_at = row.get(3).expect("created_at cell"); // Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS` // with optional fraction + timezone is enough of a contract for the test. assert!( created_at.len() >= 20 && created_at.as_bytes()[4] == b'-' && created_at.as_bytes()[7] == b'-' && created_at.as_bytes()[10] == b'T' && created_at.as_bytes()[13] == b':' && created_at.as_bytes()[16] == b':', "created_at not RFC3339-shaped: {created_at}" ); } /// `--output json` is the global pipeline-friendly format. Validates /// `apps ls` returns a real JSON array (not a TSV-with-quotes hack). #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn apps_ls_json_output_is_valid_array() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("out-json-apps"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); let out = common::pic_as(&env) .args(["--output", "json", "apps", "ls"]) .output() .expect("apps ls --output json"); assert!(out.status.success(), "apps ls failed: {out:?}"); let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON"); let arr = v.as_array().expect("apps ls JSON should be an array"); assert!( arr.iter() .any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())), "json should include created slug: {v}" ); // The header row must NOT bleed into JSON output — the rendered // objects use header *keys*, not data cells. assert!( arr.iter().all(|row| row.get("slug").is_some()), "every row should have a `slug` key: {v}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn scripts_ls_json_output_has_app_slug() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("out-json-scr"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); let fixture = common::fixture_path("hello.rhai"); common::pic_as(&env) .args([ "scripts", "deploy", fixture.to_str().unwrap(), "--app", &slug, ]) .assert() .success(); let out = common::pic_as(&env) .args(["--output", "json", "scripts", "ls", "--app", &slug]) .output() .expect("scripts ls --output json"); assert!(out.status.success()); let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); let arr = v.as_array().expect("array"); let row = arr .iter() .find(|r| r.get("name").and_then(Value::as_str) == Some("hello")) .expect("hello row"); assert_eq!(row["app_slug"].as_str(), Some(slug.as_str())); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn logs_json_output_is_array_of_objects() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai"); common::pic_as(&env) .args(["scripts", "invoke", &id]) .assert() .success(); let out = common::pic_as(&env) .args(["--output", "json", "logs", &id]) .output() .expect("logs --output json"); assert!(out.status.success()); let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON"); let arr = v.as_array().expect("array"); assert!(!arr.is_empty(), "expected at least one log"); // Schema: each row carries the raw `ExecutionLog`, not the // truncated summary the TSV form uses. assert!( arr[0].get("status").is_some(), "log row missing status: {arr:?}" ); } /// TSV `whoami` used to be a single tab-separated line with no labels; /// downstream tools couldn't tell which column was the role. Now it's /// a key/value block with stable labels. #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn whoami_tsv_has_labeled_rows() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let out = common::pic_as(&env) .args(["whoami"]) .output() .expect("whoami"); assert!(out.status.success()); let stdout = String::from_utf8(out.stdout).unwrap(); let labels: Vec<&str> = stdout .lines() .filter_map(|l| l.split('\t').next()) .map(str::trim) .collect(); assert!( labels.contains(&"username"), "missing username row: {stdout}" ); assert!(labels.contains(&"role"), "missing role row: {stdout}"); assert!(labels.contains(&"email"), "missing email row: {stdout}"); assert!(labels.contains(&"url"), "missing url row: {stdout}"); }