use bevy_net_backend::{Credentials, JsonEnvelope, OutgoingRequest, Rejection, WsFrame, WsIncoming, WsProtocol, WsPushMessage, WsRequest};
use net_backend_protocol::chat::{
ChatHistory, ChatMessage, JoinRoom, LeaveRoom, ListMembers, MessageDeleted, Presence, PresenceEvent, RoomInfo, RoomKind, RoomMember, RoomMembers, SendAck,
SendMessage,
};
use net_backend_protocol::{
codes, AccessToken, Ack, ApiError, CloseCode, MessageId, Page, RoomId, UnixMillis, UserId, WsAuth, WsAuthOk, WsCall, WsClientFrame, WsPushFrame,
WsRequestFrame, WsResponseFrame, WsServerFrame,
};
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::Value;
const NOW: UnixMillis = UnixMillis(1_790_000_000_000);
fn text(frame: &impl Serialize) -> String {
serde_json::to_string(frame).unwrap_or_else(|e| panic!("{e}"))
}
fn client_encode<R: WsRequest>(wire_id: u64, request: &R) -> String {
let payload = serde_json::to_vec(request).unwrap_or_else(|e| panic!("{e}"));
match JsonEnvelope.encode_request(wire_id, R::KIND, &payload) {
Ok(WsFrame::Text(text)) => text,
other => panic!("not a text frame: {other:?}"),
}
}
fn server_sees<R: WsRequest + WsCall + PartialEq + std::fmt::Debug + Clone>(wire_id: u64, request: R) {
let wire = client_encode(wire_id, &request);
let Ok(WsClientFrame::Request(frame)) = WsClientFrame::parse(&wire) else { panic!("server could not parse {wire}") };
assert_eq!((frame.id, frame.kind.as_str()), (wire_id, <R as WsCall>::KIND));
assert_eq!(frame.data_as::<R>().ok(), Some(request.clone()), "{wire}");
let ours: Value = serde_json::from_str(&text(&WsRequestFrame::call(wire_id, request))).unwrap_or_default();
let theirs: Value = serde_json::from_str(&wire).unwrap_or_default();
assert_eq!(ours, theirs);
}
#[test]
fn requests_from_the_client_parse_on_the_server() {
server_sees(1, JoinRoom::new("world"));
server_sees(2, JoinRoom::new(RoomId(12)));
server_sees(3, LeaveRoom::new(RoomId(12)));
server_sees(4, SendMessage::new(RoomId(12), "hello \u{1F600} \"quoted\""));
server_sees(u64::MAX, ChatHistory::new(RoomId(12)));
server_sees(5, ListMembers::new(RoomId(12)));
}
fn client_decodes_answer<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(wire_id: u64, data: T) {
let wire = text(&WsResponseFrame::ok(wire_id, &data));
match JsonEnvelope.decode(&WsFrame::Text(wire.clone())) {
WsIncoming::Response { wire_id: id, result: Ok(bytes) } => {
assert_eq!(id, wire_id);
assert_eq!(serde_json::from_slice::<T>(&bytes).ok(), Some(data), "{wire}");
}
other => panic!("{wire} decoded as {other:?}"),
}
}
#[test]
fn answers_from_the_server_decode_in_the_client() {
client_decodes_answer(1, RoomInfo::new(RoomId(12), RoomKind::Room).with_key("world"));
client_decodes_answer(2, Ack::new());
client_decodes_answer(3, SendAck::new(MessageId(981), NOW));
client_decodes_answer(4, RoomMembers::new(RoomId(12), vec![RoomMember::new(UserId(42)).with_name("Ada")], 1));
client_decodes_answer(u64::MAX, Page::new(vec![ChatMessage::new(MessageId(1), RoomId(2), UserId(3), "x", NOW)], None));
}
#[test]
fn errors_from_the_server_become_rejections_with_an_api_error() {
let error = ApiError::new(codes::NOT_A_MEMBER, "join the room first").with_details(serde_json::json!({"room": 12}));
let wire = text(&WsResponseFrame::<Value>::error(7, error.clone()));
let WsIncoming::Response { wire_id: 7, result: Err(payload) } = JsonEnvelope.decode(&WsFrame::Text(wire.clone())) else {
panic!("{wire} is not an error answer for the client");
};
assert_eq!(Rejection::new(payload).json::<ApiError>().ok(), Some(error));
}
#[test]
fn pushes_from_the_server_decode_in_the_client() {
let message = ChatMessage::new(MessageId(981), RoomId(12), UserId(42), "hello", NOW).with_sender_name("Ada");
let wire = text(&WsPushFrame::push(message.clone()));
match JsonEnvelope.decode(&WsFrame::Text(wire.clone())) {
WsIncoming::Push { kind, data } => {
assert_eq!(kind, <ChatMessage as WsPushMessage>::KIND);
assert_eq!(serde_json::from_slice::<ChatMessage>(&data).ok(), Some(message));
}
other => panic!("{wire} decoded as {other:?}"),
}
let deleted = MessageDeleted::new(MessageId(1), RoomId(2));
let wire = text(&WsPushFrame::push(deleted));
assert!(matches!(JsonEnvelope.decode(&WsFrame::Text(wire)), WsIncoming::Push { kind, .. } if kind == "chat.deleted"));
let presence = Presence::new(RoomId(12), UserId(42), PresenceEvent::Joined).with_count(2);
let wire = text(&WsPushFrame::push(presence.clone()));
match JsonEnvelope.decode(&WsFrame::Text(wire.clone())) {
WsIncoming::Push { kind, data } => {
assert_eq!(kind, <Presence as WsPushMessage>::KIND);
assert_eq!(serde_json::from_slice::<Presence>(&data).ok(), Some(presence));
}
other => panic!("{wire} decoded as {other:?}"),
}
}
#[test]
fn auth_frames_match_the_client() {
let ok = text(&WsServerFrame::AuthOk(Some(WsAuthOk::new(UserId(42)))));
assert_eq!(JsonEnvelope.decode(&WsFrame::Text(ok)), WsIncoming::AuthOk);
assert_eq!(JsonEnvelope.decode(&WsFrame::Text(text(&WsServerFrame::AuthOk(None)))), WsIncoming::AuthOk);
let error = ApiError::new(codes::UNAUTHORIZED, "the token expired");
let failed = text(&WsServerFrame::AuthFailed(error.clone()));
let WsIncoming::AuthFailed(reason) = JsonEnvelope.decode(&WsFrame::Text(failed)) else { panic!("not auth.failed") };
assert_eq!(serde_json::from_str::<ApiError>(&reason).ok(), Some(error));
let first = WsAuth::new("tok-example").to_message();
assert!(matches!(WsClientFrame::parse(&first), Ok(WsClientFrame::Auth(a)) if a.token.expose() == "tok-example"));
}
#[test]
fn both_decoders_agree() {
let frames = [
r#"{"id":1,"ok":true,"data":{"a":1}}"#,
r#"{"id":1,"ok":false,"error":{"code":"x","message":"y"}}"#,
r#"{"id":1,"data":2}"#,
r#"{"id":1}"#,
r#"{"type":"chat.message","data":{"x":1}}"#,
r#"{"type":"chat.message","id":5,"data":{"x":1}}"#,
r#"{"type":"tick"}"#,
r#"{"type":"auth.ok"}"#,
r#"{"type":"auth.failed","error":{"code":"unauthorized","message":""}}"#,
r#"{"data":1}"#,
r#"{"id":"1","ok":true}"#,
"[1,2]",
"not json",
];
for frame in frames {
let theirs = JsonEnvelope.decode(&WsFrame::Text(frame.into()));
let ours = WsServerFrame::parse(frame).ok();
assert!(same_class(&theirs, ours.as_ref()), "{frame}: client {theirs:?}, protocol {ours:?}");
}
}
fn same_class(theirs: &WsIncoming, ours: Option<&WsServerFrame>) -> bool {
match (theirs, ours) {
(WsIncoming::Response { wire_id, result }, Some(WsServerFrame::Response(r))) => *wire_id == r.id && result.is_ok() == r.is_ok(),
(WsIncoming::Push { kind, .. }, Some(WsServerFrame::Push(p))) => *kind == p.kind,
(WsIncoming::AuthOk, Some(WsServerFrame::AuthOk(_))) => true,
(WsIncoming::AuthFailed(_), Some(WsServerFrame::AuthFailed(_))) => true,
(WsIncoming::Ignore, None) => true,
_ => false,
}
}
#[test]
fn close_codes_match_the_client_retry_rule() {
for code in CloseCode::ALL {
assert_eq!(JsonEnvelope.retry_after_close(code.get()), !code.is_permanent(), "{code}");
}
for raw in 1000..=4999u16 {
assert_eq!(JsonEnvelope.retry_after_close(raw), !CloseCode(raw).is_permanent(), "{raw}");
}
}
#[test]
fn trait_impls_match_the_protocol() {
fn same_response<R>()
where
R: WsCall + WsRequest<Response = <R as WsCall>::Response>,
{
assert_eq!(<R as WsRequest>::KIND, <R as WsCall>::KIND);
}
same_response::<JoinRoom>();
same_response::<LeaveRoom>();
same_response::<SendMessage>();
same_response::<ChatHistory>();
same_response::<ListMembers>();
assert_eq!(<Presence as WsPushMessage>::KIND, "chat.presence");
assert_eq!(<ChatMessage as WsPushMessage>::KIND, "chat.message");
assert_eq!(<MessageDeleted as WsPushMessage>::KIND, "chat.deleted");
assert!(!SendMessage::new(RoomId(1), "x").resend_on_reconnect());
}
#[test]
fn access_token_is_bearer_credentials() {
let mut request = OutgoingRequest::get("/v1/account");
AccessToken::new("tok-example").apply(&mut request);
let header = request.headers().get("authorization").unwrap_or_else(|| panic!("no Authorization header"));
assert_eq!(header.to_str().ok(), Some("Bearer tok-example"));
assert!(header.is_sensitive());
assert_eq!(AccessToken::new("t").ws_auth_message(), None);
}
#[test]
fn both_decoders_agree_on_adversarial_frames() {
let frames = [
r#"{"id":1,"type":5}"#,
r#"{"id":1,"ok":"false","error":{}}"#,
r#"{"id":1,"type":null}"#,
r#"{"id":1,"type":"auth.ok"}"#,
r#"{"id":1,"type":"auth.failed","error":{"code":"x"}}"#,
r#"{"type":"auth.failed"}"#,
r#"{"id":1,"ok":null,"type":"x"}"#,
r#"{"id":0}"#,
r#"{"id":1e2}"#,
r#"{"id":18446744073709551616}"#,
r#"{"type":"x","data":null}"#,
r#"{"type":""}"#,
r#"{"id":1,"ok":true,"data":1,"type":"chat.message"}"#,
r#"{"type":"auth.ok","id":2,"ok":true}"#,
" {\"id\":1} ",
r#"{"id":1,"error":{"code":"x"}}"#,
r#"{"type":"auth.ok","data":{"user_id":"x"}}"#,
r#"{"id":1,"id":"x","type":"t"}"#,
r#"{"type":"t","type":5}"#,
r#"{"id":1,"ok":false,"data":{"a":1}}"#,
r#"{"id":-0,"ok":true}"#,
r#"{"id":1.0,"ok":true}"#,
r#"{"id":1,"ok":true,"data":{"a":1}}trailing"#,
];
for frame in frames {
let theirs = JsonEnvelope.decode(&WsFrame::Text(frame.into()));
let ours = WsServerFrame::parse(frame).ok();
assert!(same_class(&theirs, ours.as_ref()), "{frame}: client {theirs:?}, protocol {ours:?}");
}
}
#[test]
fn malformed_request_answers_reach_the_client() {
let error = WsClientFrame::parse(r#"{"id":41,"type":7}"#).err().unwrap_or_else(|| panic!("parsed"));
let wire = text(&error.answer().unwrap_or_else(|| panic!("no id")));
let WsIncoming::Response { wire_id: 41, result: Err(payload) } = JsonEnvelope.decode(&WsFrame::Text(wire.clone())) else {
panic!("{wire} is not an error answer for request 41");
};
assert!(Rejection::new(payload).json::<ApiError>().is_ok_and(|e| e.is(codes::BAD_REQUEST)));
}