From 319eeb44ae9319f3e17e65725cb5d37bf7c8ad78 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 19 Sep 2026 06:00:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(gotify):=20Phase=204=20C1=20=E2=80=94=20We?= =?UTF-8?q?bSocket=20reconnect=20+=20=E5=B8=A7=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/gotify/reconnect.rs: ReconnectPolicy 按 §15 退避序列 1s/2s/4s/8s/16s/30s...record_success() 重置;inline #[cfg(test)] 3 个 backoff 测试 - src/gotify/websocket.rs: blocking tungstenite 0.21 客户端 · stream_url() http→ws / https→wss 自动转 · build_request() 手拼 Sec-WebSocket-* header + Authorization: Bearer · connect_once() 单次 handshake · run_stream_loop() 永久循环:connect → read Message::Text 解析 GotifyMessage → 回调 on_message;Ping 触发 Pong;Close/断开/读 错都走到 §15 退避后重连 - src/gotify/mod.rs: re-export 加 reconnect + websocket 子模块 - Cargo.toml: tungstenite 0.21 (rustls-tls-webpki-roots + handshake feature) + http 1 + url 2 - tests/gotify_websocket.rs: 3 个 JSON envelope 解析测试 避坑: - tungstenite 0.21 的 connect 在 handshake feature 后(默认不带),要 显式开 - WebSocketConfig 0.21 没 connect_timeout 字段;靠 rustls 默认 ~30s 上限(Phase 4 LAN 部署可接受;Phase 8 再补 socket-level timeout) - read_message() 在 0.21 已 deprecated;改用 read() - Message 枚举加覆盖 Pong / Frame 两支(compiler non-exhaustive 警告) - inline #[cfg(test)] mod tests 在 reconnect.rs 跑 unit tests(与 tests/ 集成测试并列) 验证:cargo build / build --release 都 exit 0,0 warning - cargo test: 12 passed (3 reconnect unit + 6 api + 3 websocket); 0 failed - debug 33.36 MB, subsystem = WINDOWS_CUI - release 9.43 MB, subsystem = WINDOWS_GUI(tungstenite + http 在 LTO 后几乎不增体积——和 Phase 3 C2 持平) --- Cargo.lock | 209 +++++++++++++++++++++++++++++++++++--- Cargo.toml | 5 + src/gotify/mod.rs | 2 + src/gotify/reconnect.rs | 76 ++++++++++++++ src/gotify/websocket.rs | 161 +++++++++++++++++++++++++++++ tests/gotify_websocket.rs | 47 +++++++++ 6 files changed, 486 insertions(+), 14 deletions(-) create mode 100644 src/gotify/reconnect.rs create mode 100644 src/gotify/websocket.rs create mode 100644 tests/gotify_websocket.rs diff --git a/Cargo.lock b/Cargo.lock index 0dc6f97..beca6f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -581,6 +590,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -681,8 +696,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -838,6 +853,15 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -902,6 +926,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.10.1" @@ -917,6 +951,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -946,6 +986,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1447,6 +1497,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1492,7 +1552,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1778,11 +1838,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.45", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3607,6 +3667,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" @@ -3691,7 +3760,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls", + "rustls 0.23.45", "socket2", "thiserror 2.0.20", "tokio", @@ -3708,11 +3777,11 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash 2.1.3", - "rustls", + "rustls 0.23.45", "rustls-pki-types", "slab", "thiserror 2.0.20", @@ -3756,6 +3825,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -3764,7 +3844,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -3779,7 +3878,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3921,7 +4020,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.45", "rustls-pki-types", "serde", "serde_json", @@ -3936,7 +4035,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -4062,6 +4161,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.45" @@ -4071,7 +4184,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] @@ -4086,6 +4199,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -4230,6 +4354,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4852,7 +4987,7 @@ version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ - "rustls", + "rustls 0.23.45", "tokio", ] @@ -5038,6 +5173,28 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls 0.22.4", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", + "webpki-roots 0.26.11", +] + [[package]] name = "typed-index-collections" version = "3.5.0" @@ -5048,6 +5205,12 @@ dependencies = [ "serde", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "udev" version = "0.9.3" @@ -5177,6 +5340,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5463,6 +5632,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -5683,6 +5861,7 @@ name = "windows-gotify-client-rust" version = "0.1.0" dependencies = [ "chrono", + "http", "reqwest", "serde", "serde_json", @@ -5691,6 +5870,8 @@ dependencies = [ "thiserror 2.0.20", "tracing", "tracing-subscriber", + "tungstenite", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e5ed36e..1312f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", # --- Date/time (Phase 3 C2 convert Gotify RFC3339) --- chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +# --- WebSocket (Phase 4 real-time stream) --- +tungstenite = { version = "0.21", default-features = false, features = ["rustls-tls-webpki-roots", "handshake"] } +url = "2" +http = "1" + # Slint's `.slint` files are compiled by `build.rs`; see build.rs. [build-dependencies] slint-build = "1" diff --git a/src/gotify/mod.rs b/src/gotify/mod.rs index d0fd7de..fa0e9be 100644 --- a/src/gotify/mod.rs +++ b/src/gotify/mod.rs @@ -9,6 +9,8 @@ pub mod api; pub mod model; +pub mod reconnect; +pub mod websocket; pub use api::GotifyApi; pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig}; \ No newline at end of file diff --git a/src/gotify/reconnect.rs b/src/gotify/reconnect.rs new file mode 100644 index 0000000..f1f09fb --- /dev/null +++ b/src/gotify/reconnect.rs @@ -0,0 +1,76 @@ +//! Reconnect backoff policy (AGENTS.md §15). +//! +//! Sequence after a disconnect (in seconds): +//! 1, 2, 4, 8, 16, 30, 30, 30, ... +//! +//! `record_success()` resets the counter to zero so the next disconnect +//! restarts at 1s instead of paying the maximum-delay cost forever. + +use std::time::Duration; + +const BACKOFF_STEPS_SECS: &[u64] = &[1, 2, 4, 8, 16, 30]; + +#[derive(Debug, Clone)] +pub struct ReconnectPolicy { + attempt: u32, +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + Self::new() + } +} + +impl ReconnectPolicy { + pub fn new() -> Self { + Self { attempt: 0 } + } + + /// Compute the wait time before the *next* connect attempt, and + /// advance the internal counter. + pub fn next_delay(&mut self) -> Duration { + let idx = (self.attempt as usize).min(BACKOFF_STEPS_SECS.len() - 1); + let secs = BACKOFF_STEPS_SECS[idx]; + self.attempt = self.attempt.saturating_add(1); + Duration::from_secs(secs) + } + + /// Reset after a successful connect. Next disconnect starts at 1s. + pub fn record_success(&mut self) { + self.attempt = 0; + } + + /// How many failed attempts have happened so far (useful for tests). + pub fn attempts(&self) -> u32 { + self.attempt + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_delay_is_one_second() { + let mut p = ReconnectPolicy::new(); + assert_eq!(p.next_delay(), Duration::from_secs(1)); + assert_eq!(p.attempts(), 1); + } + + #[test] + fn sequence_matches_section_15() { + let mut p = ReconnectPolicy::new(); + let observed: Vec = (0..9).map(|_| p.next_delay().as_secs()).collect(); + assert_eq!(observed, vec![1, 2, 4, 8, 16, 30, 30, 30, 30]); + } + + #[test] + fn record_success_resets_to_zero() { + let mut p = ReconnectPolicy::new(); + p.next_delay(); + p.next_delay(); + p.next_delay(); + p.record_success(); + assert_eq!(p.next_delay(), Duration::from_secs(1)); + } +} \ No newline at end of file diff --git a/src/gotify/websocket.rs b/src/gotify/websocket.rs new file mode 100644 index 0000000..a951055 --- /dev/null +++ b/src/gotify/websocket.rs @@ -0,0 +1,161 @@ +//! Gotify WebSocket real-time stream client (AGENTS.md §14 / §15 / §46). +//! +//! Connects to `/stream` on a Gotify server, decodes each text frame as +//! a `GotifyMessage`, and runs an event-driven loop driven by closures +//! supplied by the caller. The transport (`tungstenite`) is blocking; the +//! caller is expected to invoke `run()` from a dedicated background +//! `std::thread` (see `src/lib.rs` for the wiring). Reconnects use the +//! §15 exponential-backoff policy in `reconnect.rs`. + +use http::Request; +use serde::Deserialize; +use tungstenite::client::connect; +use tungstenite::handshake::client::generate_key; +use tungstenite::{Error as WsError, Message}; + +use crate::error::{AppError, AppResult}; +use crate::gotify::model::{GotifyMessage, ServerConfig}; +use crate::gotify::reconnect::ReconnectPolicy; + +// tungstenite 0.21's `WebSocketConfig` doesn't expose a handshake timeout, +// so the TCP+TLS connect relies on rustls's default ~30s upper bound. +// §15 talks about reconnect *spacing*, not handshake ceilings — this is +// acceptable for the Phase 4 LAN-only deployment. + +/// Incoming WS frame that isn't a text payload — used by the test harness +/// to mock a server without standing up real TLS. +#[derive(Debug, Deserialize)] +pub struct StreamEnvelope { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub appid: Option, + #[serde(default)] + pub message: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub priority: i32, +} + +/// Convert the configured HTTP base URL into a `ws://` / `wss://` URL +/// pointing at `/stream`. Strips any trailing slash before appending. +fn stream_url(base_url: &str) -> AppResult { + let trimmed = base_url.trim_end_matches('/'); + let ws = if let Some(rest) = trimmed.strip_prefix("https://") { + format!("wss://{rest}/stream") + } else if let Some(rest) = trimmed.strip_prefix("http://") { + format!("ws://{rest}/stream") + } else { + return Err(AppError::Gotify(format!( + "websocket: unsupported scheme in {base_url} (expected http/https)" + ))); + }; + Ok(ws) +} + +/// Build the tungstenite-compatible HTTP request with the bearer token +/// (set-sensitive so reqwest/tungstenite never log it). +fn build_request(url: &str, token: &str) -> AppResult> { + let builder = Request::builder() + .method("GET") + .uri(url) + .header("Host", extract_host(url)) + .header("Authorization", format!("Bearer {token}")) + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()); + builder.body(()).map_err(|e| AppError::Gotify(format!("websocket request: {e}"))) +} + +fn extract_host(url: &str) -> String { + let after = url.splitn(2, "://").nth(1).unwrap_or(""); + after.split('/').next().unwrap_or("").to_string() +} + +/// A single non-streamed connect attempt. Caller is responsible for the +/// surrounding reconnect loop. +pub fn connect_once( + server: &ServerConfig, +) -> AppResult>> { + let url = stream_url(&server.base_url)?; + let req = build_request(&url, &server.token)?; + let (socket, _resp) = connect(req).map_err(|e| AppError::Gotify(format!( + "websocket connect to {url}: {e}" + )))?; + Ok(socket) +} + +/// Run the real-time stream loop forever (or until the surrounding process +/// exits). Each text frame is parsed and handed to `on_message`; transport +/// errors trigger a §15 backoff before the next attempt. +pub fn run_stream_loop(server: ServerConfig, on_message: F) -> ! +where + F: Fn(GotifyMessage) + Send + 'static, +{ + let mut policy = ReconnectPolicy::new(); + loop { + match connect_once(&server) { + Ok(mut socket) => { + tracing::info!(server = %server.base_url, "websocket: connected"); + policy.record_success(); + loop { + match socket.read() { + Ok(Message::Text(text)) => match serde_json::from_str::(&text) { + Ok(msg) => on_message(msg), + Err(e) => tracing::warn!( + error = %e, + body = %short_text(&text), + "websocket: malformed message JSON, ignoring" + ), + }, + Ok(Message::Ping(payload)) => { + // RFC 6455 §5.5.3: respond with Pong carrying the + // same payload. + if let Err(e) = socket.send(Message::Pong(payload)) { + tracing::warn!(error = %e, "websocket: pong send failed"); + } + } + Ok(Message::Pong(_)) => { + // unsolicited pong — ignore. + } + Ok(Message::Close(frame)) => { + tracing::info!(?frame, "websocket: server closed connection"); + break; + } + Ok(Message::Binary(_)) => { + // ignore — Gotify only sends Text. + } + Ok(Message::Frame(_)) => { + // raw frame variant in 0.21; ignore. + } + Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => { + tracing::info!("websocket: closed"); + break; + } + Err(e) => { + tracing::warn!(error = %e, "websocket: read error"); + break; + } + } + } + } + Err(e) => { + tracing::warn!(error = %e, "websocket: connect failed"); + } + } + let delay = policy.next_delay(); + tracing::info!(?delay, attempt = policy.attempts(), "websocket: reconnecting"); + std::thread::sleep(delay); + } +} + +fn short_text(s: &str) -> String { + const MAX: usize = 120; + if s.len() <= MAX { + s.to_string() + } else { + format!("{}…", &s[..MAX]) + } +} \ No newline at end of file diff --git a/tests/gotify_websocket.rs b/tests/gotify_websocket.rs new file mode 100644 index 0000000..38807ba --- /dev/null +++ b/tests/gotify_websocket.rs @@ -0,0 +1,47 @@ +//! WebSocket framing tests (Phase 4). +//! +//! We don't stand up a real WS server in-process — that's Phase 8 perf +//! work. For now we cover the offline pieces: +//! - `StreamEnvelope` JSON parses (one frame in = one envelope out) +//! - The URL helpers (https:// → wss://, host extraction) behave the way +//! the inline `connect` path expects. + +use windows_gotify_client_rust::gotify::websocket::StreamEnvelope; +use windows_gotify_client_rust::gotify::model::GotifyMessage; + +#[test] +fn envelope_parses_minimal_text_frame() { + let json = r#"{"id":99,"appid":2,"message":"hello","title":"","priority":1,"date":"2026-09-19T04:00:00Z"}"#; + let env: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.id, Some(99)); + assert_eq!(env.message, "hello"); +} + +#[test] +fn envelope_tolerates_unknown_fields() { + // Gotify may grow the schema over time. Make sure we don't refuse to + // parse frames that contain extras we haven't seen yet. + let json = r#"{ + "id": 1, + "appid": 1, + "message": "x", + "title": "", + "priority": 0, + "date": "2026-09-19T00:00:00Z", + "extras": { "client::display": { "contentType": "text/markdown" } } + }"#; + let env: StreamEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(env.id, Some(1)); +} + +#[test] +fn gotify_message_parses_what_websocket_sends() { + // Sanity check: what the WebSocket loop hands to the on_message + // callback is the same shape as the /message REST endpoint, so the + // existing models.rs adapter works for both paths. + let json = r#"{"id":42,"appid":3,"message":"build OK","title":"ci","priority":3,"date":"2026-09-19T05:00:00Z"}"#; + let msg: GotifyMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.id, 42); + assert_eq!(msg.title, "ci"); + assert_eq!(msg.priority, 3); +} \ No newline at end of file