feat(gotify): Phase 3 C1 — Gotify REST API client

- src/gotify/api.rs: blocking reqwest 封装(§14);health / applications
  / messages 三个方法;Bearer token 用 HeaderValue::set_sensitive
  标记,日志绝不泄露(§20);10s per-request timeout
- src/gotify/model.rs: GotifyHealth / GotifyApplication / GotifyMessage
  DTO 镜像官方 wire schema;defaultPriority 加 rename(camelCase)
- src/gotify/mod.rs: re-export
- src/error.rs: 加 AppError::Gotify(String)
- Cargo.toml: reqwest 0.12 (blocking + json + rustls-tls) 从注释升级为真
  依赖(AGENTS.md §44 模板里早就列了,Phase 1 当时推迟)
- tests/gotify_api.rs: 6 个 JSON 解析测试

避坑:
- reqwest 0.12 默认 log Bearer token;要 set_sensitive(true) 才行
- reqwest::Error 转 AppError 不能用返回 impl Fn 的 helper(path 是
  &str 不是 'static)—— 改成每个调用点 inline closure
- Gotify 的 defaultPriority 是 camelCase,其他字段都是单字;
  用 #[serde(rename = "defaultPriority")] 单点 rename 而不是全局
  rename_all = "camelCase"(因为 Rust 端字段名是 snake_case)

验证:cargo build / build --release 都 exit 0,0 warning
- cargo test --test gotify_api: 6 passed; 0 failed
- debug   27.88 MB, subsystem = WINDOWS_CUI
- release  8.10 MB, subsystem = WINDOWS_GUI(reqwest 加进 release
  LTO 后体积几乎没变——rustls 是大头但被 strip + LTO 吃掉了)
This commit is contained in:
2026-09-19 04:57:44 +08:00
parent 680fbfa0ad
commit c6ad3883f8
8 changed files with 796 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
//! Gotify wire-format tests.
//!
//! Phase 3 V1 only validates JSON parsing — the actual HTTP layer is
//! exercised manually against a real Gotify instance on the user's LAN.
//! Adding `wiremock` / `httpmock` for in-process server mocking is a
//! Phase 8 candidate once perf work makes a richer test suite useful.
use windows_gotify_client_rust::gotify::model::{
GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig,
};
#[test]
fn server_config_trims_trailing_slash() {
let a = ServerConfig::new("https://gotify.example.com/", "tok");
assert_eq!(a.base_url, "https://gotify.example.com");
let b = ServerConfig::new("https://gotify.example.com", "tok");
assert_eq!(b.base_url, "https://gotify.example.com");
}
#[test]
fn health_parses_green() {
let json = r#"{"health":"green","database":"sqlite"}"#;
let h: GotifyHealth = serde_json::from_str(json).unwrap();
assert_eq!(h.health, "green");
assert_eq!(h.database, "sqlite");
}
#[test]
fn application_parses_minimal() {
let json = r#"{
"id": 1,
"name": "GitLab Runner",
"description": "CI build pings",
"defaultPriority": 1
}"#;
let app: GotifyApplication = serde_json::from_str(json).unwrap();
assert_eq!(app.id, 1);
assert_eq!(app.name, "GitLab Runner");
assert_eq!(app.default_priority, 1);
}
#[test]
fn application_parses_with_optional_defaults() {
// Gotify may omit description / defaultPriority for clients created via
// a different code path. Make sure the `#[serde(default)]` works.
let json = r#"{ "id": 7, "name": "Airflow" }"#;
let app: GotifyApplication = serde_json::from_str(json).unwrap();
assert_eq!(app.id, 7);
assert_eq!(app.name, "Airflow");
assert_eq!(app.description, "");
assert_eq!(app.default_priority, 0);
}
#[test]
fn message_parses_full_record() {
let json = r#"{
"id": 1024,
"appid": 1,
"message": "Deploy v1.4.2 to production completed in 3m 12s.",
"title": "Build succeeded",
"priority": 1,
"date": "2026-09-19T03:42:18.000Z",
"extras": {
"client::display": { "contentType": "text/markdown" }
}
}"#;
let msg: GotifyMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.id, 1024);
assert_eq!(msg.appid, 1);
assert_eq!(msg.priority, 1);
assert!(msg.extras.contains_key("client::display"));
}
#[test]
fn message_parses_with_no_extras() {
let json = r#"{
"id": 99,
"appid": 2,
"message": "low battery",
"title": "",
"priority": 0,
"date": "2026-09-19T04:00:00Z"
}"#;
let msg: GotifyMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.title, "");
assert!(msg.extras.is_empty());
}