diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 5ce4bad..58a4c12 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -151,6 +151,19 @@ impl<'de> Deserialize<'de> for Refusal { } } +/// The mouth refused a request outright with a typed refusal frame, and nothing was done. An error of `open_direct` when the +/// mouth does not serve the kind: told as not sent, unlike a silent mouth, whose open may have gone out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MouthRefused(pub Refusal); + +impl std::fmt::Display for MouthRefused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the mouth refused the request: {}", self.0.name()) + } +} + +impl std::error::Error for MouthRefused {} + /// The mouth's typed answer to a request that names a Persona it does not /// serve: the pending request, the ack and the send alike. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 9944139..530e85e 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -13,7 +13,7 @@ use std::thread::JoinHandle; use std::time::Duration; use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions}; -use mind_body::chat_client::{self, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST}; +use mind_body::chat_client::{self, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST}; use mind_body::chat_rudp::{RudpChat, connection_id}; const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack"); @@ -23,11 +23,15 @@ const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/ga const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack"); const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack"); const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack"); +const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack"); const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack"); const KEY: &str = "connection-key-for-test"; const TAG: &str = "cultnet.transport.rudp.v0"; +/// What a fixture mouth answers an open with: the document kind, a record key (none: the request's own), and its bytes. +type OpenReply = (&'static str, Option<&'static str>, &'static [u8]); + type Seen = Arc)>>>; struct Mouth { @@ -70,15 +74,15 @@ fn mouth(key: &str) -> Mouth { /// A mouth that has no image field yet: it answers a pending request in the v1 kind. fn legacy_mouth(key: &str) -> Mouth { - serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES) + serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) } /// The same mouth on a chosen address: a restart binds the address the old one held. fn mouth_at(key: &str, bind: &str) -> Mouth { - serve(key, bind, PENDING, PENDING_BYTES) + serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) } -fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8]) -> Mouth { +fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8], open: OpenReply) -> Mouth { let socket = UdpSocket::bind(bind).unwrap(); socket.set_read_timeout(Some(Duration::from_millis(20))).unwrap(); let address = socket.local_addr().unwrap(); @@ -98,7 +102,7 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta let reply = match document.schema_id.as_str() { PENDING_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)), SEND_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)), - OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(OPEN_DIRECT_RECEIPT, &document.record_key, OPENED_BYTES)), + OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)), _ => None, }; if let Some(reply) = reply { @@ -231,3 +235,30 @@ fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_ano let seen = mouth.seen.lock().unwrap().clone(); assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec())); } + +/// A mouth that answers every open with `reply`. +fn opening_with(reply: OpenReply) -> Mouth { + serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply) +} + +#[test] +fn a_mouth_that_does_not_serve_the_open_kind_refuses_it_and_the_open_is_told_not_sent_at_once() { + let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES)); + let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20)); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let started = std::time::Instant::now(); + let error = chat.open_direct(&request).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed()); + assert_eq!(error.downcast_ref::(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}"); +} + +#[test] +fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() { + let wrong_persona = opening_with((REFUSAL, Some("someone-else"), UNKNOWN_KIND_BYTES)); + let request = chat_client::decode::(OPEN_REQUEST_BYTES).unwrap(); + let error = client(&wrong_persona, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "a refusal keyed by another persona is not heard: {error}"); + let wrong_kind = opening_with((SEND_RECEIPT, Some("eureka"), SEND_RECEIPT_BYTES)); + let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "another kind is not heard: {error}"); +}