Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/korp-collectors

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",
    );
}