tests/not_startable.rs
//! **A collector whose config is not filled in is NOT STARTABLE.** The owner's ruling,
//! 2026-09-06: "a collector whose config is not filled in must be NOT STARTABLE, full
//! stop" — it *refuses at load* with a named reason, and it **never half-starts, never
//! retries, never logs a nag**.
//!
//! # Why this file exists — the report was not the behaviour
//!
//! `src/requires.rs` already declares the requirement and already derives
//! [`Readiness::NeedsConfig`] with every missing item NAMED, and korp's object registry
//! already renders it: the live oden server showed 21 collectors refusing, each naming
//! its own variable (`ENTSOE_TOKEN`, `OPENSKY_CLIENT_ID`, `TRAFIKVERKET_API_KEY`,
//! `BOLAGSVERKET_CLIENT_ID`). All true, and all of it a **report**.
//!
//! Underneath, nothing consulted it. Grep every use of `readiness(` / `NeedsConfig`
//! outside `requires.rs` on `439b845` and every hit is a test or a doc comment. The
//! daemon path did the opposite ON PURPOSE — `daemon_run_loop`'s own comment said an
//! unresolved secret is "a loud `Failed` fact this tick, **not a permanent dead
//! daemon**" — so a collector the owner has no key for entered the loop, failed, logged
//! the reason, backed off 30 s → 3600 s, and did it again until shutdown. Forever.
//!
//! That is the exact defect class this constellation has been finding all week: a
//! surface indistinguishable from a working one. The roster said REFUSED while the
//! daemon underneath was ticking.
//!
//! # What is asserted here
//!
//! The APPLIED behaviour, never a state round-trip (LAW 2): the daemon's own `run()`
//! future is driven to completion and we count what it DID — how many ticks, how many
//! log lines, whether `shutdown` was ever tripped. A test that only asked
//! `Requirements::readiness(...).key() == "needs_config"` would have passed on
//! `439b845`, when the daemon was nagging hourly.
#![cfg(feature = "daemon")]
use std::sync::{Arc, Mutex};
use std::time::Duration;
use korp_collectors::daemon::{
Collector, CollectorCtx, CollectorState, LogSink, Phase, SecretStore, SecretString,
};
use korp_collectors::iceberg_daemons::TrafikverketDaemon;
/// **A secret backend that resolves nothing** — an unconfigured host, exactly.
///
/// Not `EnvSecretStore`: that one falls through to the real process environment, so a
/// developer who happens to export `TRAFIKVERKET_API_KEY` would turn this test green
/// for the wrong reason. The whole point is a deterministic "no key anywhere".
struct NoSecretStore;
impl SecretStore for NoSecretStore {
fn secret(&self, _name: &str) -> Option<SecretString> {
None
}
}
/// A store that answers one name — the GREEN arm, so the gate is proven to
/// DISCRIMINATE rather than to refuse everything (a gate that always refuses is as
/// blind as one that never does).
struct OneSecret(&'static str);
impl SecretStore for OneSecret {
fn secret(&self, name: &str) -> Option<SecretString> {
(name == self.0).then(|| SecretString::new("a-key-shaped-string"))
}
}
fn ctx_with(secret: Arc<dyn SecretStore>) -> (CollectorCtx, Arc<Mutex<Vec<String>>>) {
let dir = tempfile::tempdir().expect("tempdir").keep();
let mut ctx = CollectorCtx::in_memory(
TrafikverketDaemon::NAME,
dir,
TrafikverketDaemon::default_config(),
);
let (log, lines) = LogSink::recorder();
ctx.log = log;
ctx.secret = secret;
(ctx, lines)
}
// ─────────────────────────────────────────────────────────────────────────────
// RED 1 — the daemon path
// ─────────────────────────────────────────────────────────────────────────────
/// **An unconfigured daemon's `run()` RETURNS, on its own, having done nothing twice.**
///
/// Three independent things are asserted, because each one alone can be satisfied by a
/// daemon that is still wrong:
///
/// * `run()` **completes** while `shutdown` is still clear. A loop that ticks forever
/// fails here by timing out — and the timeout is the assertion, not an accident.
/// * it emitted **one** line, not a stream. "Never logs a nag" is a countable claim.
/// * the line and the phase **name the variable** (`TRAFIKVERKET_API_KEY`), because a
/// refusal an operator cannot act on is the state `SecretReq::undeclared` exists to
/// call out.
#[tokio::test]
async fn an_unconfigured_daemon_refuses_at_load_and_never_ticks_again() {
tokio::time::pause();
let daemon = Arc::new(TrafikverketDaemon::with_config(
true,
TrafikverketDaemon::default_config(),
));
let (ctx, lines) = ctx_with(Arc::new(NoSecretStore));
let shutdown = ctx.shutdown.clone();
// The daemon's OWN run future. If it refuses at load this resolves immediately; if
// it enters the loop it never resolves, and `timeout` is what says so.
let finished = tokio::time::timeout(Duration::from_secs(30), daemon.clone().run(ctx)).await;
assert!(
finished.is_ok(),
"run() never returned: the daemon ENTERED its loop with no key. A collector \
whose config is not filled in must be NOT STARTABLE — it refuses at load and \
returns, it does not tick, back off and retry until someone stops it.",
);
assert!(
!shutdown.is_stopped(),
"the daemon only stopped because shutdown was tripped — that is a stop, not a \
refusal. Nothing in this test asks it to stop.",
);
let lines = lines.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert_eq!(
lines.len(),
1,
"a refusal is ONE line, said once. {} lines were logged, which is the nag \
the owner forbade: {lines:#?}",
lines.len(),
);
assert!(
lines[0].contains("TRAFIKVERKET_API_KEY"),
"the refusal must NAME the missing variable an operator would set; got: {}",
lines[0],
);
match daemon.phase() {
Phase::Refused { reason } => assert!(
reason.contains("TRAFIKVERKET_API_KEY"),
"the terminal phase must name the variable too; got: {reason}",
),
other => panic!(
"the daemon must come to rest in a TERMINAL refusal, distinguishable from \
the retryable `Failed` a live outage produces. Got: {}",
other.label(),
),
}
}
/// **The GREEN arm: a configured daemon still starts.** Without this the gate above is
/// satisfied by a daemon that refuses unconditionally.
#[tokio::test]
async fn a_configured_daemon_is_not_refused_and_does_enter_its_loop() {
tokio::time::pause();
let daemon = Arc::new(TrafikverketDaemon::with_config(
true,
TrafikverketDaemon::default_config(),
));
let (ctx, lines) = ctx_with(Arc::new(OneSecret("TRAFIKVERKET_API_KEY")));
let shutdown = ctx.shutdown.clone();
let handle = tokio::spawn(daemon.clone().run(ctx));
// Let it get through at least one tick. The fetch seam is `Fetcher::noop()`, so the
// tick fails at the WIRE — which is the point: this daemon was allowed to try, and
// the unconfigured one above was not.
let mut ticked = false;
for _ in 0..200 {
tokio::time::sleep(Duration::from_millis(200)).await;
if !lines.lock().unwrap_or_else(|e| e.into_inner()).is_empty() {
ticked = true;
break;
}
}
shutdown.stop();
let _ = tokio::time::timeout(Duration::from_secs(30), handle).await;
let lines = lines.lock().unwrap_or_else(|e| e.into_inner()).clone();
assert!(
ticked,
"a daemon WITH its key never reached a tick — the gate is refusing everything, \
which proves nothing about a missing key",
);
assert!(
!lines.iter().any(|l| l.contains("TRAFIKVERKET_API_KEY")),
"a configured daemon must not report a missing key: {lines:#?}",
);
assert!(
!matches!(daemon.phase(), Phase::Refused { .. }),
"a daemon with its key must never reach the terminal refusal",
);
}