Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/nexum-venue-test/goldens/reference-header.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"version": 1,
"venue": "nexum-venue-test/reference",
"goldens": [
{
Expand Down
68 changes: 63 additions & 5 deletions crates/nexum-venue-test/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//! `IntentBody` wire bytes, and the check that holds a codec to them.
//!
//! A vector file is the venue's codec contract in portable form: JSON,
//! bytes as lowercase hex, one entry per published body. A non-Rust
//! a leading format version (unknown versions fail closed), bytes as
//! lowercase hex, one entry per published body, never zero. A non-Rust
//! adapter author proves byte-exactness by decoding and re-encoding
//! each `round-trip` vector in their own language and comparing bytes;
//! a Rust author runs [`CodecVectors::assert_conforms`] against the
Expand All @@ -15,17 +16,21 @@ use std::path::Path;
use nexum_venue_sdk::{BodyError, IntentBody};
use serde::{Deserialize, Serialize};

use crate::fixture::{self, FixtureError, hex_bytes};
use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes};
use crate::report::{ConformanceReport, Violation, settle};

/// A published set of codec vectors for one venue body schema.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CodecVectors {
/// File-format discriminator; an unknown version fails to parse.
pub version: FormatVersion,
/// Name of the body schema the vectors bind, e.g.
/// `acme-dex/order-body`. Informational: the check never reads it.
pub schema: String,
/// The vectors, in publication order.
/// The vectors, in publication order. Never empty in a parsed
/// file: an empty set would conform vacuously.
#[serde(deserialize_with = "fixture::non_empty")]
pub vectors: Vec<CodecVector>,
}

Expand Down Expand Up @@ -80,9 +85,11 @@ impl std::fmt::Display for Expectation {
}

