//! `pic apps create` / `pic apps ls` edge cases. The integration smoke //! test covers the happy path; this module covers conflict, validation, //! and the persistence of the optional `--name` / `--description` flags //! (which `apps ls` doesn't surface). use predicates::prelude::*; use serde_json::Value; use crate::common; use crate::common::cleanup::AppGuard; use crate::common::member; #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn create_with_name_and_description_persists() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-named"); common::pic_as(&env) .args([ "apps", "create", &slug, "--name", "Pretty Name", "--description", "test description", ]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); // `apps ls` only shows slug+name+role+created_at, so verify the // persisted shape via the admin GET endpoint. let client = reqwest::blocking::Client::new(); let resp = client .get(format!("{}/api/v1/admin/apps/{}", env.url, slug)) .bearer_auth(&env.token) .send() .expect("GET app"); assert!(resp.status().is_success(), "GET app failed: {resp:?}"); let body: Value = resp.json().expect("app json"); assert_eq!(body["slug"].as_str(), Some(slug.as_str())); assert_eq!(body["name"].as_str(), Some("Pretty Name")); assert_eq!(body["description"].as_str(), Some("test description")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn create_duplicate_slug_conflicts() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-dup"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let _guard = AppGuard::new(&env.url, &env.token, &slug); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .failure() .stderr(predicate::str::contains("409").or(predicate::str::contains("conflict"))); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn create_invalid_slug_rejected() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); // Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase // breaks the rule on the very first char. The server returns 422 // (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous // `"HTTP 4"` predicate would have silently matched any other 4xx // (a regressed 401 from broken auth, for example). common::pic_as(&env) .args(["apps", "create", "NotALowerSlug"]) .assert() .failure() .stderr(predicate::str::contains("HTTP 422")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn ls_includes_created_app_with_expected_columns() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-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(["apps", "ls"]) .output() .expect("apps ls"); assert!(out.status.success(), "apps ls failed: {out:?}"); let stdout = String::from_utf8(out.stdout).expect("utf8 stdout"); let mut lines = stdout.lines(); let header = lines.next().expect("header row"); assert_eq!( common::cells(header), vec!["slug", "name", "my_role", "created_at"] ); // The slug must appear in some data row and its row's my_role column // is dashed (the ls endpoint doesn't compute it per-app). let row = lines .map(common::cells) .find(|c| c.first().copied() == Some(slug.as_str())) .unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}")); assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}"); assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn delete_removes_app_from_ls() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-del"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); common::pic_as(&env) .args(["apps", "delete", &slug]) .assert() .success() .stdout(predicate::str::contains(format!("Deleted app {slug}"))); 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(); assert!( !stdout.lines().any(|l| l.starts_with(&slug)), "deleted slug should not appear in ls: {stdout}" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn delete_with_scripts_errors_without_force() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-del-busy"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); // AppGuard is the safety net: if the no-force delete fails (as // expected) the app stays around; AppGuard force-deletes on drop. 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(); common::pic_as(&env) .args(["apps", "delete", &slug]) .assert() .failure() // Server `HasScripts` → 409 with a "scripts present" message. .stderr(predicate::str::contains("HTTP 409")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn delete_with_scripts_succeeds_with_force() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let slug = common::unique_slug("apps-del-force"); common::pic_as(&env) .args(["apps", "create", &slug]) .assert() .success(); let fixture = common::fixture_path("hello.rhai"); common::pic_as(&env) .args([ "scripts", "deploy", fixture.to_str().unwrap(), "--app", &slug, ]) .assert() .success(); common::pic_as(&env) .args(["apps", "delete", &slug, "--force"]) .assert() .success() .stdout(predicate::str::contains(format!("Deleted app {slug}"))); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn show_prints_my_role_for_member() { let Some(fx) = common::fixture_or_skip() else { return; }; let admin_env = common::admin_env(fx); let slug = common::unique_slug("apps-show"); common::pic_as(&admin_env) .args(["apps", "create", &slug]) .assert() .success(); let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug); let m = member::member_user(fx, &common::unique_username("show")); member::grant_membership(fx, &slug, &m.id, "viewer"); let member_env = common::custom_env(&fx.url, &m.token); common::seed_credentials(&member_env, &m.username); let out = common::pic_as(&member_env) .args(["apps", "show", &slug]) .output() .expect("apps show"); assert!(out.status.success(), "apps show failed: {out:?}"); let stdout = String::from_utf8(out.stdout).unwrap(); // KvBlock output: `my_role` row carries the wire form (`viewer`). assert!( stdout .lines() .any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")), "show should surface my_role=viewer, got: {stdout}" ); assert!( stdout.lines().any(|l| l.starts_with("slug")), "show should include slug row: {stdout}" ); }