data-gov-catalog 0.4.0

Async client for the data.gov Catalog API (DCAT-US 3 search)
Documentation
//! Unit tests for [`data_gov_catalog::CatalogClient`] against a mock HTTP server.
//!
//! These tests never hit the network. Fixtures live in `tests/fixtures/` and
//! are trimmed captures of real responses.

use data_gov_catalog::{CatalogClient, CatalogError, Configuration, SearchParams};
use serde_json::json;
use std::sync::Arc;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn fixture(name: &str) -> String {
    let path = format!("tests/fixtures/{name}");
    std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("fixture {path} missing: {e}"))
}

fn client_for(server: &MockServer) -> CatalogClient {
    CatalogClient::new(Arc::new(Configuration {
        base_path: server.uri(),
        user_agent: Some("data-gov-catalog-tests/1.0".to_string()),
        client: reqwest::Client::new(),
    }))
}

#[tokio::test]
async fn search_builds_query_string_and_parses_results() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .and(query_param("q", "climate"))
        .and(query_param("per_page", "2"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(fixture("search.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let page = client
        .search(SearchParams::new().q("climate").per_page(2))
        .await
        .expect("search succeeds");

    assert!(!page.results.is_empty());
    let hit = &page.results[0];
    assert!(hit.title.is_some());
    assert!(hit.dcat.is_some(), "hit should carry a nested dcat record");
    assert!(
        page.after.is_some(),
        "non-empty page returns an `after` cursor"
    );
}

#[tokio::test]
async fn search_sends_repeated_keyword_params() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .and(query_param("keyword", "climate"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_raw(fixture("search_filtered.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let _ = client
        .search(SearchParams::new().keyword("climate").keyword("noaa"))
        .await
        .expect("search succeeds");
}

#[tokio::test]
async fn search_with_org_slug_passes_filter() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .and(query_param("org_slug", "nasa"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_raw(fixture("search_filtered.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    client
        .search(SearchParams::new().q("climate").org_slug("nasa"))
        .await
        .expect("filtered search succeeds");
}

#[tokio::test]
async fn dataset_by_slug_returns_first_hit() {
    let server = MockServer::start().await;
    // dataset_by_slug uses q=<slug> (the API doesn't honor slug= today)
    // and matches the slug client-side.
    Mock::given(method("GET"))
        .and(path("/search"))
        .and(query_param("q", "crime-data-from-2020-to-present"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_raw(fixture("search_by_slug.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let hit = client
        .dataset_by_slug("crime-data-from-2020-to-present")
        .await
        .expect("slug lookup succeeds")
        .expect("slug matches a dataset");

    assert!(hit.title.is_some());
    assert!(hit.dcat.is_some());
}

#[tokio::test]
async fn dataset_by_slug_returns_none_when_empty() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "results": [],
            "sort": "relevance"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let result = client.dataset_by_slug("nonexistent").await.unwrap();
    assert!(result.is_none());
}

/// The live Catalog API doesn't honor a `slug=` query parameter — it
/// returns the top relevance hit regardless. We work around it with
/// `q=<slug>` plus a client-side slug-equality check. Make sure that
/// check actually fires when the API returns a non-matching hit.
#[tokio::test]
async fn dataset_by_slug_returns_none_when_top_hit_has_a_different_slug() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "results": [{
                "slug": "crime-data-from-2020-to-present",
                "title": "Crime Data from 2020 to 2024"
            }],
            "sort": "relevance"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let result = client.dataset_by_slug("nasa-thesaurus").await.unwrap();
    assert!(
        result.is_none(),
        "expected None when API returns a hit with a different slug, got Some(_)"
    );
}

/// When the response includes the requested slug among multiple hits
/// (for example, a `q=<slug>` query that fans out to similarly-named
/// datasets), `dataset_by_slug` should pick the exact match rather than
/// the top-ranked or first hit.
#[tokio::test]
async fn dataset_by_slug_picks_exact_match_among_multiple_hits() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "results": [
                { "slug": "ev-population-history", "title": "Decoy 1" },
                { "slug": "electric-vehicle-population-data", "title": "Real" },
                { "slug": "ev-population-by-county",   "title": "Decoy 2" }
            ],
            "sort": "relevance"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let hit = client
        .dataset_by_slug("electric-vehicle-population-data")
        .await
        .unwrap()
        .expect("exact match should be found");
    assert_eq!(
        hit.slug.as_deref(),
        Some("electric-vehicle-population-data")
    );
}

#[tokio::test]
async fn organizations_parses_envelope() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/organizations"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_raw(fixture("organizations.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let orgs = client.organizations().await.expect("orgs succeed");
    assert!(orgs.total > 0);
    assert!(!orgs.organizations.is_empty());
    assert!(orgs.organizations[0].slug.is_some());
}

#[tokio::test]
async fn keywords_passes_size_and_min_count() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/keywords"))
        .and(query_param("size", "10"))
        .and(query_param("min_count", "5"))
        .respond_with(
            ResponseTemplate::new(200).set_body_raw(fixture("keywords.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let kw = client
        .keywords(Some(10), Some(5))
        .await
        .expect("keywords succeed");
    assert_eq!(kw.size, 10);
    assert!(!kw.keywords.is_empty());
    assert!(kw.keywords[0].count > 0);
}

/// The Catalog API returns `null` for empty repeated DCAT-US 3 fields
/// (observed: `references`, `rights`, also seen elsewhere on `keyword`,
/// `theme`, etc.). With plain `#[serde(default)]` only the *missing*
/// case is covered; an explicit `null` value blows up with
/// `invalid type: null, expected a sequence`. Make sure every `Vec<T>`
/// field tolerates `null` and treats it as empty.
#[tokio::test]
async fn search_tolerates_null_for_repeated_fields() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "results": [{
                "slug": "with-nulls",
                "title": "Has nulls everywhere a Vec is expected",
                "keyword": null,
                "theme": null,
                "distribution_titles": null,
                "dcat": {
                    "@type": "dcat:Dataset",
                    "title": "Inner",
                    "keyword": null,
                    "theme": null,
                    "distribution": null,
                    "language": null,
                    "references": null
                }
            }],
            "sort": "relevance"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let page = client
        .search(SearchParams::new().q("anything"))
        .await
        .expect("null Vec fields should not panic on parse");
    assert_eq!(page.results.len(), 1);
    let hit = &page.results[0];
    assert_eq!(hit.slug.as_deref(), Some("with-nulls"));
    assert!(hit.keyword.is_empty());
    assert!(hit.theme.is_empty());
    assert!(hit.distribution_titles.is_empty());
    let dcat = hit.dcat.as_ref().expect("dcat present");
    assert!(dcat.keyword.is_empty());
    assert!(dcat.theme.is_empty());
    assert!(dcat.distribution.is_empty());
    assert!(dcat.language.is_empty());
    assert!(dcat.references.is_empty());
}

#[tokio::test]
async fn locations_search_returns_suggestions() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/locations/search"))
        .and(query_param("q", "Colorado"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_raw(fixture("locations_search.json"), "application/json"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let locs = client
        .locations_search("Colorado", Some(3))
        .await
        .expect("locations succeed");
    assert!(!locs.locations.is_empty());
}

#[tokio::test]
async fn harvest_record_transformed_parses_dataset() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path(
            "/harvest_record/c1d2faad-b413-41a8-934d-119f7c50d8ab/transformed",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            fixture("harvest_record_transformed.json"),
            "application/json",
        ))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let ds = client
        .harvest_record_transformed("c1d2faad-b413-41a8-934d-119f7c50d8ab")
        .await
        .expect("transformed record parses");
    assert!(ds.title.is_some());
    assert!(!ds.distribution.is_empty());
}

#[tokio::test]
async fn api_error_status_is_preserved() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/search"))
        .respond_with(
            ResponseTemplate::new(503).set_body_string("{\"message\":\"Service Unavailable\"}"),
        )
        .mount(&server)
        .await;

    let client = client_for(&server);
    let err = client.search(SearchParams::new().q("x")).await.unwrap_err();
    match err {
        CatalogError::ApiError { status, .. } => assert_eq!(status, 503),
        other => panic!("expected ApiError, got {other:?}"),
    }
}

#[tokio::test]
async fn parse_error_surfaces_bad_json() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/api/organizations"))
        .respond_with(ResponseTemplate::new(200).set_body_string("not json"))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let err = client.organizations().await.unwrap_err();
    assert!(matches!(err, CatalogError::ParseError(_)));
}