impl CodecVectors {
/// An empty vector set for `schema`.
/// An empty vector set for `schema`. Push at least one vector
/// before publishing: a parsed file is never empty.
pub fn new(schema: impl Into<String>) -> Self {
Self {
version: FormatVersion,
schema: schema.into(),
vectors: Vec::new(),
}
Expand Down Expand Up @@ -158,9 +165,16 @@ impl CodecVectors {
///
/// A `round-trip` vector must decode and re-encode to the exact
/// published bytes; a failure vector must produce the matching
/// [`BodyError`] case (the free-text detail is not compared).
/// [`BodyError`] case (the free-text detail is not compared). An
/// empty set is itself a violation: it would conform vacuously.
pub fn check<B: IntentBody>(&self) -> Result<(), ConformanceReport> {
let mut violations = Vec::new();
if self.vectors.is_empty() {
violations.push(Violation {
vector: "<set>".to_owned(),
detail: "published vector set is empty".to_owned(),
});
}
for vector in &self.vectors {
if let Err(detail) = vector.check::<B>() {
violations.push(Violation {
Expand Down Expand Up @@ -317,6 +331,7 @@ mod tests {
let json = vectors.to_json();
assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors);
// The wire spellings are the contract for non-Rust readers.
assert!(json.contains("\"version\": 1"));
assert!(json.contains("\"round-trip\""));
assert!(json.contains("\"unknown-version\""));
assert!(json.contains("\"notes\": \"first published body\""));
Expand All @@ -343,4 +358,47 @@ mod tests {
Err(FixtureError::Read { .. }),
));
}

#[test]
fn unknown_format_version_fails_closed() {
let json = published()
.to_json()
.replace("\"version\": 1", "\"version\": 2");
let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else {
panic!("version 2 must not parse");
};
assert!(detail.contains("unknown fixture format version 2"));
}

#[test]
fn missing_format_version_fails() {
let json = published().to_json().replace(" \"version\": 1,\n", "");
assert!(matches!(
CodecVectors::from_json(&json),
Err(FixtureError::Format(_)),
));
}

#[test]
fn empty_vector_set_fails_the_check() {
let report = CodecVectors::new("test/body").check::<Body>().unwrap_err();
assert_eq!(report.violations.len(), 1, "violations: {report}");
assert_eq!(report.violations[0].vector, "<set>");
assert!(report.violations[0].detail.contains("empty"));
}

#[test]
#[should_panic(expected = "codec does not conform")]
fn assert_conforms_rejects_an_empty_set() {
CodecVectors::new("test/body").assert_conforms::<Body>();
}

#[test]
fn empty_vector_set_fails_to_parse() {
let json = CodecVectors::new("test/body").to_json();
let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else {
panic!("an empty set must not parse");
};
assert!(detail.contains("never empty"));
}
}
53 changes: 48 additions & 5 deletions crates/nexum-venue-test/src/fixture.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
//! The shared fixture-file plumbing: JSON on disk, byte fields as
//! lowercase hex, and the typed [`FixtureError`] both file formats
//! load and save through.
//! The shared fixture-file plumbing: JSON on disk, a leading
//! [`FormatVersion`] (unknown versions fail closed), byte fields as
//! lowercase hex, non-empty entry lists, and the typed
//! [`FixtureError`] both file formats load and save through.

use std::path::Path;

use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::de::{DeserializeOwned, Error as _};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// The one published fixture file-format version.
const FORMAT_VERSION: u32 = 1;

/// Fixture file-format discriminator: serializes as the current
/// version, refuses any other on parse (fail-closed), so a reader
/// never guesses at a future layout.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct FormatVersion;

impl Serialize for FormatVersion {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u32(FORMAT_VERSION)
}
}

impl<'de> Deserialize<'de> for FormatVersion {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let version = u32::deserialize(deserializer)?;
if version == FORMAT_VERSION {
Ok(Self)
} else {
Err(D::Error::custom(format!(
"unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}",
)))
}
}
}

/// Deserialize a fixture's entry list, refusing an empty one: an empty
/// set would conform vacuously.
pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
let entries = Vec::<T>::deserialize(deserializer)?;
if entries.is_empty() {
return Err(D::Error::custom("a published fixture set is never empty"));
}
Ok(entries)
}

/// Why a fixture file failed to load or save. The JSON case carries
/// serde's rendered detail rather than the error value so the type
Expand Down
69 changes: 63 additions & 6 deletions crates/nexum-venue-test/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
//!
//! A golden file pairs wire bodies with the intent header a conforming
//! adapter derives from them, spelled in the golden mirror types below
//! (JSON, kebab-case case names matching the WIT, bytes as lowercase
//! hex). The mirrors exist because wit-bindgen types carry no serde;
//! (JSON, a leading format version that fails closed on an unknown tag,
//! kebab-case case names matching the WIT, bytes as lowercase hex,
//! never zero goldens). The mirrors exist because wit-bindgen types
//! carry no serde;
//! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a
//! macro-built adapter whose bindgen mints its own header type bridges
//! with a field-for-field `From` impl on its crate boundary, the same
Expand All @@ -18,17 +20,21 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount};
use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement};
use serde::{Deserialize, Serialize};

use crate::fixture::{self, FixtureError, hex_bytes};
use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes};
use crate::report::{ConformanceReport, Violation, settle};

/// A published set of header goldens for one venue.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeaderGoldens {
/// File-format discriminator; an unknown version fails to parse.
pub version: FormatVersion,
/// The venue the goldens bind. Informational: the check never
/// reads it.
pub venue: String,
/// The goldens, in publication order.
/// The goldens, in publication order. Never empty in a parsed
/// file: an empty set would conform vacuously.
#[serde(deserialize_with = "fixture::non_empty")]
pub goldens: Vec<HeaderGolden>,
}

Expand Down Expand Up @@ -157,9 +163,11 @@ impl From<AuthScheme> for GoldenAuthScheme {
}

