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

nordisk/korp-collectors

tests/provider_fixtures.rs

//! **Every collector proved against payloads its PROVIDER published — and no credential
//! anywhere.**
//!
//! The owner, 2026-09-06: he will never create an account whose key lands in a build or in
//! git. So a collector's parse, schema, business key, freshness and graph mapping are
//! exercised here against bytes taken from the provider's own public endpoints and
//! documentation, committed under `tests/fixtures/provider/` with their papers (url +
//! date fetched + digest) in `provenance.json`.
//!
//! # What these fixtures are worth that a synthetic one is not
//!
//! This crate has ~40 `SYNTHETIC_*` literals and they are honestly labelled. But a
//! synthetic fixture can only prove that the parser accepts what its author already
//! believed the provider sends. It cannot find the field the provider actually sends,
//! and it cannot be wrong in a way anybody notices.
//!
//! Two real findings from the first five fixtures, neither of which a synthetic payload
//! could have produced:
//!
//! * **ENTSO-E's `Acknowledgement_MarketDocument` parsed to `Ok(0)` points.** Fetched
//!   unauthenticated, it is a real IEC 62325 document saying `Reason code 999,
//!   Authentication failed.` — and `parse_entsoe_xml` read it as an empty result, which
//!   `EntsoeCollector::collect` then reports as "returned no TimeSeries points (window
//!   empty?)". A provider's *no* laundered into an explained empty; the exact shape
//!   RAGNARÖK exists to refuse.
//! * **Trafikverket's `ERROR` envelope is refused BY NAME**, which is the right
//!   behaviour, and it only became visible because a fixture asked the question.
//!
//! # No network here, ever
//!
//! These tests read committed bytes. The fetching lives in the `collector-fixtures`
//! binary and is run by hand when a provider changes its payload.

use korp_collectors::provenance::{Access, ProvenanceManifest, FIXTURE_DIR};

fn fixture_dir() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR)
}

fn read(file: &str) -> Vec<u8> {
    let path = fixture_dir().join(file);
    std::fs::read(&path).unwrap_or_else(|e| panic!("fixture {}: {e}", path.display()))
}

fn manifest() -> ProvenanceManifest {
    ProvenanceManifest::load(&fixture_dir()).expect("provenance.json parses")
}

// ─────────────────────────────────────────────────────────────────────────────
// The papers
// ─────────────────────────────────────────────────────────────────────────────

/// **Every fixture has papers, every paper has a fixture, and no fixture has been
/// edited since it was fetched.**
///
/// The digest half is the one that cannot be eyeballed: a fixture quietly "fixed" to
/// keep a test green is no longer the provider's payload, and nothing else in the tree
/// would say so.
#[test]
fn every_fixture_carries_its_provenance_and_still_matches_it() {
    let dir = fixture_dir();
    let m = manifest();

    let problems = m
        .problems(&dir, |b| blake3::hash(b).to_hex().to_string())
        .expect("read the fixture directory");
    assert!(
        problems.is_empty(),
        "the fixture directory and its provenance disagree:\n  {}",
        problems.join("\n  "),
    );

    // Anti-vacuity: an EMPTY directory has no problems either, and would sail through
    // every assertion in this file.
    assert!(
        m.fixtures.len() >= 4,
        "only {} provider fixture(s) — this sweep is not seeing the directory, so every \
         test under it is vacuous",
        m.fixtures.len(),
    );
}

/// **Every fixture names a collector that exists in the registry.** A fixture attributed
/// to a collector nobody ships is a test of nothing, and it is how a renamed collector
/// silently loses its only real coverage.
#[test]
fn every_fixture_names_a_registered_collector() {
    let names: Vec<String> = korp_collectors::registry()
        .iter()
        .map(|c| c.meta().name)
        .collect();
    let mut orphans = Vec::new();
    for f in manifest().fixtures {
        if !names.contains(&f.collector) {
            orphans.push(format!("{} → collector `{}`", f.file, f.collector));
        }
    }
    assert!(
        orphans.is_empty(),
        "fixture(s) attributed to collectors that are not in the registry:\n  {}",
        orphans.join("\n  "),
    );
}

