exochain-api 0.2.0-beta

EXOCHAIN constitutional trust fabric — P2P networking and external API types
Documentation
// Copyright 2026 Exochain Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at:
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

//! Integration tests for the exo-api public API surface.
//!
//! Covers:
//! - `ApiRequest` / `ApiResponse` serde roundtrips
//! - `canonical_request_hash` determinism and collision resistance
//! - `p2p::PeerRegistry` operations
//! - `types::ApiVersion` / `Cursor`
//!
//! Async GraphQL resolver tests live in `crates/exo-gateway/src/graphql.rs`.
#![allow(clippy::expect_used)]

use exo_api::schema::{ApiRequest, ApiResponse, canonical_request_hash};
use exo_core::{Did, Hash256, Timestamp};
use uuid::Uuid;

fn did(id: &str) -> Did {
    Did::new(&format!("did:exo:{id}")).expect("valid did")
}

// ---------------------------------------------------------------------------
// ApiRequest serde
// ---------------------------------------------------------------------------

#[test]
fn api_request_all_variants_serde() {
    let reqs: Vec<ApiRequest> = vec![
        ApiRequest::CreateTransaction {
            actor: did("alice"),
            scope: "governance".into(),
        },
        ApiRequest::TransitionState {
            tx_id: Uuid::nil(),
            target_state: "DELIBERATION".into(),
            actor: did("alice"),
        },
        ApiRequest::QueryTransaction { tx_id: Uuid::nil() },
        ApiRequest::ResolveIdentity { did: did("alice") },
        ApiRequest::RegisterIdentity {
            did: did("alice"),
            public_key_hash: Hash256::ZERO,
        },
        ApiRequest::Deliberate {
            proposal_hash: Hash256::ZERO,
            actor: did("alice"),
        },
        ApiRequest::Vote {
            proposal_id: Uuid::nil(),
            approve: true,
            actor: did("alice"),
        },
        ApiRequest::Vote {
            proposal_id: Uuid::nil(),
            approve: false,
            actor: did("bob"),
        },
        ApiRequest::Challenge {
            target_id: Uuid::nil(),
            grounds: "procedural violation".into(),
            actor: did("alice"),
        },
    ];
    for r in &reqs {
        let json = serde_json::to_string(r).expect("serialize");
        assert!(!json.is_empty());
        let _back: ApiRequest = serde_json::from_str(&json).expect("deserialize");
    }
}

// ---------------------------------------------------------------------------
// ApiResponse serde
// ---------------------------------------------------------------------------

#[test]
fn api_response_all_variants_serde() {
    let resps: Vec<ApiResponse> = vec![
        ApiResponse::Success {
            correlation_id: Uuid::nil(),
            timestamp: Timestamp::ZERO,
        },
        ApiResponse::Error {
            code: 400,
            message: "bad request".into(),
        },
        ApiResponse::TransactionState {
            tx_id: Uuid::nil(),
            state: "CREATED".into(),
        },
        ApiResponse::Identity {
            did: did("alice"),
            verified: true,
        },
        ApiResponse::Receipt {
            hash: Hash256::ZERO,
            timestamp: Timestamp::ZERO,
        },
    ];
    for r in &resps {
        let json = serde_json::to_string(r).expect("serialize");
        assert!(!json.is_empty());
        let _back: ApiResponse = serde_json::from_str(&json).expect("deserialize");
    }
}

// ---------------------------------------------------------------------------
// canonical_request_hash
// ---------------------------------------------------------------------------

#[test]
fn canonical_hash_deterministic() {
    let r = ApiRequest::CreateTransaction {
        actor: did("alice"),
        scope: "s".into(),
    };
    assert_eq!(
        canonical_request_hash(&r).expect("hash"),
        canonical_request_hash(&r).expect("hash")
    );
}

#[test]
fn canonical_hash_scope_differs() {
    let r1 = ApiRequest::CreateTransaction {
        actor: did("alice"),
        scope: "s1".into(),
    };
    let r2 = ApiRequest::CreateTransaction {
        actor: did("alice"),
        scope: "s2".into(),
    };
    assert_ne!(
        canonical_request_hash(&r1).expect("hash"),
        canonical_request_hash(&r2).expect("hash")
    );
}

#[test]
fn canonical_hash_actor_differs() {
    let r1 = ApiRequest::CreateTransaction {
        actor: did("alice"),
        scope: "s".into(),
    };
    let r2 = ApiRequest::CreateTransaction {
        actor: did("bob"),
        scope: "s".into(),
    };
    assert_ne!(
        canonical_request_hash(&r1).expect("hash"),
        canonical_request_hash(&r2).expect("hash")
    );
}

#[test]
fn canonical_hash_no_collisions_across_variants() {
    let reqs: Vec<ApiRequest> = vec![
        ApiRequest::CreateTransaction {
            actor: did("a"),
            scope: "s".into(),
        },
        ApiRequest::TransitionState {
            tx_id: Uuid::nil(),
            target_state: "t".into(),
            actor: did("a"),
        },
        ApiRequest::QueryTransaction { tx_id: Uuid::nil() },
        ApiRequest::ResolveIdentity { did: did("a") },
        ApiRequest::RegisterIdentity {
            did: did("a"),
            public_key_hash: Hash256::ZERO,
        },
        ApiRequest::Deliberate {
            proposal_hash: Hash256::ZERO,
            actor: did("a"),
        },
        ApiRequest::Vote {
            proposal_id: Uuid::nil(),
            approve: true,
            actor: did("a"),
        },
        ApiRequest::Challenge {
            target_id: Uuid::nil(),
            grounds: "g".into(),
            actor: did("a"),
        },
    ];
    let hashes: Vec<_> = reqs
        .iter()
        .map(|req| canonical_request_hash(req).expect("hash"))
        .collect();
    for (i, h1) in hashes.iter().enumerate() {
        for (j, h2) in hashes.iter().enumerate() {
            if i != j {
                assert_ne!(h1, h2, "hash collision between variants {i} and {j}");
            }
        }
    }
}

#[test]
fn canonical_hash_is_hash256() {
    let r = ApiRequest::QueryTransaction { tx_id: Uuid::nil() };
    let h = canonical_request_hash(&r).expect("hash");
    // Hash must be non-zero for non-trivial input.
    assert_ne!(h, Hash256::ZERO);
}

// ---------------------------------------------------------------------------
// types module
// ---------------------------------------------------------------------------

#[test]
fn api_version_default_is_v1() {
    use exo_api::types::ApiVersion;
    assert_eq!(ApiVersion::default().0, "v1");
}

#[test]
fn cursor_serde_roundtrip() {
    use exo_api::types::Cursor;
    let c = Cursor("page-token-abc".into());
    let j = serde_json::to_string(&c).expect("serialize");
    let c2: Cursor = serde_json::from_str(&j).expect("deserialize");
    assert_eq!(c, c2);
}