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:
MechaCat02
2026-07-15 19:39:13 +02:00
parent 2445074c87
commit 345db4a076
22 changed files with 131 additions and 59 deletions

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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."

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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", &[])],