use crate::{Event, ShellyError};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const PROTOCOL_VERSION_V1: &str = "shelly/1";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Connect {
protocol: String,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
last_revision: Option<u64>,
#[serde(default)]
resume_token: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
trace_id: Option<String>,
#[serde(default)]
span_id: Option<String>,
#[serde(default)]
parent_span_id: Option<String>,
#[serde(default)]
correlation_id: Option<String>,
#[serde(default)]
request_id: Option<String>,
},
Event {
event: String,
target: Option<String>,
#[serde(default)]
value: Value,
#[serde(default)]
metadata: Map<String, Value>,
},
Ping { nonce: Option<String> },
PatchUrl { to: String },
Navigate { to: String },
UploadStart {
upload_id: String,
event: String,
target: Option<String>,
name: String,
size: u64,
content_type: Option<String>,
},
UploadChunk {
upload_id: String,
offset: u64,
data: String,
},
UploadComplete { upload_id: String },
}
impl TryFrom<ClientMessage> for Event {
type Error = ShellyError;
fn try_from(message: ClientMessage) -> Result<Self, Self::Error> {
match message {
ClientMessage::Connect { .. } => Err(ShellyError::InvalidMessage(
"connect cannot be converted into a LiveView event".to_string(),
)),
ClientMessage::Event {
event,
target,
value,
metadata,
} => Ok(Event {
name: event,
target,
value,
metadata,
}),
ClientMessage::Ping { .. } => Err(ShellyError::InvalidMessage(
"ping cannot be converted into a LiveView event".to_string(),
)),
ClientMessage::PatchUrl { .. } => Err(ShellyError::InvalidMessage(
"patch_url cannot be converted into a LiveView event".to_string(),
)),
ClientMessage::Navigate { .. } => Err(ShellyError::InvalidMessage(
"navigate cannot be converted into a LiveView event".to_string(),
)),
ClientMessage::UploadStart { .. }
| ClientMessage::UploadChunk { .. }
| ClientMessage::UploadComplete { .. } => Err(ShellyError::InvalidMessage(
"upload protocol messages cannot be converted into LiveView events".to_string(),
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Hello {
session_id: String,
target: String,
revision: u64,
protocol: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
server_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
resume_status: Option<ResumeStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
resume_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
resume_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
resume_expires_in_ms: Option<u64>,
},
Patch {
target: String,
html: String,
revision: u64,
},
Diff {
target: String,
revision: u64,
slots: Vec<DynamicSlotPatch>,
},
StreamInsert {
target: String,
id: String,
html: String,
at: StreamPosition,
},
StreamDelete { target: String, id: String },
StreamBatch {
target: String,
operations: Vec<StreamBatchOperation>,
},
ChartSeriesAppend {
chart: String,
series: String,
point: ChartPoint,
},
ChartSeriesAppendMany {
chart: String,
series: String,
points: Vec<ChartPoint>,
},
ChartSeriesReplace {
chart: String,
series: String,
points: Vec<ChartPoint>,
},
ChartReset { chart: String },
ChartAnnotationUpsert {
chart: String,
annotation: ChartAnnotation,
},
ChartAnnotationDelete { chart: String, id: String },
ToastPush { toast: Toast },
ToastDismiss { id: String },
InboxUpsert { item: InboxItem },
InboxDelete { id: String },
GridReplace { grid: String, state: GridState },
GridRowsReplace {
grid: String,
window: GridRowsWindow,
},
InteropDispatch { dispatch: JsInteropDispatch },
Pong { nonce: Option<String> },
Redirect { to: String },
PatchUrl { to: String },
Navigate { to: String },
UploadProgress {
upload_id: String,
received: u64,
total: u64,
},
UploadComplete {
upload_id: String,
name: String,
size: u64,
content_type: Option<String>,
},
UploadError {
upload_id: String,
message: String,
code: Option<String>,
},
Error {
message: String,
code: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DynamicSlotPatch {
pub index: usize,
pub html: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResumeStatus {
Fresh,
Resumed,
Fallback,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ChartPoint {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartAnnotation {
pub id: String,
pub x: f64,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Toast {
pub id: String,
pub level: ToastLevel,
pub title: Option<String>,
pub message: String,
pub ttl_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToastLevel {
Info,
Success,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboxItem {
pub id: String,
pub title: String,
pub body: String,
pub read: bool,
pub inserted_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridState {
#[serde(default)]
pub columns: Vec<GridColumn>,
#[serde(default)]
pub rows: Vec<GridRow>,
pub total_rows: usize,
pub offset: usize,
pub limit: usize,
#[serde(default)]
pub views: Vec<GridSavedView>,
pub active_view: Option<String>,
pub group_by: Option<String>,
pub query: Option<String>,
pub sort: Option<GridSort>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridRowsWindow {
pub offset: usize,
pub total_rows: usize,
#[serde(default)]
pub rows: Vec<GridRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridColumn {
pub id: String,
pub label: String,
pub width_px: Option<u16>,
pub min_width_px: Option<u16>,
#[serde(default)]
pub pinned: GridPinned,
#[serde(default = "default_true")]
pub sortable: bool,
#[serde(default = "default_true")]
pub resizable: bool,
#[serde(default)]
pub editable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum GridPinned {
Left,
Right,
#[default]
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridRow {
pub id: String,
#[serde(default)]
pub cells: Map<String, Value>,
pub group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridSavedView {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridSort {
pub column: String,
pub direction: GridSortDirection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GridSortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsInteropDispatch {
pub target: Option<String>,
pub event: String,
#[serde(default)]
pub detail: Value,
#[serde(default = "default_true")]
pub bubbles: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamPosition {
Append,
Prepend,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum StreamBatchOperation {
Insert {
id: String,
html: String,
at: StreamPosition,
},
Delete {
id: String,
},
}
#[cfg(test)]
mod tests {
use super::{
ChartAnnotation, ChartPoint, ClientMessage, DynamicSlotPatch, GridColumn, GridPinned,
GridRow, GridRowsWindow, GridSavedView, GridSort, GridSortDirection, GridState, InboxItem,
JsInteropDispatch, ServerMessage, StreamBatchOperation, StreamPosition, Toast, ToastLevel,
};
use crate::Event;
use serde_json::{json, Map};
#[test]
fn decodes_client_event() {
let raw = r#"{
"type": "event",
"event": "inc",
"target": "counter",
"value": {"step": 1},
"metadata": {"tag": "BUTTON"}
}"#;
let message: ClientMessage = serde_json::from_str(raw).unwrap();
let event = Event::try_from(message).unwrap();
assert_eq!(event.name, "inc");
assert_eq!(event.target.as_deref(), Some("counter"));
assert_eq!(event.value, json!({"step": 1}));
assert_eq!(event.metadata.get("tag"), Some(&json!("BUTTON")));
}
#[test]
fn decodes_protocol_connect() {
let message: ClientMessage =
serde_json::from_value(json!({"type": "connect", "protocol": "shelly/1"})).unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn decodes_protocol_connect_resume_hints() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1",
"session_id": "sid-1",
"last_revision": 7
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: Some("sid-1".to_string()),
last_revision: Some(7),
resume_token: None,
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn decodes_protocol_connect_resume_token() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1",
"session_id": "sid-1",
"last_revision": 7,
"resume_token": "resume-token-1"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: Some("sid-1".to_string()),
last_revision: Some(7),
resume_token: Some("resume-token-1".to_string()),
tenant_id: None,
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn decodes_protocol_connect_correlation_fields() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"parent_span_id": "89abcdef01234567",
"correlation_id": "corr-123",
"request_id": "req-123"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: None,
trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
span_id: Some("00f067aa0ba902b7".to_string()),
parent_span_id: Some("89abcdef01234567".to_string()),
correlation_id: Some("corr-123".to_string()),
request_id: Some("req-123".to_string()),
}
);
}
#[test]
fn decodes_protocol_connect_tenant_context() {
let message: ClientMessage = serde_json::from_value(json!({
"type": "connect",
"protocol": "shelly/1",
"tenant_id": "tenant-a"
}))
.unwrap();
assert_eq!(
message,
ClientMessage::Connect {
protocol: "shelly/1".to_string(),
session_id: None,
last_revision: None,
resume_token: None,
tenant_id: Some("tenant-a".to_string()),
trace_id: None,
span_id: None,
parent_span_id: None,
correlation_id: None,
request_id: None,
}
);
}
#[test]
fn decodes_navigation_requests() {
assert_eq!(
serde_json::from_value::<ClientMessage>(json!({
"type": "patch_url",
"to": "/pages/intro"
}))
.unwrap(),
ClientMessage::PatchUrl {
to: "/pages/intro".to_string()
}
);
assert_eq!(
serde_json::from_value::<ClientMessage>(json!({
"type": "navigate",
"to": "/users/1"
}))
.unwrap(),
ClientMessage::Navigate {
to: "/users/1".to_string()
}
);
}
#[test]
fn decodes_upload_requests() {
assert_eq!(
serde_json::from_value::<ClientMessage>(json!({
"type": "upload_start",
"upload_id": "up-1",
"event": "uploaded",
"target": "file",
"name": "notes.txt",
"size": 12,
"content_type": "text/plain"
}))
.unwrap(),
ClientMessage::UploadStart {
upload_id: "up-1".to_string(),
event: "uploaded".to_string(),
target: Some("file".to_string()),
name: "notes.txt".to_string(),
size: 12,
content_type: Some("text/plain".to_string()),
}
);
assert_eq!(
serde_json::from_value::<ClientMessage>(json!({
"type": "upload_chunk",
"upload_id": "up-1",
"offset": 0,
"data": "aGVsbG8="
}))
.unwrap(),
ClientMessage::UploadChunk {
upload_id: "up-1".to_string(),
offset: 0,
data: "aGVsbG8=".to_string(),
}
);
assert_eq!(
serde_json::from_value::<ClientMessage>(json!({
"type": "upload_complete",
"upload_id": "up-1"
}))
.unwrap(),
ClientMessage::UploadComplete {
upload_id: "up-1".to_string()
}
);
}
#[test]
fn encodes_server_patch() {
let message = ServerMessage::Patch {
target: "shelly-root".to_string(),
html: "<p>ok</p>".to_string(),
revision: 2,
};
let encoded = serde_json::to_value(message).unwrap();
assert_eq!(encoded["type"], "patch");
assert_eq!(encoded["target"], "shelly-root");
assert_eq!(encoded["revision"], 2);
}
#[test]
fn encodes_server_diff() {
let message = ServerMessage::Diff {
target: "shelly-root".to_string(),
revision: 3,
slots: vec![DynamicSlotPatch {
index: 0,
html: "2".to_string(),
}],
};
let encoded = serde_json::to_value(message).unwrap();
assert_eq!(
encoded,
json!({
"type": "diff",
"target": "shelly-root",
"revision": 3,
"slots": [{"index": 0, "html": "2"}]
})
);
}
#[test]
fn encodes_stream_messages() {
assert_eq!(
serde_json::to_value(ServerMessage::StreamInsert {
target: "messages".to_string(),
id: "msg-1".to_string(),
html: "<li id=\"msg-1\">hi</li>".to_string(),
at: StreamPosition::Append,
})
.unwrap(),
json!({
"type": "stream_insert",
"target": "messages",
"id": "msg-1",
"html": "<li id=\"msg-1\">hi</li>",
"at": "append"
})
);
assert_eq!(
serde_json::to_value(ServerMessage::StreamDelete {
target: "messages".to_string(),
id: "msg-1".to_string(),
})
.unwrap(),
json!({
"type": "stream_delete",
"target": "messages",
"id": "msg-1"
})
);
assert_eq!(
serde_json::to_value(ServerMessage::StreamBatch {
target: "messages".to_string(),
operations: vec![
StreamBatchOperation::Insert {
id: "msg-2".to_string(),
html: "<li id=\"msg-2\">batch</li>".to_string(),
at: StreamPosition::Append,
},
StreamBatchOperation::Delete {
id: "msg-1".to_string(),
},
],
})
.unwrap(),
json!({
"type": "stream_batch",
"target": "messages",
"operations": [
{
"op": "insert",
"id": "msg-2",
"html": "<li id=\"msg-2\">batch</li>",
"at": "append"
},
{
"op": "delete",
"id": "msg-1"
}
]
})
);
}
#[test]
fn encodes_chart_messages() {
assert_eq!(
serde_json::to_value(ServerMessage::ChartSeriesAppend {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
point: ChartPoint { x: 4.0, y: 11.5 },
})
.unwrap(),
json!({
"type": "chart_series_append",
"chart": "traffic-chart",
"series": "requests",
"point": {
"x": 4.0,
"y": 11.5
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ChartSeriesReplace {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
points: vec![
ChartPoint { x: 1.0, y: 10.0 },
ChartPoint { x: 2.0, y: 11.5 }
],
})
.unwrap(),
json!({
"type": "chart_series_replace",
"chart": "traffic-chart",
"series": "requests",
"points": [
{"x": 1.0, "y": 10.0},
{"x": 2.0, "y": 11.5}
]
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ChartSeriesAppendMany {
chart: "traffic-chart".to_string(),
series: "requests".to_string(),
points: vec![
ChartPoint { x: 3.0, y: 10.75 },
ChartPoint { x: 4.0, y: 11.5 }
],
})
.unwrap(),
json!({
"type": "chart_series_append_many",
"chart": "traffic-chart",
"series": "requests",
"points": [
{"x": 3.0, "y": 10.75},
{"x": 4.0, "y": 11.5}
]
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ChartReset {
chart: "traffic-chart".to_string(),
})
.unwrap(),
json!({
"type": "chart_reset",
"chart": "traffic-chart"
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ChartAnnotationUpsert {
chart: "traffic-chart".to_string(),
annotation: ChartAnnotation {
id: "release-1".to_string(),
x: 18.0,
label: "Deploy".to_string(),
},
})
.unwrap(),
json!({
"type": "chart_annotation_upsert",
"chart": "traffic-chart",
"annotation": {
"id": "release-1",
"x": 18.0,
"label": "Deploy"
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ChartAnnotationDelete {
chart: "traffic-chart".to_string(),
id: "release-1".to_string(),
})
.unwrap(),
json!({
"type": "chart_annotation_delete",
"chart": "traffic-chart",
"id": "release-1"
})
);
}
#[test]
fn encodes_toast_and_inbox_messages() {
assert_eq!(
serde_json::to_value(ServerMessage::ToastPush {
toast: Toast {
id: "toast-1".to_string(),
level: ToastLevel::Success,
title: Some("Saved".to_string()),
message: "Profile updated".to_string(),
ttl_ms: Some(2500),
},
})
.unwrap(),
json!({
"type": "toast_push",
"toast": {
"id": "toast-1",
"level": "success",
"title": "Saved",
"message": "Profile updated",
"ttl_ms": 2500
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::ToastDismiss {
id: "toast-1".to_string(),
})
.unwrap(),
json!({
"type": "toast_dismiss",
"id": "toast-1"
})
);
assert_eq!(
serde_json::to_value(ServerMessage::InboxUpsert {
item: InboxItem {
id: "msg-1".to_string(),
title: "Welcome".to_string(),
body: "Thanks for joining".to_string(),
read: false,
inserted_at: Some("2026-05-05T12:00:00Z".to_string()),
},
})
.unwrap(),
json!({
"type": "inbox_upsert",
"item": {
"id": "msg-1",
"title": "Welcome",
"body": "Thanks for joining",
"read": false,
"inserted_at": "2026-05-05T12:00:00Z"
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::InboxDelete {
id: "msg-1".to_string(),
})
.unwrap(),
json!({
"type": "inbox_delete",
"id": "msg-1"
})
);
assert_eq!(
serde_json::to_value(ServerMessage::GridReplace {
grid: "enterprise-grid".to_string(),
state: GridState {
columns: vec![
GridColumn {
id: "name".to_string(),
label: "Name".to_string(),
width_px: Some(220),
min_width_px: Some(120),
pinned: GridPinned::Left,
sortable: true,
resizable: true,
editable: false,
},
GridColumn {
id: "arr".to_string(),
label: "ARR".to_string(),
width_px: Some(120),
min_width_px: Some(80),
pinned: GridPinned::None,
sortable: true,
resizable: true,
editable: true,
},
],
rows: vec![GridRow {
id: "acct-1".to_string(),
cells: Map::from_iter([
("name".to_string(), json!("Acme")),
("arr".to_string(), json!(125000)),
]),
group: Some("Enterprise".to_string()),
}],
total_rows: 1000,
offset: 0,
limit: 100,
views: vec![GridSavedView {
id: "ops".to_string(),
label: "Ops".to_string(),
}],
active_view: Some("ops".to_string()),
group_by: Some("segment".to_string()),
query: Some("acme".to_string()),
sort: Some(GridSort {
column: "arr".to_string(),
direction: GridSortDirection::Desc,
}),
},
})
.unwrap(),
json!({
"type": "grid_replace",
"grid": "enterprise-grid",
"state": {
"columns": [
{
"id": "name",
"label": "Name",
"width_px": 220,
"min_width_px": 120,
"pinned": "left",
"sortable": true,
"resizable": true,
"editable": false
},
{
"id": "arr",
"label": "ARR",
"width_px": 120,
"min_width_px": 80,
"pinned": "none",
"sortable": true,
"resizable": true,
"editable": true
}
],
"rows": [
{
"id": "acct-1",
"cells": {
"name": "Acme",
"arr": 125000
},
"group": "Enterprise"
}
],
"total_rows": 1000,
"offset": 0,
"limit": 100,
"views": [
{
"id": "ops",
"label": "Ops"
}
],
"active_view": "ops",
"group_by": "segment",
"query": "acme",
"sort": {
"column": "arr",
"direction": "desc"
}
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::GridRowsReplace {
grid: "enterprise-grid".to_string(),
window: GridRowsWindow {
offset: 400,
total_rows: 1000,
rows: vec![GridRow {
id: "acct-401".to_string(),
cells: Map::from_iter([
("name".to_string(), json!("Acme North")),
("arr".to_string(), json!(166000)),
]),
group: Some("Enterprise".to_string()),
}],
},
})
.unwrap(),
json!({
"type": "grid_rows_replace",
"grid": "enterprise-grid",
"window": {
"offset": 400,
"total_rows": 1000,
"rows": [
{
"id": "acct-401",
"cells": {
"name": "Acme North",
"arr": 166000
},
"group": "Enterprise"
}
]
}
})
);
assert_eq!(
serde_json::to_value(ServerMessage::InteropDispatch {
dispatch: JsInteropDispatch {
target: Some("peer-a".to_string()),
event: "shelly:webrtc-signal".to_string(),
detail: json!({"kind": "offer", "sdp": "v=0..."}),
bubbles: true,
},
})
.unwrap(),
json!({
"type": "interop_dispatch",
"dispatch": {
"target": "peer-a",
"event": "shelly:webrtc-signal",
"detail": {"kind": "offer", "sdp": "v=0..."},
"bubbles": true
}
})
);
}
#[test]
fn encodes_navigation_messages() {
assert_eq!(
serde_json::to_value(ServerMessage::PatchUrl {
to: "/pages/intro".to_string(),
})
.unwrap(),
json!({"type": "patch_url", "to": "/pages/intro"})
);
assert_eq!(
serde_json::to_value(ServerMessage::Navigate {
to: "/users/1".to_string(),
})
.unwrap(),
json!({"type": "navigate", "to": "/users/1"})
);
}
#[test]
fn encodes_upload_status_messages() {
assert_eq!(
serde_json::to_value(ServerMessage::UploadProgress {
upload_id: "up-1".to_string(),
received: 5,
total: 10,
})
.unwrap(),
json!({
"type": "upload_progress",
"upload_id": "up-1",
"received": 5,
"total": 10
})
);
assert_eq!(
serde_json::to_value(ServerMessage::UploadError {
upload_id: "up-1".to_string(),
message: "too large".to_string(),
code: Some("upload_too_large".to_string()),
})
.unwrap(),
json!({
"type": "upload_error",
"upload_id": "up-1",
"message": "too large",
"code": "upload_too_large"
})
);
}
#[test]
fn rejects_unknown_client_message_type() {
let decoded = serde_json::from_value::<ClientMessage>(json!({
"type": "do_the_thing",
"payload": {}
}));
assert!(decoded.is_err());
}
#[test]
fn rejects_event_without_event_name() {
let decoded = serde_json::from_value::<ClientMessage>(json!({
"type": "event",
"target": "counter",
"value": {"step": 1}
}));
assert!(decoded.is_err());
}
#[test]
fn rejects_upload_chunk_without_required_fields() {
let missing_data = serde_json::from_value::<ClientMessage>(json!({
"type": "upload_chunk",
"upload_id": "up-1",
"offset": 0
}));
assert!(missing_data.is_err());
let missing_offset = serde_json::from_value::<ClientMessage>(json!({
"type": "upload_chunk",
"upload_id": "up-1",
"data": "aGVsbG8="
}));
assert!(missing_offset.is_err());
}
}