commit e9d0d8af43950e156e6015663ad84c6a32601e30
parent 5c671d4b6206032be6a4c930930a7f72ef9e8edc
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Jul 2026 04:56:12 +0000
transport: harden transport coverage gates
- add mesh, mesh agent, transport, Reticulum, and publish protocol edge coverage
- cover Nostr outbox recovered-state, target filtering, and outcome classification paths
- separate Nostr outbox fallible result propagation for precise release-gate coverage
- validate focused transport tests, coverage gates, contract validation, and release preflight
Diffstat:
7 files changed, 1226 insertions(+), 6 deletions(-)
diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs
@@ -55,9 +55,135 @@ fn payload_transmission_is_forbidden_in_mvp_frames() {
#[test]
fn custom_scope_has_explicit_namespace() {
let scope = RadrootsMeshScope::custom(" Farm-North ").expect("custom scope");
+ assert_eq!(scope.label(), "farm-north");
let frame = RadrootsMeshFrame::new(scope, Vec::new());
let encoded = encode_mesh_frame_cbor(&frame).expect("encode custom scope");
let decoded = decode_mesh_frame_cbor(&encoded).expect("decode custom scope");
assert_eq!(decoded.scope.cbor_label(), "custom:farm-north");
}
+
+#[test]
+fn mesh_scope_and_payload_policy_parsers_reject_unknown_values() {
+ assert_eq!(
+ RadrootsMeshScope::custom(" ").expect_err("empty custom scope"),
+ RadrootsMeshError::EmptyCustomScope
+ );
+ assert_eq!(
+ RadrootsMeshScope::parse("unscoped").expect_err("unknown scope"),
+ RadrootsMeshError::UnknownScope
+ );
+ assert_eq!(RadrootsMeshScope::Local.label(), "local");
+ assert_eq!(RadrootsMeshScope::Community.label(), "community");
+ assert_eq!(
+ RadrootsMeshPayloadPolicy::parse("inline-payloads").expect_err("unknown policy"),
+ RadrootsMeshError::UnknownPayloadPolicy
+ );
+ assert_eq!(
+ RadrootsMeshPayloadPolicy::PayloadTransmissionForbidden.label(),
+ "payload-forbidden"
+ );
+}
+
+#[test]
+fn mesh_errors_have_stable_display_strings() {
+ let cases = [
+ (
+ RadrootsMeshError::EmptyCustomScope,
+ "mesh custom scope is empty",
+ ),
+ (
+ RadrootsMeshError::PayloadTransmissionForbidden,
+ "mesh payload transmission is forbidden",
+ ),
+ (RadrootsMeshError::InvalidCbor, "mesh frame CBOR is invalid"),
+ (
+ RadrootsMeshError::InvalidUtf8,
+ "mesh frame text is invalid UTF-8",
+ ),
+ (RadrootsMeshError::UnknownScope, "mesh scope is unknown"),
+ (
+ RadrootsMeshError::UnknownPayloadPolicy,
+ "mesh payload policy is unknown",
+ ),
+ (
+ RadrootsMeshError::UnsupportedVersion,
+ "mesh frame version is unsupported",
+ ),
+ ];
+
+ for (error, message) in cases {
+ assert_eq!(error.to_string(), message);
+ }
+}
+
+#[test]
+fn cbor_codec_covers_extended_integer_widths() {
+ let frame = RadrootsMeshFrame::new(
+ RadrootsMeshScope::Community,
+ vec![RadrootsMeshEventHead {
+ event_id: "event-with-wide-created-at".to_string(),
+ author: "author-with-wide-created-at".to_string(),
+ kind: 24,
+ created_at: u64::MAX,
+ }],
+ );
+ let encoded = encode_mesh_frame_cbor(&frame).expect("encode wide frame");
+ let decoded = decode_mesh_frame_cbor(&encoded).expect("decode wide frame");
+
+ assert_eq!(decoded, frame);
+}
+
+#[test]
+fn decoder_rejects_malformed_cbor_shapes() {
+ let encoded = encode_mesh_frame_cbor(&RadrootsMeshFrame::new(
+ RadrootsMeshScope::Local,
+ Vec::new(),
+ ))
+ .expect("encode default");
+ let mut unsupported_version = encoded.clone();
+ unsupported_version[2] = 2;
+ assert_eq!(
+ decode_mesh_frame_cbor(&unsupported_version).expect_err("unsupported version"),
+ RadrootsMeshError::UnsupportedVersion
+ );
+ let mut frame = RadrootsMeshFrame::new(RadrootsMeshScope::Local, Vec::new());
+ frame.version = 2;
+ assert_eq!(
+ frame.validate().expect_err("unsupported frame version"),
+ RadrootsMeshError::UnsupportedVersion
+ );
+
+ let cases = [
+ vec![0x80],
+ vec![0xbc],
+ vec![0xa4],
+ vec![0xa5, 0x02],
+ vec![0xa5, 0x01, 0x01, 0x02, 0x61, 0xff],
+ vec![
+ 0xa5, 0x01, 0x01, 0x02, 0x63, b'b', b'a', b'd', 0x03, 0x71, b'p', b'a', b'y', b'l',
+ b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e', b'n', 0x04,
+ 0x80, 0x05, 0xf6,
+ ],
+ vec![
+ 0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x67, b'u', b'n',
+ b'k', b'n', b'o', b'w', b'n', 0x04, 0x80, 0x05, 0xf6,
+ ],
+ vec![
+ 0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x71, b'p', b'a',
+ b'y', b'l', b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e',
+ b'n', 0x04, 0x80, 0x05, 0x00,
+ ],
+ ];
+
+ for malformed in cases {
+ assert!(decode_mesh_frame_cbor(&malformed).is_err());
+ }
+
+ let mut trailing = encoded;
+ trailing.push(0x00);
+ assert_eq!(
+ decode_mesh_frame_cbor(&trailing).expect_err("trailing byte"),
+ RadrootsMeshError::InvalidCbor
+ );
+}
diff --git a/crates/mesh_agent_proto/tests/schema.rs b/crates/mesh_agent_proto/tests/schema.rs
@@ -34,3 +34,113 @@ fn schema_validator_rejects_missing_required_surface() {
RadrootsMeshAgentProtoError::MissingNamespace
);
}
+
+#[test]
+fn schema_validator_reports_each_missing_required_surface() {
+ let valid = RADROOTS_MESH_AGENT_SCHEMA;
+ let cases = [
+ (
+ valid.replace(RADROOTS_MESH_AGENT_SCHEMA_ID, "0x0000000000000000"),
+ RadrootsMeshAgentProtoError::MissingSchemaId,
+ ),
+ (
+ valid.replace(RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, "radroots::missing"),
+ RadrootsMeshAgentProtoError::MissingNamespace,
+ ),
+ (
+ valid.replace("struct MeshAgentRequest", "struct MissingMeshAgentRequest"),
+ RadrootsMeshAgentProtoError::MissingRequest,
+ ),
+ (
+ valid.replace("frameCbor @2 :Data;", "frameBytes @2 :Data;"),
+ RadrootsMeshAgentProtoError::MissingRequest,
+ ),
+ (
+ valid.replace("enum MeshAgentAction", "enum MissingMeshAgentAction"),
+ RadrootsMeshAgentProtoError::MissingAction,
+ ),
+ (
+ valid.replace("validateFrame @0;", "validateSomethingElse @0;"),
+ RadrootsMeshAgentProtoError::MissingAction,
+ ),
+ (
+ valid.replace("stageDelivery @1;", "stageSomethingElse @1;"),
+ RadrootsMeshAgentProtoError::MissingAction,
+ ),
+ (
+ valid.replace("observeEventHead @2;", "observeSomethingElse @2;"),
+ RadrootsMeshAgentProtoError::MissingAction,
+ ),
+ (
+ valid.replace(
+ "struct MeshAgentResponse",
+ "struct MissingMeshAgentResponse",
+ ),
+ RadrootsMeshAgentProtoError::MissingResponse,
+ ),
+ (
+ valid.replace(
+ "enum MeshAgentResponseStatus",
+ "enum MissingMeshAgentResponseStatus",
+ ),
+ RadrootsMeshAgentProtoError::MissingResponse,
+ ),
+ (
+ valid.replace("struct MeshAgentReceipt", "struct MissingMeshAgentReceipt"),
+ RadrootsMeshAgentProtoError::MissingReceipt,
+ ),
+ (
+ valid.replace(
+ "acceptedEventHeads @1 :List(Text);",
+ "acceptedEventIds @1 :List(Text);",
+ ),
+ RadrootsMeshAgentProtoError::MissingReceipt,
+ ),
+ (
+ valid.replace("struct MeshAgentError", "struct MissingMeshAgentError"),
+ RadrootsMeshAgentProtoError::MissingError,
+ ),
+ ];
+
+ for (schema, error) in cases {
+ assert_eq!(validate_schema_text(schema.as_str()), Err(error));
+ }
+}
+
+#[test]
+fn mesh_agent_proto_errors_have_stable_display_strings() {
+ let cases = [
+ (
+ RadrootsMeshAgentProtoError::MissingSchemaId,
+ "mesh agent schema id is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingNamespace,
+ "mesh agent schema namespace is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingRequest,
+ "mesh agent request schema is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingAction,
+ "mesh agent action schema is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingResponse,
+ "mesh agent response schema is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingReceipt,
+ "mesh agent receipt schema is missing",
+ ),
+ (
+ RadrootsMeshAgentProtoError::MissingError,
+ "mesh agent error schema is missing",
+ ),
+ ];
+
+ for (error, message) in cases {
+ assert_eq!(error.to_string(), message);
+ }
+}
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -48,10 +48,18 @@ fn transport_kind_parser_round_trips_canonical_labels_and_custom_values() {
RadrootsTransportKind::Local
);
assert_eq!(
+ RadrootsTransportKind::Local.canonical_label(),
+ "local".to_owned()
+ );
+ assert_eq!(
RadrootsTransportKind::parse("fieldbus").expect("custom kind"),
RadrootsTransportKind::Custom("fieldbus".to_owned())
);
assert_eq!(
+ RadrootsTransportKind::Custom("fieldbus".to_owned()).canonical_label(),
+ "fieldbus".to_owned()
+ );
+ assert_eq!(
RadrootsTransportKind::parse("bad kind").expect_err("invalid kind"),
RadrootsTransportError::InvalidTransportKind
);
@@ -144,3 +152,154 @@ fn fingerprint_parser_rejects_non_sha256_hex() {
RadrootsTransportError::InvalidTargetUri
);
}
+
+#[test]
+fn transport_errors_have_stable_display_strings() {
+ let cases = [
+ (
+ RadrootsTransportError::EmptyTransportKind,
+ "transport kind is empty",
+ ),
+ (
+ RadrootsTransportError::InvalidTransportKind,
+ "transport kind is invalid",
+ ),
+ (
+ RadrootsTransportError::EmptyTargetUri,
+ "transport target URI is empty",
+ ),
+ (
+ RadrootsTransportError::InvalidTargetUri,
+ "transport target URI is invalid",
+ ),
+ (
+ RadrootsTransportError::EmptyTargetSet,
+ "transport target set is empty",
+ ),
+ (
+ RadrootsTransportError::DuplicateTargetFingerprint,
+ "transport target set contains duplicate fingerprints",
+ ),
+ (
+ RadrootsTransportError::InvalidTargetFingerprint,
+ "transport target fingerprint is invalid",
+ ),
+ (
+ RadrootsTransportError::InvalidSatisfactionPolicy,
+ "transport satisfaction policy is invalid",
+ ),
+ ];
+
+ for (error, message) in cases {
+ assert_eq!(error.to_string(), message);
+ }
+}
+
+#[test]
+fn transport_kind_and_target_parsers_cover_negative_edges() {
+ assert_eq!(
+ RadrootsTransportKind::custom(" ").expect_err("empty kind"),
+ RadrootsTransportError::EmptyTransportKind
+ );
+ for invalid in ["bad kind", "bad:kind", "bad/kind", "bad\nkind"] {
+ assert_eq!(
+ RadrootsTransportKind::custom(invalid).expect_err("invalid kind"),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ }
+ assert_eq!(
+ RadrootsTransportKind::custom(" FieldBus ").expect("custom kind"),
+ RadrootsTransportKind::Custom("fieldbus".to_owned())
+ );
+
+ let no_scheme =
+ RadrootsTransportTargetUri::parse(" transport-target ").expect("schemeless target uri");
+ assert_eq!(no_scheme.as_str(), "transport-target");
+ assert_eq!(no_scheme.to_string(), "transport-target");
+ let opaque = RadrootsTransportTargetUri::parse("RNS:PeerA").expect("opaque uri");
+ assert_eq!(opaque.as_str(), "rns:PeerA");
+ let authority = RadrootsTransportTargetUri::parse("MESH://Node.Example/path?q=1#frag")
+ .expect("authority uri");
+ assert_eq!(authority.as_str(), "mesh://node.example/path?q=1#frag");
+
+ assert_eq!(
+ RadrootsTransportTargetUri::parse(" ").expect_err("empty uri"),
+ RadrootsTransportError::EmptyTargetUri
+ );
+ for invalid in [
+ "bad target",
+ ":target",
+ "1bad:target",
+ "bad_scheme://target",
+ "bad\target",
+ ] {
+ assert_eq!(
+ RadrootsTransportTargetUri::parse(invalid).expect_err("invalid uri"),
+ RadrootsTransportError::InvalidTargetUri
+ );
+ }
+}
+
+#[test]
+fn target_fingerprints_and_sets_cover_accessors_and_validation() {
+ let target = RadrootsTransportTarget::new(RadrootsTransportKind::Mesh, "mesh://node.example")
+ .expect("mesh target");
+ let parsed =
+ RadrootsTransportTargetFingerprint::parse(target.fingerprint.as_str().to_ascii_uppercase())
+ .expect("uppercase fingerprint parses");
+ assert_eq!(parsed.as_str(), target.fingerprint.as_str());
+ assert_eq!(parsed.to_string(), target.fingerprint.as_str());
+ assert_eq!(
+ RadrootsTransportTargetFingerprint::parse("g".repeat(64)).expect_err("non-hex fingerprint"),
+ RadrootsTransportError::InvalidTargetFingerprint
+ );
+
+ assert_eq!(
+ RadrootsTransportTargetSet::new(Vec::new()).expect_err("empty target set"),
+ RadrootsTransportError::EmptyTargetSet
+ );
+ let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set");
+ assert_eq!(target_set.len(), 1);
+ assert!(!target_set.is_empty());
+ assert_eq!(target_set.targets().len(), 1);
+}
+
+#[test]
+fn satisfaction_and_target_status_cover_all_contract_states() {
+ assert_eq!(
+ RadrootsTransportSatisfactionPolicy::AllTargets
+ .required_target_count(3)
+ .expect("all targets"),
+ 3
+ );
+ assert_eq!(
+ RadrootsTransportSatisfactionPolicy::AnyTarget
+ .required_target_count(3)
+ .expect("any target"),
+ 1
+ );
+ assert_eq!(
+ RadrootsTransportSatisfactionPolicy::AtLeast(4)
+ .required_target_count(3)
+ .expect_err("at least too high"),
+ RadrootsTransportError::InvalidSatisfactionPolicy
+ );
+
+ let statuses = [
+ RadrootsTransportDeliveryTargetStatus::Pending,
+ RadrootsTransportDeliveryTargetStatus::Accepted,
+ RadrootsTransportDeliveryTargetStatus::Deferred,
+ RadrootsTransportDeliveryTargetStatus::Rejected,
+ RadrootsTransportDeliveryTargetStatus::Failed,
+ RadrootsTransportDeliveryTargetStatus::Unavailable,
+ ];
+ assert!(!statuses[0].is_terminal());
+ assert!(statuses[1..].iter().all(|status| status.is_terminal()));
+ assert!(RadrootsTransportDeliveryTargetStatus::Accepted.counts_as_satisfied());
+ assert!(
+ statuses
+ .iter()
+ .filter(|status| **status != RadrootsTransportDeliveryTargetStatus::Accepted)
+ .all(|status| !status.counts_as_satisfied())
+ );
+}
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -107,11 +107,12 @@ where
policy.relay_url_policy,
)?;
let target_strings = targets.relay_strings();
+ let satisfaction_policy = satisfaction_policy_for_required_accept_count(
+ publishable.required_accept_count,
+ publishable.relays.len(),
+ )?;
let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms)
- .with_satisfaction_policy(satisfaction_policy_for_required_accept_count(
- publishable.required_accept_count,
- publishable.relays.len(),
- )?);
+ .with_satisfaction_policy(satisfaction_policy);
let publish = match publish_signed_event(adapter, request).await {
Ok(receipt) => receipt,
Err(RadrootsRelayTransportError::Transport(message)) => adapter_transport_failure_receipt(
@@ -315,12 +316,13 @@ async fn ingest_publish_observation(
message: Option<&str>,
observed_at_ms: i64,
) -> Result<(), RadrootsRelayTransportError> {
- let mut observation = RadrootsTransportObservation::new(
+ let observation = RadrootsTransportObservation::new(
RadrootsTransportKind::Nostr,
relay_url,
RadrootsTransportObservationType::NostrPublishAck,
observed_at_ms,
- )?;
+ );
+ let mut observation = observation?;
if let Some(message) = message {
observation = observation.with_redacted_message(message);
}
@@ -342,3 +344,49 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv
sig: signed_event.sig.clone(),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{adapter_transport_failure_receipt, satisfaction_policy_for_required_accept_count};
+ use radroots_transport::RadrootsTransportSatisfactionPolicy;
+
+ #[test]
+ fn internal_outbox_publish_helpers_cover_policy_edges() {
+ assert_eq!(
+ satisfaction_policy_for_required_accept_count(2, 2).expect("all targets"),
+ RadrootsTransportSatisfactionPolicy::AllTargets
+ );
+ assert_eq!(
+ satisfaction_policy_for_required_accept_count(1, 3).expect("at least one"),
+ RadrootsTransportSatisfactionPolicy::AtLeast(1)
+ );
+ assert!(
+ satisfaction_policy_for_required_accept_count(
+ usize::from(u16::MAX) + 1,
+ usize::from(u16::MAX) + 2,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn adapter_transport_failure_receipts_preserve_each_target() {
+ let receipt = adapter_transport_failure_receipt(
+ "event-1".to_owned(),
+ vec![
+ "wss://relay-a.example".to_owned(),
+ "wss://relay-b.example".to_owned(),
+ ],
+ 2,
+ "offline".to_owned(),
+ );
+
+ assert_eq!(receipt.event_id, "event-1");
+ assert_eq!(receipt.attempted_count, 2);
+ assert_eq!(receipt.retryable_count, 2);
+ assert_eq!(receipt.terminal_count, 0);
+ assert_eq!(receipt.quorum, 2);
+ assert!(!receipt.quorum_met);
+ assert!(receipt.relays.iter().all(|relay| relay.attempted));
+ }
+}
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -69,6 +69,36 @@ impl RadrootsRelayPublishAdapter for NostrJsonFailurePublishAdapter {
}
}
+struct UnknownRelayReceiptPublishAdapter;
+
+impl RadrootsRelayPublishAdapter for UnknownRelayReceiptPublishAdapter {
+ fn publish<'a>(
+ &'a self,
+ request: RadrootsRelayPublishRequest,
+ ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>>
+ {
+ Box::pin(async move {
+ let relay = request
+ .targets
+ .relays()
+ .first()
+ .expect("fixture target")
+ .as_str()
+ .to_owned();
+ Ok(vec![
+ RadrootsRelayPublishRelayReceipt::attempted(
+ relay,
+ RadrootsRelayOutcome::accepted(),
+ ),
+ RadrootsRelayPublishRelayReceipt::attempted(
+ RELAY_TERTIARY_WSS,
+ RadrootsRelayOutcome::accepted(),
+ ),
+ ])
+ })
+ }
+}
+
fn fixture_keys() -> RadrootsNostrKeys {
let secret_key =
RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
@@ -471,6 +501,43 @@ fn outcome_prefix_classification_covers_required_kinds() {
let outcome = RadrootsRelayOutcome::classify(message);
assert_eq!(outcome.kind, kind);
}
+ let labels = [
+ (RadrootsRelayOutcomeKind::Accepted, "accepted"),
+ (
+ RadrootsRelayOutcomeKind::DuplicateAccepted,
+ "duplicate_accepted",
+ ),
+ (RadrootsRelayOutcomeKind::Blocked, "blocked"),
+ (RadrootsRelayOutcomeKind::RateLimited, "rate_limited"),
+ (RadrootsRelayOutcomeKind::Invalid, "invalid"),
+ (RadrootsRelayOutcomeKind::PowRequired, "pow_required"),
+ (RadrootsRelayOutcomeKind::Restricted, "restricted"),
+ (RadrootsRelayOutcomeKind::AuthRequired, "auth_required"),
+ (RadrootsRelayOutcomeKind::Muted, "muted"),
+ (RadrootsRelayOutcomeKind::Unsupported, "unsupported"),
+ (
+ RadrootsRelayOutcomeKind::PaymentRequired,
+ "payment_required",
+ ),
+ (RadrootsRelayOutcomeKind::Error, "error"),
+ (RadrootsRelayOutcomeKind::Timeout, "timeout"),
+ (
+ RadrootsRelayOutcomeKind::ConnectionFailed,
+ "connection_failed",
+ ),
+ (
+ RadrootsRelayOutcomeKind::RelayUrlRejected,
+ "relay_url_rejected",
+ ),
+ (
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted,
+ "skipped_already_accepted",
+ ),
+ (RadrootsRelayOutcomeKind::Unknown, "unknown"),
+ ];
+ for (kind, label) in labels {
+ assert_eq!(kind.as_str(), label);
+ }
assert!(RadrootsRelayOutcome::classify("duplicate: already have it").counts_toward_quorum());
assert!(
@@ -486,6 +553,42 @@ fn outcome_prefix_classification_covers_required_kinds() {
.status,
radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted
);
+ assert_eq!(
+ RadrootsRelayOutcome::timeout("timeout: no OK")
+ .to_transport_outcome()
+ .status,
+ radroots_transport::RadrootsTransportDeliveryTargetStatus::Failed
+ );
+ assert_eq!(
+ RadrootsRelayOutcome::classify("restricted: denied")
+ .to_transport_outcome()
+ .status,
+ radroots_transport::RadrootsTransportDeliveryTargetStatus::Rejected
+ );
+ assert_eq!(
+ RadrootsRelayOutcome::connection_failed("offline")
+ .kind
+ .as_str(),
+ "connection_failed"
+ );
+ assert_eq!(
+ RadrootsRelayOutcome::relay_url_rejected("unsafe")
+ .kind
+ .as_str(),
+ "relay_url_rejected"
+ );
+}
+
+#[test]
+fn relay_transport_error_wraps_transport_contract_errors() {
+ let error = RadrootsRelayTransportError::from(
+ radroots_transport::RadrootsTransportError::EmptyTargetSet,
+ );
+
+ assert_eq!(
+ error.to_string(),
+ "Transport contract error: transport target set is empty"
+ );
}
#[tokio::test]
@@ -1146,6 +1249,31 @@ async fn fetch_subscription_mode_and_store_errors_are_reported() {
}
#[tokio::test]
+async fn fetch_ingest_rejects_invalid_observation_endpoint() {
+ let signed = signed_post("invalid observation endpoint");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let adapter = RadrootsMockRelayFetchAdapter::new(vec![RadrootsRelayFetchItem::Event {
+ relay_url: " ".to_owned(),
+ raw_json: signed.raw_json,
+ observed_at_ms: 1_300,
+ }]);
+
+ let error =
+ fetch_and_ingest_relay_events(&adapter, &store, post_relay_fetch_request(1_300, 10))
+ .await
+ .expect_err("invalid observation endpoint");
+
+ assert!(matches!(
+ error,
+ RadrootsRelayTransportError::EventStore(
+ radroots_event_store::RadrootsEventStoreError::Transport(
+ radroots_transport::RadrootsTransportError::EmptyTargetUri
+ )
+ )
+ ));
+}
+
+#[tokio::test]
async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() {
let signed = signed_post("hello");
let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
@@ -1469,6 +1597,142 @@ async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_
}
#[tokio::test]
+async fn outbox_publish_ignores_unknown_adapter_receipts() {
+ let signed = signed_post("unknown receipt");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at,
+ signed.tags.clone(),
+ signed.content.clone(),
+ signed.pubkey.as_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_targets_outbox_operation_input(
+ draft,
+ vec![RELAY_PRIMARY_WSS.to_owned()],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "sign-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ outbox.recover_expired_claims(2_001).await.expect("recover");
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100)
+ .await
+ .expect("claim")
+ .expect("publish claim");
+
+ let published = publish_claimed_outbox_event(
+ &outbox,
+ &store,
+ &UnknownRelayReceiptPublishAdapter,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(published.publish.attempted_count, 2);
+ assert!(published.publish.quorum_met);
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::Published);
+ let observations = store
+ .observations_for_event(signed.id.as_str())
+ .await
+ .expect("observations");
+ assert_eq!(observations.len(), 1);
+ assert_eq!(observations[0].endpoint_uri.as_str(), RELAY_PRIMARY_WSS);
+}
+
+#[tokio::test]
+async fn outbox_publish_skips_non_nostr_targets() {
+ let signed = signed_post("mixed target");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at,
+ signed.tags.clone(),
+ signed.content.clone(),
+ signed.pubkey.as_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(RadrootsOutboxOperationInput::new(
+ "publish_post",
+ draft,
+ RadrootsOutboxDeliveryPlanInput::new(
+ "transport.mixed.local",
+ 1,
+ RadrootsTransportSatisfactionPolicy::AllTargets,
+ vec![
+ nostr_target(RELAY_PRIMARY_WSS),
+ RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, "reticulum:a")
+ .expect("reticulum target"),
+ ],
+ ),
+ 1_000,
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "sign-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ outbox.recover_expired_claims(2_001).await.expect("recover");
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100)
+ .await
+ .expect("claim")
+ .expect("publish claim");
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+
+ let published = publish_claimed_outbox_event(
+ &outbox,
+ &store,
+ &adapter,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(published.publish.attempted_count, 1);
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::Signed);
+ let targets = outbox
+ .delivery_targets(receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ assert!(targets.iter().any(|target| {
+ target.transport_kind == RadrootsTransportKind::Reticulum
+ && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
+ }));
+}
+
+#[tokio::test]
async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_with_failure_diagnostics()
{
let signed = signed_post("quorum");
@@ -1660,6 +1924,87 @@ async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() {
}
#[tokio::test]
+async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() {
+ let signed = signed_post("republish terminal excluded");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at,
+ signed.tags.clone(),
+ signed.content.clone(),
+ signed.pubkey.as_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_targets_outbox_operation_input(
+ draft,
+ vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "sign-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ outbox.recover_expired_claims(2_001).await.expect("recover");
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100)
+ .await
+ .expect("claim")
+ .expect("publish claim");
+ let initial_targets = publish_claim.delivery_targets.clone();
+ outbox
+ .mark_delivery_target_accepted(
+ publish_claim.outbox_event_id,
+ publish_claim.claim_token.as_str(),
+ initial_targets[0].delivery_target_id,
+ 2_150,
+ )
+ .await
+ .expect("primary accepted");
+ outbox
+ .mark_delivery_target_failed_terminal(
+ publish_claim.outbox_event_id,
+ publish_claim.claim_token.as_str(),
+ initial_targets[1].delivery_target_id,
+ "terminal",
+ 2_151,
+ )
+ .await
+ .expect("secondary terminal");
+ let adapter = RadrootsMockRelayPublishAdapter::new()
+ .with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted())
+ .with_outcome(RELAY_SECONDARY_WSS, RadrootsRelayOutcome::accepted());
+
+ let published = publish_claimed_outbox_event(
+ &outbox,
+ &store,
+ &adapter,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true),
+ 2_200,
+ )
+ .await
+ .expect("publish");
+
+ assert_eq!(published.publish.attempted_count, 1);
+ assert_eq!(published.publish.accepted_count, 1);
+ assert_eq!(published.publish.quorum, 1);
+ assert!(published.publish.quorum_met);
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::FailedTerminal);
+}
+
+#[tokio::test]
async fn outbox_publish_requires_claimed_signed_event() {
let signed = signed_post("missing signature");
let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
@@ -1765,6 +2110,65 @@ async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_fil
}
#[tokio::test]
+async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish() {
+ let signed = signed_post("invalid relay target");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at,
+ signed.tags,
+ signed.content,
+ signed.pubkey.as_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_targets_outbox_operation_input(
+ draft,
+ vec!["ws://127.0.0.1:9999".to_owned()],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "sign-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+ complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ outbox.recover_expired_claims(2_001).await.expect("recover");
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100)
+ .await
+ .expect("claim")
+ .expect("publish claim");
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+
+ let error = publish_claimed_outbox_event(
+ &outbox,
+ &store,
+ &adapter,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect_err("invalid relay target");
+
+ assert!(matches!(
+ error,
+ RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }
+ ));
+ assert!(adapter.captured_raw_events().is_empty());
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::Publishing);
+}
+
+#[tokio::test]
async fn smoke_relay_fetch_processes_one_thousand_event_receipts() {
let store = RadrootsEventStore::open_memory().await.expect("store");
let mut items = Vec::new();
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -722,4 +722,285 @@ mod tests {
serde_json::from_str(encoded.as_str()).expect("decode");
assert_eq!(decoded, request);
}
+
+ #[test]
+ fn protocol_errors_have_stable_display_strings() {
+ let cases = [
+ (
+ TransportPublishProtocolError::InvalidHexField {
+ field: "id",
+ expected_len: 64,
+ },
+ "id must be 64 lowercase hex characters",
+ ),
+ (
+ TransportPublishProtocolError::InvalidKind(70_000),
+ "event kind 70000 exceeds transport publish range",
+ ),
+ (
+ TransportPublishProtocolError::EmptyTag { index: 2 },
+ "tag 2 must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::EmptyIdempotencyKey,
+ "idempotency key must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::EmptyTransportKind { index: 1 },
+ "transport target 1 kind must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::EmptyEndpointUri { index: 3 },
+ "transport target 3 endpoint_uri must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::TargetLimitExceeded { max: 1, actual: 2 },
+ "transport target count 2 exceeds limit 1",
+ ),
+ (
+ TransportPublishProtocolError::EmptyTargetSet,
+ "transport publish target set must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::InvalidQuorum,
+ "delivery quorum must be greater than zero",
+ ),
+ (
+ TransportPublishProtocolError::EmptyPrincipalId,
+ "principal id must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::EmptyJobId,
+ "job id must not be empty",
+ ),
+ ];
+
+ for (error, message) in cases {
+ assert_eq!(error.to_string(), message);
+ }
+ }
+
+ #[test]
+ fn signed_event_validation_rejects_each_invalid_event_shape() {
+ let mut invalid_id = event();
+ invalid_id.id = "A".repeat(64);
+ assert!(matches!(
+ invalid_id.validate(),
+ Err(TransportPublishProtocolError::InvalidHexField { field: "id", .. })
+ ));
+
+ let mut invalid_pubkey = event();
+ invalid_pubkey.pubkey = "g".repeat(64);
+ assert!(matches!(
+ invalid_pubkey.validate(),
+ Err(TransportPublishProtocolError::InvalidHexField {
+ field: "pubkey",
+ ..
+ })
+ ));
+
+ let mut invalid_sig = event();
+ invalid_sig.sig = "2".repeat(127);
+ assert!(matches!(
+ invalid_sig.validate(),
+ Err(TransportPublishProtocolError::InvalidHexField { field: "sig", .. })
+ ));
+
+ let mut invalid_kind = event();
+ invalid_kind.kind = u16::MAX as u32 + 1;
+ assert_eq!(
+ invalid_kind.validate(),
+ Err(TransportPublishProtocolError::InvalidKind(
+ u16::MAX as u32 + 1
+ ))
+ );
+
+ let mut empty_tag = event();
+ empty_tag.tags.push(Vec::new());
+ assert_eq!(
+ empty_tag.validate(),
+ Err(TransportPublishProtocolError::EmptyTag { index: 1 })
+ );
+ }
+
+ #[test]
+ fn target_and_delivery_policy_validation_cover_all_modes() {
+ assert_eq!(
+ TransportPublishPreviewBehavior::default(),
+ TransportPublishPreviewBehavior::RejectDeliveryAttempts
+ );
+ let explicit = TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example"),
+ TransportPublishTarget::reticulum_preview(
+ "reticulum:preview",
+ TransportPublishPreviewBehavior::DeferDeliveryPlans,
+ ),
+ ]);
+ let nostr = TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault,
+ vec!["wss://relay.example".to_owned()],
+ );
+ assert_eq!(explicit.request_target_count(), 2);
+ assert_eq!(nostr.request_target_count(), 1);
+
+ let mut empty_targets = TransportPublishEventRequest {
+ event: event(),
+ target_policy: TransportPublishTargetPolicy::explicit_targets(Vec::new()),
+ delivery_policy: TransportPublishDeliveryPolicy::Any,
+ idempotency_key: None,
+ timeout_ms: None,
+ };
+ assert_eq!(
+ empty_targets.validate(10),
+ Err(TransportPublishProtocolError::EmptyTargetSet)
+ );
+
+ empty_targets.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
+ transport_kind: " ".to_owned(),
+ endpoint_uri: "wss://relay.example".to_owned(),
+ preview_behavior: None,
+ }]);
+ assert_eq!(
+ empty_targets.validate(10),
+ Err(TransportPublishProtocolError::EmptyTransportKind { index: 0 })
+ );
+
+ empty_targets.target_policy = TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::ExplicitOnly,
+ vec![" ".to_owned()],
+ );
+ assert_eq!(
+ empty_targets.validate(10),
+ Err(TransportPublishProtocolError::EmptyEndpointUri { index: 0 })
+ );
+
+ empty_targets.delivery_policy = TransportPublishDeliveryPolicy::Quorum { quorum: 0 };
+ empty_targets.target_policy = nostr;
+ assert_eq!(
+ empty_targets.validate(10),
+ Err(TransportPublishProtocolError::InvalidQuorum)
+ );
+
+ assert_eq!(
+ TransportPublishDeliveryPolicy::Any.required_target_count(0),
+ 0
+ );
+ assert_eq!(
+ TransportPublishDeliveryPolicy::Any.required_target_count(3),
+ 1
+ );
+ assert_eq!(
+ TransportPublishDeliveryPolicy::All.required_target_count(3),
+ 3
+ );
+ assert_eq!(
+ TransportPublishDeliveryPolicy::Quorum { quorum: 2 }.required_target_count(3),
+ 2
+ );
+ }
+
+ #[test]
+ fn outcome_kinds_cover_negative_classification_edges() {
+ let satisfied = [
+ TransportPublishOutcomeKind::Accepted,
+ TransportPublishOutcomeKind::DuplicateAccepted,
+ TransportPublishOutcomeKind::SkippedAlreadyAccepted,
+ ];
+ let retryable = [
+ TransportPublishOutcomeKind::RateLimited,
+ TransportPublishOutcomeKind::PowRequired,
+ TransportPublishOutcomeKind::AuthRequired,
+ TransportPublishOutcomeKind::Error,
+ TransportPublishOutcomeKind::Timeout,
+ TransportPublishOutcomeKind::ConnectionFailed,
+ TransportPublishOutcomeKind::Unknown,
+ ];
+ let terminal = [
+ TransportPublishOutcomeKind::Blocked,
+ TransportPublishOutcomeKind::Invalid,
+ TransportPublishOutcomeKind::Restricted,
+ TransportPublishOutcomeKind::Muted,
+ TransportPublishOutcomeKind::Unsupported,
+ TransportPublishOutcomeKind::PaymentRequired,
+ TransportPublishOutcomeKind::TargetRejected,
+ TransportPublishOutcomeKind::Deferred,
+ TransportPublishOutcomeKind::Unavailable,
+ ];
+
+ for kind in satisfied {
+ assert!(kind.counts_toward_satisfaction());
+ assert!(!kind.is_retryable());
+ assert!(!kind.is_terminal_failure());
+ }
+ for kind in retryable {
+ assert!(!kind.counts_toward_satisfaction());
+ assert!(kind.is_retryable());
+ assert!(!kind.is_terminal_failure());
+ }
+ for kind in terminal {
+ assert!(!kind.counts_toward_satisfaction());
+ assert!(!kind.is_retryable());
+ assert!(kind.is_terminal_failure());
+ }
+ }
+
+ #[test]
+ fn job_validation_rejects_empty_job_invalid_kind_and_invalid_delivery_policy() {
+ let base = TransportPublishJobView {
+ job_id: "job-1".to_owned(),
+ status: TransportPublishJobStatus::Accepted,
+ terminal: false,
+ delivery_satisfied: false,
+ event_id: "0".repeat(64),
+ pubkey: "1".repeat(64),
+ event_kind: 30_402,
+ target_policy: TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::DaemonDefaultOnly,
+ Vec::new(),
+ ),
+ delivery_policy: TransportPublishDeliveryPolicy::Any,
+ target_count: 0,
+ acknowledged_count: 0,
+ retryable_count: 0,
+ terminal_count: 0,
+ requested_at_ms: 1,
+ completed_at_ms: None,
+ last_error: None,
+ targets: Vec::new(),
+ };
+
+ let mut empty_job = base.clone();
+ empty_job.job_id = " ".to_owned();
+ assert_eq!(
+ empty_job.validate(),
+ Err(TransportPublishProtocolError::EmptyJobId)
+ );
+
+ let mut invalid_kind = base.clone();
+ invalid_kind.event_kind = u16::MAX as u32 + 1;
+ assert_eq!(
+ invalid_kind.validate(),
+ Err(TransportPublishProtocolError::InvalidKind(
+ u16::MAX as u32 + 1
+ ))
+ );
+
+ let mut invalid_pubkey = base.clone();
+ invalid_pubkey.pubkey = "x".repeat(64);
+ assert!(matches!(
+ invalid_pubkey.validate(),
+ Err(TransportPublishProtocolError::InvalidHexField {
+ field: "pubkey",
+ ..
+ })
+ ));
+
+ let mut invalid_delivery = base;
+ invalid_delivery.delivery_policy = TransportPublishDeliveryPolicy::Quorum { quorum: 0 };
+ assert_eq!(
+ invalid_delivery.validate(),
+ Err(TransportPublishProtocolError::InvalidQuorum)
+ );
+ }
}
diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs
@@ -56,8 +56,16 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
assert_eq!(endpoint.as_str(), "reticulum:preview-target");
assert_eq!(endpoint.to_string(), "reticulum:preview-target");
assert_eq!(endpoint.clone().into_string(), "reticulum:preview-target");
+ assert_eq!(
+ RadrootsReticulumPreviewEndpoint::default().as_str(),
+ "reticulum:preview-unavailable"
+ );
assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse(" ").expect_err("empty endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
RadrootsReticulumPreviewEndpoint::parse("reticulum:").expect_err("empty endpoint"),
RadrootsReticulumPreviewError::InvalidEndpoint
);
@@ -71,6 +79,11 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
RadrootsReticulumPreviewError::InvalidEndpoint
);
assert_eq!(
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:bad\ntarget")
+ .expect_err("control endpoint"),
+ RadrootsReticulumPreviewError::InvalidEndpoint
+ );
+ assert_eq!(
RadrootsReticulumPreviewProfile::new(
"transport reticulum",
endpoint,
@@ -79,6 +92,27 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
.expect_err("profile id whitespace"),
RadrootsReticulumPreviewError::InvalidProfileId
);
+ assert_eq!(
+ RadrootsReticulumPreviewProfile::new(
+ "",
+ RadrootsReticulumPreviewEndpoint::default(),
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts,
+ )
+ .expect_err("empty profile id"),
+ RadrootsReticulumPreviewError::InvalidProfileId
+ );
+ let profile = RadrootsReticulumPreviewProfile::new(
+ "transport.reticulum.custom",
+ RadrootsReticulumPreviewEndpoint::parse("reticulum:custom").expect("custom endpoint"),
+ RadrootsReticulumPreviewBehavior::DeferDeliveryPlans,
+ )
+ .expect("custom profile");
+ assert_eq!(profile.profile_id(), "transport.reticulum.custom");
+ assert_eq!(profile.endpoint().as_str(), "reticulum:custom");
+ assert_eq!(
+ profile.behavior(),
+ RadrootsReticulumPreviewBehavior::DeferDeliveryPlans
+ );
}
#[test]
@@ -144,6 +178,14 @@ fn non_reticulum_targets_are_rejected_without_nostr_routing() {
#[test]
fn fetch_reports_preview_unavailable_without_observed_events() {
let transport = RadrootsReticulumPreviewTransport::default();
+ assert_eq!(
+ transport.profile().profile_id(),
+ "transport.reticulum.preview"
+ );
+ assert_eq!(
+ transport.status().implementation_state,
+ RadrootsTransportImplementationState::PreviewUnavailable
+ );
let receipt = transport
.fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-1", 10).expect("fetch request"))
.expect("fetch receipt");
@@ -163,6 +205,26 @@ fn fetch_reports_preview_unavailable_without_observed_events() {
RadrootsReticulumPreviewFetchRequest::new("fetch-0", 0).expect_err("zero limit"),
RadrootsReticulumPreviewError::InvalidFetchLimit
);
+ assert_eq!(
+ transport
+ .fetch(RadrootsReticulumPreviewFetchRequest {
+ request_id: "fetch-public-zero".to_owned(),
+ max_events: 0,
+ })
+ .expect_err("zero limit at transport boundary"),
+ RadrootsReticulumPreviewError::InvalidFetchLimit
+ );
+ let deferred_transport = RadrootsReticulumPreviewTransport::new(
+ RadrootsReticulumPreviewProfile::default()
+ .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans),
+ );
+ let deferred = deferred_transport
+ .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-deferred", 1).expect("fetch"))
+ .expect("fetch receipt");
+ assert_eq!(
+ deferred.outcome.status,
+ RadrootsTransportDeliveryTargetStatus::Deferred
+ );
}
#[test]
@@ -175,3 +237,33 @@ fn public_models_round_trip_through_serde() {
assert_eq!(decoded, profile);
}
+
+#[test]
+fn reticulum_preview_errors_and_defaults_are_stable() {
+ assert_eq!(
+ RadrootsReticulumPreviewBehavior::default(),
+ RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts
+ );
+ let cases = [
+ (
+ RadrootsReticulumPreviewError::InvalidEndpoint,
+ "invalid Reticulum preview endpoint",
+ ),
+ (
+ RadrootsReticulumPreviewError::InvalidProfileId,
+ "invalid Reticulum preview profile id",
+ ),
+ (
+ RadrootsReticulumPreviewError::InvalidFetchLimit,
+ "Reticulum preview fetch limit must be greater than zero",
+ ),
+ (
+ RadrootsReticulumPreviewError::NonReticulumTarget,
+ "Reticulum preview transport received a non-Reticulum target",
+ ),
+ ];
+
+ for (error, message) in cases {
+ assert_eq!(error.to_string(), message);
+ }
+}