Files
MechaCat02 345db4a076 test: close the DB-suite hermeticity + vacuous-skip gaps
Three related fixes from the test audit.

**The CLI journey fixture gets its own database.** It spawned a real picloud —
whose dispatcher/orchestrator claim loops are global by design (one instance owns
one database) — against the shared dev DB, so it could claim the manager-core
suites' outbox/workflow rows (the same class of bug already fixed for the e2e
suites, one binary over). It now clones one dedicated database per journey run
from the migrated template. test-support gains `named_test_db_url` (explicit
stable name) + a blocking wrapper for the sync `LazyLock` fixture. The one journey
that talks to Postgres directly (dead-letter injection) now uses the fixture's DB
URL, not the base DATABASE_URL, so it hits the database the server reads.

**workflow_orchestrator moves to per-test databases.** Its `claim_ready_step` is
global, so the old harness serialized every test behind a process-wide CLAIM_LOCK
AND ran `DELETE FROM workflow_runs` (unscoped — it wiped every app's runs) before
each one. A private database per test makes the global claim see only that test's
rows, so both the lock and the unscoped DELETE are deleted.

**DB-backed suites fail loud instead of skipping green.** ~15 manager-core suites
`return None` when DATABASE_URL is unset and report PASS — so in any environment
that lost its database the entire integration surface reports green while running
nothing (why the CI gap went unnoticed for so long). New
`picloud_test_support::abort_if_db_required` panics when `PICLOUD_REQUIRE_DB` is
set (CI now sets it) but DATABASE_URL is not, injected into each suite's skip
path. Local runs without the var still skip cleanly.

Mutation-verified: with PICLOUD_REQUIRE_DB=1 and DATABASE_URL unset, a suite
panics; without the var it skips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:39:13 +02:00

333 lines
12 KiB
Rust

//! Shared fixture and helpers for the CLI integration test binary.
//!
//! All tests in `tests/cli.rs` route through `fixture()`, a `LazyLock`
//! that spawns picloud on a private port the first time it's touched
//! and reuses that subprocess for every subsequent test. The dashboard
//! Playwright suite pays the same cost once for 63 tests; we do the
//! same here.
#![allow(dead_code)] // shared helpers — not every module uses every fn.
pub mod cleanup;
pub mod member;
pub mod server;
use std::path::PathBuf;
use std::process::Child;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use assert_cmd::Command as AssertCommand;
use tempfile::TempDir;
// --------------------------------------------------------------------
// Fixture
// --------------------------------------------------------------------
pub struct Fixture {
pub url: String,
pub admin_token: String,
pub admin_username: String,
/// The Postgres URL the spawned picloud is using — a per-journey-run cloned
/// database, NOT the base `DATABASE_URL`. A test that talks to the DB directly
/// (e.g. injecting a dead-letter row) must use THIS so it hits the same
/// database the server does.
pub database_url: String,
// Held in a Mutex so Drop can kill it without UB; we never re-enter.
child: Mutex<Option<Child>>,
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Ok(mut guard) = self.child.lock() {
if let Some(mut child) = guard.take() {
server::kill_subprocess(&mut child);
}
}
}
}
static FIXTURE: LazyLock<Fixture> = LazyLock::new(init_fixture);
fn init_fixture() -> Fixture {
// ONE database for the whole journey binary, cloned fresh from the migrated
// template. The spawned picloud runs the real dispatcher/orchestrator, whose
// claim loops are global by design (one instance owns one database) — sharing
// the dev DB with the manager-core suites let it claim their outbox/workflow
// rows. A dedicated DB matches production and ends that interference.
let database_url = picloud_test_support::named_test_db_url_blocking("pt_cli_journeys")
.expect("DATABASE_URL is required to spawn picloud");
let username = admin_username();
let password = admin_password();
let port = server::pick_free_port();
let url = format!("http://127.0.0.1:{port}");
let mut child = server::spawn_picloud(&database_url, port, &username, &password);
if let Err(e) = server::wait_for_health(&url, Duration::from_secs(60)) {
server::kill_subprocess(&mut child);
panic!("picloud failed to become healthy: {e}");
}
let token = server::login_for_bearer_token(&url, &username, &password);
Fixture {
url,
admin_token: token,
admin_username: username,
database_url,
child: Mutex::new(Some(child)),
}
}
/// Returns the shared fixture, spawning the picloud subprocess on first
/// call. Returns `None` (and prints a skip message) when `DATABASE_URL`
/// is absent — matching the existing convention so the suite is a
/// no-op outside the integration environment.
pub fn fixture_or_skip() -> Option<&'static Fixture> {
if std::env::var("DATABASE_URL").is_err() {
// CI sets PICLOUD_REQUIRE_DB so a lost/misconfigured database fails the
// build instead of silently skipping the entire journey suite.
assert!(
std::env::var("PICLOUD_REQUIRE_DB").is_err(),
"DATABASE_URL is unset but PICLOUD_REQUIRE_DB is set — refusing to skip \
the CLI journeys silently"
);
eprintln!("skipping: DATABASE_URL not set");
return None;
}
Some(&FIXTURE)
}
// --------------------------------------------------------------------
// Per-test env
// --------------------------------------------------------------------
pub struct TestEnv {
pub url: String,
pub token: String,
pub config_dir: TempDir,
pub home: TempDir,
}
/// Per-test env pre-loaded with the admin token, and a credentials
/// file already on disk so non-login commands ("pic apps create", …)
/// can run without first calling `pic login`. As of the env-var
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
/// also work for *every* command, not just `login` — `config::resolve`
/// reads them first and falls back to the on-disk file.
pub fn admin_env(fx: &Fixture) -> TestEnv {
let env = TestEnv {
url: fx.url.clone(),
token: fx.admin_token.clone(),
config_dir: TempDir::new().expect("config tempdir"),
home: TempDir::new().expect("home tempdir"),
};
seed_credentials(&env, &fx.admin_username);
env
}
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by
/// tests that want a non-admin token, a bogus token, or an unreachable
/// URL. Does **not** seed a credentials file — call `seed_credentials`
/// explicitly when the test needs to run non-login commands.
pub fn custom_env(url: &str, token: &str) -> TestEnv {
TestEnv {
url: url.to_string(),
token: token.to_string(),
config_dir: TempDir::new().expect("config tempdir"),
home: TempDir::new().expect("home tempdir"),
}
}
/// Write a valid credentials TOML into `env.config_dir` so subsequent
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
/// the file shape `pic login` produces (url/token/username). Tests that
/// exercise the "no credentials" / "stale token" error paths construct
/// `TestEnv` directly to keep the config dir empty.
pub fn seed_credentials(env: &TestEnv, username: &str) {
let body = format!(
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
env.url, env.token, username,
);
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
}
/// `pic` invocation with the env wired up — credentials dir, HOME, and
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
pub fn pic_as(env: &TestEnv) -> AssertCommand {
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
cmd.env("PICLOUD_URL", &env.url)
.env("PICLOUD_TOKEN", &env.token)
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
.env("HOME", env.home.path());
cmd
}
/// `pic` invocation with `PICLOUD_URL`/`PICLOUD_TOKEN` *cleared*, so the
/// command sees only the on-disk credentials file (or lack thereof).
pub fn pic_no_env(env: &TestEnv) -> AssertCommand {
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
cmd.env_remove("PICLOUD_URL")
.env_remove("PICLOUD_TOKEN")
.env("PICLOUD_CONFIG_DIR", env.config_dir.path())
.env("HOME", env.home.path());
cmd
}
// --------------------------------------------------------------------
// Unique slugs / usernames
// --------------------------------------------------------------------
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn unique_slug(prefix: &str) -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("pic-cli-{prefix}-{ms}-{n:x}")
}
pub fn unique_username(prefix: &str) -> String {
// Server regex: [a-z0-9._-]{2,32}. Build out of lowercase
// alphanumerics only; "piccli" prefix keeps collisions with other
// test suites obvious. Caller's `prefix` must be ≤8 chars and
// already match the regex.
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let n = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
let name = format!("piccli{prefix}{ms:x}{n:x}");
assert!(name.len() <= 32, "username overflow: {name}");
name
}
// --------------------------------------------------------------------
// Misc helpers
// --------------------------------------------------------------------
pub fn admin_username() -> String {
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
}
pub fn admin_password() -> String {
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
}
pub fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
/// it. Returns the new script id plus the `AppGuard` that cleans the
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
/// logs / output journeys that all need "deploy something, then drive
/// `pic` against it".
pub fn deploy_fixture(
env: &TestEnv,
app_label: &str,
fixture_name: &str,
) -> (String, cleanup::AppGuard) {
let slug = unique_slug(app_label);
pic_as(env)
.args(["apps", "create", &slug])
.assert()
.success();
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
let fixture = fixture_path(fixture_name);
pic_as(env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
let out = pic_as(env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
.expect("scripts ls should produce one row");
(id, guard)
}
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
/// cells. The output writer space-pads each cell to its column's max
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
/// this helper hides that detail from tests that only care about the
/// logical values.
pub fn cells(row: &str) -> Vec<&str> {
row.split('\t').map(str::trim).collect()
}
/// First data row's first tab-delimited cell, used to extract IDs from
/// `pic scripts ls` output. The header is expected to start with "id".
pub fn parse_first_id(table: &str) -> Option<String> {
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
let header = lines.next()?;
if !header.starts_with("id") {
return None;
}
let row = lines.next()?;
let first = row.split('\t').next()?.trim();
if first.is_empty() {
None
} else {
Some(first.to_string())
}
}
// --------------------------------------------------------------------
// Fixture-sharing sanity check
// --------------------------------------------------------------------
//
// Two tests record `fixture().url` into the same `OnceLock` — if the
// fixture isn't actually shared, the second test sees a different URL
// and panics. Belt-and-suspenders: pointer identity on `&Fixture`.
static OBSERVED_URL: OnceLock<String> = OnceLock::new();
fn observe_fixture_url(label: &str) {
let Some(fx) = fixture_or_skip() else {
return;
};
let url = fx.url.clone();
match OBSERVED_URL.get() {
Some(prev) => assert_eq!(
prev, &url,
"{label} observed a different fixture URL: prior={prev} now={url}"
),
None => {
let _ = OBSERVED_URL.set(url);
}
}
// Same `&'static Fixture` from every call — proves the LazyLock is
// sharing, not respawning.
let a = fixture_or_skip().unwrap();
let b = fixture_or_skip().unwrap();
assert!(
std::ptr::eq(a, b),
"fixture_or_skip should return the same &'static Fixture"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_a() {
observe_fixture_url("fixture_url_is_shared_a");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn fixture_url_is_shared_b() {
observe_fixture_url("fixture_url_is_shared_b");
}