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>
This commit is contained in:
@@ -21,6 +21,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("email_secret_aad");
|
||||
eprintln!("email_secret_aad: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("group_collection_isolation");
|
||||
eprintln!("group_collection_isolation: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("group_queue");
|
||||
eprintln!("group_queue: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("group_route_templates");
|
||||
eprintln!("group_route_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("group_suppression");
|
||||
eprintln!("group_suppression: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("group_trigger_templates");
|
||||
eprintln!("group_trigger_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ use sqlx::PgPool;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("migration_queue_messages");
|
||||
eprintln!("migration_queue_messages: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("projects_repo");
|
||||
eprintln!("projects_repo: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("queue_release");
|
||||
eprintln!("queue_release: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ async fn schema_after_replay_matches_snapshot() {
|
||||
// Skip cleanly when DATABASE_URL is unset so `cargo test --workspace`
|
||||
// stays green without Postgres. CI sets it (postgres:15 service).
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("schema_snapshot");
|
||||
eprintln!(
|
||||
"schema_snapshot: DATABASE_URL unset — skipping. Set it (e.g. \
|
||||
postgres://picloud:picloud@localhost:5432/picloud) to run this guardrail."
|
||||
|
||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("sealed_templates");
|
||||
eprintln!("sealed_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("shared_topics");
|
||||
eprintln!("shared_topics: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("shared_triggers");
|
||||
eprintln!("shared_triggers: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("stateful_templates");
|
||||
eprintln!("stateful_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
picloud_test_support::abort_if_db_required("template_suppression");
|
||||
eprintln!("template_suppression: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
#![allow(clippy::too_many_lines)]
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, ExecStats};
|
||||
@@ -34,47 +34,19 @@ use picloud_shared::workflow::{
|
||||
use picloud_shared::{AppId, ExecutionLogSink, LogSinkError, WorkflowId};
|
||||
use picloud_shared::{RequestId, ScriptId, WorkflowService};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::time::Duration as StdDuration;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// `claim_ready_step` is deliberately **global** (one production orchestrator
|
||||
/// claims every app's ready steps). These tests share one DB, so a sibling
|
||||
/// test's concurrent claim would steal steps from the run under assertion. This
|
||||
/// process-wide lock serializes the claim-driving tests — each still creates
|
||||
/// its own app/workflow/run, and each finishes with no `ready` steps, so once
|
||||
/// serialized they don't interfere. (See the shared-DB test-harness note.)
|
||||
static CLAIM_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
/// Acquire the serialization lock AND clear any leftover runs/steps so the
|
||||
/// global `claim_ready_step`/`reclaim_stale_steps` see only this test's rows.
|
||||
/// (The shared dev DB accumulates rows across runs; `workflow_runs` CASCADEs to
|
||||
/// its steps.) Returns the held guard.
|
||||
async fn lock_and_reset(pool: &PgPool) -> tokio::sync::MutexGuard<'static, ()> {
|
||||
let guard = CLAIM_LOCK.lock().await;
|
||||
sqlx::query("DELETE FROM workflow_runs")
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("reset workflow_runs");
|
||||
guard
|
||||
}
|
||||
|
||||
/// A PRIVATE database per test. `claim_ready_step`/`reclaim_stale_steps` are
|
||||
/// deliberately global (one production orchestrator claims every app's ready
|
||||
/// steps), so when these tests shared one DB a sibling's concurrent claim would
|
||||
/// steal the steps under assertion — the reason the old harness needed a
|
||||
/// process-wide `CLAIM_LOCK` AND an unscoped `DELETE FROM workflow_runs` before
|
||||
/// every test. A private database makes both unnecessary: the global claim only
|
||||
/// ever sees this test's own rows.
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("workflow_orchestrator: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
picloud_test_support::test_pool("workflow_orchestrator").await
|
||||
}
|
||||
|
||||
fn step(name: &str, function: &str, deps: &[&str]) -> WorkflowStepDef {
|
||||
@@ -165,7 +137,6 @@ async fn seeds_roots_ready_and_completes_linear_chain() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// a -> b -> c
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![
|
||||
@@ -205,7 +176,6 @@ async fn parallel_fan_out_and_fan_in() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// a -> {b, c} -> d (diamond).
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![
|
||||
@@ -268,7 +238,6 @@ async fn retry_then_succeed() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let mut a = step("a", "fn_a", &[]);
|
||||
a.retry = Some(WorkflowRetry {
|
||||
max_attempts: 3,
|
||||
@@ -305,7 +274,6 @@ async fn on_error_fail_fails_the_run() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// a (fail) -> b; a has no retry and on_error=fail → run fails, b never runs.
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("a", "fn_a", &[]), step("b", "fn_b", &["a"])],
|
||||
@@ -337,7 +305,6 @@ async fn on_error_continue_run_succeeds() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let mut a = step("a", "fn_a", &[]);
|
||||
a.on_error = OnError::Continue;
|
||||
let def = WorkflowDefinition {
|
||||
@@ -371,7 +338,6 @@ async fn double_complete_is_idempotent() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("a", "fn_a", &[])],
|
||||
};
|
||||
@@ -403,7 +369,6 @@ async fn stale_lease_is_reclaimed() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("a", "fn_a", &[])],
|
||||
};
|
||||
@@ -491,7 +456,6 @@ async fn end_to_end_tick_executes_functions_and_completes() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// a -> b; both bound to real scripts (fn_a, fn_b) so name resolution works.
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("a", "fn_a", &[]), step("b", "fn_b", &["a"])],
|
||||
@@ -738,7 +702,6 @@ async fn when_false_skips_step() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// a -> b, but b runs only `when input.flag` (false here) → b is skipped.
|
||||
let mut b = step("b", "fn_b", &["a"]);
|
||||
b.when = Some("input.flag".into());
|
||||
@@ -778,7 +741,6 @@ async fn step_output_flows_into_downstream_input() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
// b's input pulls a field out of a's output, type-preserved.
|
||||
let mut b = step("b", "fn_b", &["a"]);
|
||||
b.input = json!({ "v": "{{ steps.a.output.n }}", "label": "n={{ steps.a.output.n }}" });
|
||||
@@ -810,7 +772,6 @@ async fn missing_input_ref_fails_the_step() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let mut b = step("b", "fn_b", &["a"]);
|
||||
b.input = json!({ "v": "{{ steps.a.output.does_not_exist }}" });
|
||||
let def = WorkflowDefinition {
|
||||
@@ -848,7 +809,6 @@ async fn nested_sub_workflow_runs_and_output_flows_to_parent() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let app = seed_app(&pool).await;
|
||||
|
||||
// Child workflow: one function step producing { answer: 42 }.
|
||||
@@ -905,7 +865,6 @@ async fn nesting_depth_ceiling_fails_the_run() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let app = seed_app(&pool).await;
|
||||
|
||||
// A workflow that nests into ITSELF — bounded only by the depth ceiling.
|
||||
@@ -957,7 +916,6 @@ async fn workflow_service_start_seeds_a_run() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let _claim_guard = lock_and_reset(&pool).await;
|
||||
let app = seed_app(&pool).await;
|
||||
let def = WorkflowDefinition {
|
||||
steps: vec![step("only", "fn_only", &[])],
|
||||
|
||||
Reference in New Issue
Block a user