/// **No fixture's URL carries a credential.** The fetcher has nowhere to put one, but
/// the manifest is a text file a human edits, and this is the guard that a pasted URL
/// with `?apikey=…` in it never reaches the tree.
#[test]
fn no_provenance_url_carries_a_credential() {
    const SMELLS: &[&str] = &[
        "apikey", "api_key", "api-key", "securitytoken", "token=", "secret", "password",
        "client_secret", "access_key", "subscription-key",
    ];
    let mut bad = Vec::new();
    for f in manifest().fixtures {
        let lower = f.url.to_ascii_lowercase();
        for s in SMELLS {
            if lower.contains(s) {
                bad.push(format!("{}: url contains `{s}`", f.file));
            }
        }
    }
    assert!(
        bad.is_empty(),
        "a provenance URL looks like it carries a credential — that is the one thing \
         these fixtures exist to keep out of git:\n  {}",
        bad.join("\n  "),
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// Parse — against the collectors' REAL parsers
// ─────────────────────────────────────────────────────────────────────────────

/// **THE SWEEP: every committed fixture, through the collector's own parser, behaving as
/// its access kind says it must.**
///
/// The named tests below go deeper on individual payloads. This one is the guard that no
/// fixture is ever committed and then never read — which is what a growing fixture
/// directory quietly becomes.
///
/// It runs through [`provenance::parse_rows`], the SAME dispatch the
/// `collector-fixtures` fetcher validates with. One writer: a fetcher with its own
/// reader would accept payloads these tests then read differently, and the fixture would
/// "work" only in the tool that made it.
#[test]
fn every_committed_fixture_parses_as_its_access_kind_declares() {
    let m = manifest();
    let mut data_rows = 0usize;
    let mut refusals = 0usize;
    let mut faults: Vec<String> = Vec::new();

    for f in &m.fixtures {
        let bytes = read(&f.file);
        let got = korp_collectors::provenance::parse_rows(&f.collector, &bytes);
        match (&got, f.access.expects_rows()) {
            (Ok(0), true) => faults.push(format!(
                "{}: parsed to ZERO rows. A data fixture that yields nothing proves the \
                 parser did not crash and nothing else — which is the definition of a \
                 guard that cannot fail.",
                f.file
            )),
            (Ok(n), true) => data_rows += n,
            (Err(e), false) => {
                refusals += 1;
                // The refusal must be SAYABLE. An `Err` with an empty message leaves an
                // operator exactly where a silent zero would.
                if e.trim().is_empty() {
                    faults.push(format!("{}: refused with an empty message", f.file));
                }
            }
            (Ok(n), false) => faults.push(format!(
                "{}: this is the provider's REFUSAL document and the parser read it as \
                 {n} row(s) instead of erroring — a provider's `no` reported as data.",
                f.file
            )),
            (Err(e), true) => faults.push(format!(
                "{}: a payload the provider served cannot be read by `{}`: {e}",
                f.file, f.collector
            )),
        }
    }

    assert!(faults.is_empty(), "\n  {}", faults.join("\n  "));
    // Anti-vacuity, both halves: enough fixtures were actually read, and they carried
    // real records rather than empty envelopes.
    assert!(
        m.fixtures.len() >= 10,
        "only {} fixtures swept — vacuous",
        m.fixtures.len()
    );
    assert!(
        data_rows >= 500,
        "the whole fixture set parsed to only {data_rows} rows; these are supposed to be \
         real payloads",
    );
    assert!(
        refusals >= 2,
        "only {refusals} refusal document(s) exercised — the 'a provider's no is not an \
         empty result' guard is the one that already caught a real bug",
    );
    eprintln!("{} fixtures · {data_rows} records parsed · {refusals} refusals refused", m.fixtures.len());
}

/// **Nord Pool: the public data-portal slice parses to real prices.**
///
/// The endpoint needs no credential — the collector's own doc comment says so, and the
/// `NORDPOOL_API_TOKEN` it declares is for the licensed API, a different endpoint. So
/// this is real Nord Pool data, in the modern `multiAreaEntries` shape, at the
/// 15-minute MTU the exchange moved to.
#[test]
fn nordpool_public_slice_parses_to_prices() {
    let bytes = read("nordpool-dayahead-se3.json");
    let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    let prices = korp_collectors::nordpool::parse_nordpool_json(&v).expect("parses");

    assert!(
        prices.len() >= 24,
        "a delivery day must yield at least 24 points, got {}",
        prices.len()
    );
    assert!(
        prices.iter().all(|p| p.area == "SE3"),
        "the fixture was requested for deliveryArea=SE3 only",
    );
    // The value, not just the shape. A parser that returned the right NUMBER of points
    // with every price at 0.0 would satisfy a length assertion and be dead.
    assert!(
        prices.iter().any(|p| p.price > 0.0),
        "every price parsed as 0.0 — the price field is not being read",
    );
    assert!(
        prices
            .iter()
            .all(|p| p.delivery_start.is_some() && p.delivery_end.is_some()),
        "a price with no delivery window cannot be keyed or deduplicated",
    );

    // The Arrow batch the sink actually receives, and its schema — proving the parse
    // reaches the wire shape, not just an intermediate struct.
    let batch = korp_collectors::nordpool::prices_batch(&prices).expect("batch");
    assert_eq!(batch.num_rows(), prices.len());
    let schema = korp_collectors::nordpool::nordpool_schema();
    assert_eq!(
        batch.schema().fields().len(),
        schema.fields().len(),
        "the batch's schema must be the collector's declared schema",
    );
    for f in schema.fields() {
        assert!(
            batch.schema().field_with_name(f.name()).is_ok(),
            "declared column `{}` is missing from the parsed batch",
            f.name(),
        );
    }
}

/// **OpenSky: the ANONYMOUS tier parses to real state vectors — with no
/// `OPENSKY_CLIENT_ID` anywhere.**
///
/// This is the fixture that most directly answers the owner: `opensky-flights` and
/// `opensky-europe-bbox` are two of the collectors refusing on the live server for want
/// of a key, and OpenSky publishes an unauthenticated tier that serves the same document
/// shape. The 18-element state-vector array order is the provider's own, and getting it
/// wrong is exactly the mistake a hand-written fixture makes and never notices.
#[test]
fn opensky_anonymous_states_parse_with_the_documented_field_order() {
    let bytes = read("opensky-states-nordic-bbox.json");
    let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json");

    assert!(v.get("time").and_then(|t| t.as_i64()).is_some(), "no `time`");
    let states = v.get("states").and_then(|s| s.as_array()).expect("`states`");
    assert!(!states.is_empty(), "the bbox returned no aircraft at all");

    let mut with_position = 0usize;
    for s in states {
        let a = s.as_array().expect("a state vector is an array");
        assert!(
            a.len() >= 17,
            "OpenSky documents 17-18 state-vector elements; this row has {}",
            a.len(),
        );
        // 0 icao24, 2 origin_country, 5 longitude, 6 latitude — the documented order.
        assert!(
            a[0].as_str().is_some_and(|s| !s.trim().is_empty()),
            "element 0 must be a non-empty icao24",
        );
        assert!(
            a[2].as_str().is_some(),
            "element 2 must be origin_country (a string)",
        );
        if let (Some(lon), Some(lat)) = (a[5].as_f64(), a[6].as_f64()) {
            // The bbox this fixture was fetched for. Getting lon/lat SWAPPED still gives
            // two floats and still passes a null check — this is the assertion that
            // catches it, and only a real payload can carry the truth to check against.
            assert!(
                (10.0..=20.0).contains(&lon),
                "element 5 should be LONGITUDE inside the requested 10..20 box, got {lon}",
            );
            assert!(
                (55.0..=60.0).contains(&lat),
                "element 6 should be LATITUDE inside the requested 55..60 box, got {lat}",
            );
            with_position += 1;
        }
    }
    assert!(
        with_position > 0,
        "not one state vector carried a position — the lon/lat columns are never exercised",
    );
}

/// **Riksdagen: the keyless document list parses.**
#[test]
fn riksdagen_dokumentlista_parses() {
    let bytes = read("riksdagen-dokumentlista.json");
    let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    let docs = v
        .pointer("/dokumentlista/dokument")
        .and_then(|d| d.as_array())
        .expect("/dokumentlista/dokument");
    assert!(!docs.is_empty(), "no documents in the list");
    assert!(
        docs.iter()
            .all(|d| d.get("id").and_then(|i| i.as_str()).is_some_and(|s| !s.is_empty())),
        "every document must carry a non-empty id — it is the business key",
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// The refusal documents — a provider's "no" must never read as an empty result
// ─────────────────────────────────────────────────────────────────────────────

/// **RED, and it was red when this fixture arrived: an ENTSO-E acknowledgement is not an
/// empty price series.**
///
/// Fetched unauthenticated from the Transparency Platform, this is a real
/// `Acknowledgement_MarketDocument` carrying `Reason` code `999`, `Authentication
/// failed.`. `parse_entsoe_xml` returned `Ok(vec![])` for it, and
/// `EntsoeCollector::collect` turns an empty vector into the `CollectStats.reason`
/// "ENTSO-E returned no TimeSeries points (window empty?)".
///
/// The live path was not hurt today — `HttpCore::send` errors on a 401 before the body
/// is parsed — but the parser is the wrong place to be lucky. ENTSO-E returns an
/// acknowledgement for *no matching data* too, and that one can arrive with a 2xx. Then
/// a refusal an operator could act on becomes a shrug about an empty window.
#[test]
fn an_entsoe_acknowledgement_is_refused_and_names_the_reason() {
    let bytes = read("entsoe-acknowledgement-auth-failed.xml");
    let xml = String::from_utf8_lossy(&bytes);

    // The fixture really is what it claims to be, before anything is concluded from it.
    assert!(
        xml.contains("Acknowledgement_MarketDocument"),
        "this fixture is not an acknowledgement document",
    );
    assert!(xml.contains("999"), "expected Reason code 999 in the fixture");

    let err = korp_collectors::entsoe::parse_entsoe_xml(&xml)
        .expect_err("an acknowledgement must be REFUSED, never read as zero points");
    let msg = format!("{err:#}");
    assert!(
        msg.contains("999"),
        "the refusal must carry the provider's own Reason CODE so an operator can look \
         it up; got: {msg}",
    );
    assert!(
        msg.to_lowercase().contains("authentication failed"),
        "the refusal must carry the provider's own Reason TEXT; got: {msg}",
    );
}

/// **A Trafikverket ERROR envelope is refused by name, not read as zero trains.**
///
/// Unlike ENTSO-E this was already right, and the fixture is what proves it stays right.
#[test]
fn a_trafikverket_error_envelope_is_refused_by_name() {
    let bytes = read("trafikverket-error-get-refused.json");
    let v: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    assert!(
        v.pointer("/RESPONSE/RESULT/0/ERROR").is_some(),
        "this fixture is not an ERROR envelope",
    );

    let c = korp_collectors::trafikverket::TrafikverketCollector::train_positions();
    let err = korp_collectors::rest::parse_records(&v, &c.records_pointer(), c.fields())
        .expect_err("an ERROR envelope must be refused, never read as zero trains");
    let msg = format!("{err:#}");
    assert!(
        msg.contains(&c.records_pointer()),
        "the refusal should name the pointer it could not find; got: {msg}",
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// business_key · freshness · graph_mapping, over the fixture-backed collectors
// ─────────────────────────────────────────────────────────────────────────────

/// **Each fixture's collector declares a business key, a freshness and a graph mapping
/// whose columns its schema actually carries.**
///
/// The registry-wide sweeps for these already exist
/// (`business_keys_name_real_columns`, `graph_mappings_agree`). What this adds is the
/// tie to a REAL payload: those sweeps compare a declaration against a declaration, and
/// two declarations can agree with each other while both being wrong about what the
/// provider sends.
#[test]
fn every_fixture_collector_declares_a_usable_key_freshness_and_mapping() {
    let m = manifest();
    let mut checked = 0usize;
    let mut faults: Vec<String> = Vec::new();

    for f in &m.fixtures {
        let Some(c) = korp_collectors::by_name(&f.collector) else {
            continue; // named-collector guard is its own test
        };
        let cfg = korp_collectors::CollectorCfg::new("https://example.invalid/", "t");
        let Ok(schema) = c.schema(&cfg) else {
            faults.push(format!("{}: schema() needs config", f.collector));
            continue;
        };
        checked += 1;
        let have: Vec<&str> = schema.fields().iter().map(|x| x.name().as_str()).collect();

        let key = c.business_key_for(&cfg);
        let missing = key.missing_from(&schema);
        if !missing.is_empty() {
            faults.push(format!(
                "{}: business key {:?} names {missing:?}, schema has {have:?}",
                f.collector, key.columns
            ));
        }

        // Freshness must be DECLARED. `Freshness::default()` would make every
        // incremental run a full re-read that nobody notices.
        let fresh = c.freshness();
        if fresh.key().is_empty() {
            faults.push(format!("{}: freshness has no key", f.collector));
        }

        if let Some(map) = c.graph_mapping() {
            for v in &map.vertices {
                let derived = map.derived_ids.iter().any(|d| d.column == v.id_column);
                if !derived && !have.contains(&v.id_column.as_str()) {
                    faults.push(format!(
                        "{}: graph mapping vertex `{}` keys on column `{}`, which the \
                         schema does not carry and no derived id produces (schema: {have:?})",
                        f.collector, v.label, v.id_column,
                    ));
                }
            }
            if map.limitation.trim().is_empty() {
                faults.push(format!(
                    "{}: graph mapping states no limitation — the ones that go unwritten \
                     are the ones an operator meets as a wrong number",
                    f.collector,
                ));
            }
        }
    }

    assert!(
        checked >= 3,
        "only {checked} fixture-backed collectors were checked — vacuous",
    );
    assert!(faults.is_empty(), "\n  {}", faults.join("\n  "));
}

/// **A fixture declared as an error document must not be counted as coverage of the
/// happy path.** Bookkeeping, and it is the honest kind: `entsoe` and
/// `trafikverket-trainpos` have a REFUSAL fixture and no data fixture, because neither
/// provider serves one byte of data without an account. Saying that out loud here is
/// what stops the fixture count from being read as "these collectors are covered".
#[test]
fn the_roster_of_what_these_fixtures_do_and_do_not_prove() {
    let m = manifest();
    let data: Vec<&str> = m
        .fixtures
        .iter()
        .filter(|f| f.access.expects_rows())
        .map(|f| f.collector.as_str())
        .collect();
    let refusal_only: Vec<&str> = m
        .fixtures
        .iter()
        .filter(|f| f.access == Access::ProviderErrorDocument)
        .map(|f| f.collector.as_str())
        .filter(|c| !data.contains(c))
        .collect();

    assert!(
        !data.is_empty(),
        "not one fixture carries provider DATA — the parse tests prove only that \
         refusals are refused",
    );
    // Not an assertion about which collectors these are: that list will grow. The
    // assertion is that the two kinds are distinguishable at all, which is what lets the
    // roster report `fixtures: refusal-document only` instead of a bare tick.
    for c in &refusal_only {
        assert!(
            !data.contains(c),
            "{c} is in both lists, which makes the distinction meaningless",
        );
    }
    eprintln!(
        "provider fixtures — DATA for {data:?}; REFUSAL DOCUMENT ONLY for {refusal_only:?}"
    );
}