impl HeaderGoldens {
/// An empty golden set for `venue`.
/// An empty golden set for `venue`. Record at least one golden
/// before publishing: a parsed file is never empty.
pub fn new(venue: impl Into<String>) -> Self {
Self {
version: FormatVersion,
venue: venue.into(),
goldens: Vec::new(),
}
Expand Down Expand Up @@ -211,7 +219,8 @@ impl HeaderGoldens {
/// collecting all violations rather than stopping at the first.
///
/// `derive` is the adapter's derivation; a trait-based adapter
/// passes `MyAdapter::derive_header` directly.
/// passes `MyAdapter::derive_header` directly. An empty set is
/// itself a violation: it would conform vacuously.
pub fn check<H, E>(
&self,
mut derive: impl FnMut(Vec<u8>) -> Result<H, E>,
Expand All @@ -221,6 +230,12 @@ impl HeaderGoldens {
E: fmt::Debug,
{
let mut violations = Vec::new();
if self.goldens.is_empty() {
violations.push(Violation {
vector: "<set>".to_owned(),
detail: "published golden set is empty".to_owned(),
});
}
for golden in &self.goldens {
match derive(golden.body.clone()) {
Ok(header) => {
Expand Down Expand Up @@ -288,6 +303,7 @@ mod tests {
fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() {
let golden: GoldenHeader = wire_header().into();
let goldens = HeaderGoldens {
version: FormatVersion,
venue: "acme".to_owned(),
goldens: vec![HeaderGolden {
name: "kitchen-sink".to_owned(),
Expand All @@ -299,6 +315,7 @@ mod tests {
let json = goldens.to_json();
assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens);
// The wire spellings are the contract for non-Rust readers.
assert!(json.contains("\"version\": 1"));
assert!(json.contains("\"native\""));
assert!(json.contains("\"erc20\""));
assert!(json.contains("\"token\""));
Expand Down Expand Up @@ -359,4 +376,44 @@ mod tests {
.unwrap();
goldens.assert_conforms(|_| Err::<IntentHeader, _>(VenueError::Timeout));
}

#[test]
fn unknown_format_version_fails_closed() {
let mut goldens = HeaderGoldens::new("acme");
goldens
.record("a", vec![1], |_| Ok::<_, VenueError>(wire_header()))
.unwrap();
let json = goldens
.to_json()
.replace("\"version\": 1", "\"version\": 7");
let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else {
panic!("version 7 must not parse");
};
assert!(detail.contains("unknown fixture format version 7"));
}

#[test]
fn empty_golden_set_fails_the_check() {
let report = HeaderGoldens::new("acme")
.check(|_| Ok::<_, VenueError>(wire_header()))
.unwrap_err();
assert_eq!(report.violations.len(), 1, "violations: {report}");
assert_eq!(report.violations[0].vector, "<set>");
assert!(report.violations[0].detail.contains("empty"));
}

#[test]
#[should_panic(expected = "derive-header does not conform")]
fn assert_conforms_rejects_an_empty_set() {
HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header()));
}

#[test]
fn empty_golden_set_fails_to_parse() {
let json = HeaderGoldens::new("acme").to_json();
let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else {
panic!("an empty set must not parse");
};
assert!(detail.contains("never empty"));
}
}
2 changes: 1 addition & 1 deletion crates/nexum-venue-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pub mod report;
pub mod transport;

pub use codec::{CodecVector, CodecVectors, Expectation};
pub use fixture::FixtureError;
pub use fixture::{FixtureError, FormatVersion};
pub use header::{
GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden,
HeaderGoldens,
Expand Down
1 change: 1 addition & 0 deletions crates/nexum-venue-test/vectors/reference-body.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"version": 1,
"schema": "nexum-venue-test/reference-body",
"vectors": [
{
Expand Down
6 changes: 4 additions & 2 deletions modules/examples/echo-venue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ fn minimal_be(value: u64) -> Vec<u8> {
mod conformance {
use super::*;
use nexum_venue_test::{
GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement,
HeaderGolden, HeaderGoldens,
FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader,
GoldenSettlement, HeaderGolden, HeaderGoldens,
};

fn asset_to_golden(asset: Asset) -> GoldenAsset {
Expand Down Expand Up @@ -177,6 +177,7 @@ mod conformance {
notes: Some("amount is the minimal big-endian body length".to_owned()),
};
let goldens = HeaderGoldens {
version: FormatVersion,
venue: "echo-venue".to_owned(),
goldens: vec![golden],
};
Expand All @@ -188,6 +189,7 @@ mod conformance {
// A non-minimal amount is the classic uint bug; the golden must
// reject it, proving the check has teeth on echo-venue.
let goldens = HeaderGoldens {
version: FormatVersion,
venue: "echo-venue".to_owned(),
goldens: vec![HeaderGolden {
name: "four-byte-body".to_owned(),
Expand Down
Loading