Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nomifun-webhook"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-auth.workspace = true
|
||||
nomifun-requirement.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
reqwest.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
sqlx = { workspace = true }
|
||||
@@ -0,0 +1,16 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors from outbound webhook delivery. These are surfaced to clients only via
|
||||
/// the explicit `/test` endpoint (as a 502); the completion notifier logs and
|
||||
/// swallows them so a failing webhook never affects requirement state.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WebhookError {
|
||||
#[error("signing failed: {0}")]
|
||||
Sign(String),
|
||||
|
||||
#[error("request failed: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("remote rejected the webhook: {0}")]
|
||||
Remote(String),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Webhook management + AutoWork completion notifications.
|
||||
//!
|
||||
//! - CRUD over reusable outbound webhooks (v1: Lark/飞书 custom bots) and per-tag
|
||||
//! settings (bound webhook + description) layered over the implicit tags.
|
||||
//! - `CompletionNotifierImpl` implements `nomifun_requirement::CompletionNotifier`
|
||||
//! so a requirement reaching a terminal state notifies its tag's bound webhook.
|
||||
|
||||
pub mod error;
|
||||
pub mod notifier;
|
||||
pub mod routes;
|
||||
pub mod sender;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
pub use error::WebhookError;
|
||||
pub use notifier::CompletionNotifierImpl;
|
||||
pub use routes::webhook_routes;
|
||||
pub use sender::{DefaultWebhookSender, WebhookSender};
|
||||
pub use service::WebhookService;
|
||||
pub use state::WebhookRouterState;
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Completion notifier: implements `nomifun_requirement::CompletionNotifier` by
|
||||
//! looking up the requirement's tag → bound webhook and sending a notification.
|
||||
//!
|
||||
//! Dependency direction: this crate depends on `nomifun-requirement` (for the
|
||||
//! trait); `nomifun-requirement` does NOT depend on this crate. Mirrors how
|
||||
//! `nomifun-idmm` implements `nomifun_requirement::IdmmHandle`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_api_types::WebhookPlatform;
|
||||
use nomifun_db::models::RequirementRow;
|
||||
use nomifun_db::{ITagSettingRepository, IWebhookRepository};
|
||||
use nomifun_requirement::CompletionNotifier;
|
||||
|
||||
use crate::sender::WebhookSender;
|
||||
|
||||
/// Truncate a content snippet for the notification card (keeps cards compact).
|
||||
const MAX_CONTENT_CHARS: usize = 500;
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// Human-readable completion status for the 【完成状态】 field.
|
||||
fn status_label(status: &str) -> &'static str {
|
||||
match status {
|
||||
"done" => "已完成 (done)",
|
||||
"failed" => "失败 (failed)",
|
||||
"cancelled" => "已取消 (cancelled)",
|
||||
_ => "完成 (completed)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `status` is in the per-tag allowed event set.
|
||||
pub fn event_allowed(status: &str, events: &[String]) -> bool {
|
||||
events.iter().any(|e| e == status)
|
||||
}
|
||||
|
||||
pub struct CompletionNotifierImpl {
|
||||
tag_settings: Arc<dyn ITagSettingRepository>,
|
||||
webhooks: Arc<dyn IWebhookRepository>,
|
||||
sender: Arc<dyn WebhookSender>,
|
||||
}
|
||||
|
||||
impl CompletionNotifierImpl {
|
||||
pub fn new(
|
||||
tag_settings: Arc<dyn ITagSettingRepository>,
|
||||
webhooks: Arc<dyn IWebhookRepository>,
|
||||
sender: Arc<dyn WebhookSender>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tag_settings,
|
||||
webhooks,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_arc(self) -> Arc<dyn CompletionNotifier> {
|
||||
Arc::new(self)
|
||||
}
|
||||
|
||||
/// Resolve the bound + enabled webhook for `tag` plus its allowed event set,
|
||||
/// if any binding exists.
|
||||
async fn resolve_webhook(&self, tag: &str) -> Option<(nomifun_db::models::WebhookRow, Vec<String>)> {
|
||||
let setting = self.tag_settings.get(tag).await.ok().flatten()?;
|
||||
let events: Vec<String> = setting
|
||||
.notify_events
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let webhook_id = setting.webhook_id?;
|
||||
let webhook = self.webhooks.get_by_id(webhook_id).await.ok().flatten()?;
|
||||
webhook.enabled.then_some((webhook, events))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CompletionNotifier for CompletionNotifierImpl {
|
||||
async fn notify_completion(&self, requirement: &RequirementRow) {
|
||||
let Some((webhook, events)) = self.resolve_webhook(&requirement.tag).await else {
|
||||
return; // no binding / disabled / missing → silent skip
|
||||
};
|
||||
if !event_allowed(&requirement.status, &events) {
|
||||
return; // this event isn't in the tag's allowed set → silent skip
|
||||
}
|
||||
|
||||
// Template: 【需求id】【需求名】【需求内容】【完成状态】【完成记录(报告)】
|
||||
let fields = vec![
|
||||
("需求id".to_string(), requirement.id.to_string()),
|
||||
("需求名".to_string(), requirement.title.clone()),
|
||||
(
|
||||
"需求内容".to_string(),
|
||||
truncate(&requirement.content, MAX_CONTENT_CHARS),
|
||||
),
|
||||
("完成状态".to_string(), status_label(&requirement.status).to_string()),
|
||||
(
|
||||
"完成记录(报告)".to_string(),
|
||||
requirement
|
||||
.completion_note
|
||||
.as_deref()
|
||||
.map(|n| truncate(n, MAX_CONTENT_CHARS))
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
let title = format!("需求{}: {}", status_label(&requirement.status), requirement.title);
|
||||
if let Err(e) = self
|
||||
.sender
|
||||
.send_card(
|
||||
WebhookPlatform::from_db(&webhook.platform),
|
||||
&webhook.url,
|
||||
webhook.secret.as_deref(),
|
||||
&title,
|
||||
&fields,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Best-effort: log + swallow. A failing webhook must never affect
|
||||
// requirement state (and this runs on a detached task anyway).
|
||||
tracing::warn!(
|
||||
webhook_id = %webhook.id,
|
||||
requirement_id = %requirement.id,
|
||||
error = %e,
|
||||
"completion webhook delivery failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::event_allowed;
|
||||
#[test]
|
||||
fn allows_when_status_in_set() {
|
||||
assert!(event_allowed("done", &["done".to_string(), "failed".to_string()]));
|
||||
assert!(!event_allowed("needs_review", &["done".to_string(), "failed".to_string()]));
|
||||
}
|
||||
#[test]
|
||||
fn empty_set_allows_nothing() {
|
||||
assert!(!event_allowed("done", &[]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Webhook HTTP routes. Handlers do request/response transformation only; all
|
||||
//! logic lives in `WebhookService`. Auth is layered externally in nomifun-app
|
||||
//! (mirrors the requirement / idmm routes).
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, CreateWebhookRequest, TagSetting, UpdateWebhookRequest, UpsertTagSettingRequest, Webhook,
|
||||
};
|
||||
use nomifun_auth::CurrentUser;
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::state::WebhookRouterState;
|
||||
|
||||
pub fn webhook_routes(state: WebhookRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/webhooks", get(list_webhooks).post(create_webhook))
|
||||
.route(
|
||||
"/api/webhooks/{id}",
|
||||
get(get_webhook).put(update_webhook).delete(delete_webhook),
|
||||
)
|
||||
.route("/api/webhooks/{id}/test", post(test_webhook))
|
||||
.route("/api/tags/{tag}/settings", get(get_tag_setting).put(upsert_tag_setting))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Parse the `{id}` path segment (always a string on the wire) into the i64
|
||||
/// webhook primary key, surfacing a clean 400 on malformed input.
|
||||
fn parse_id(id: &str) -> Result<i64, AppError> {
|
||||
id.parse::<i64>()
|
||||
.map_err(|_| AppError::BadRequest(format!("invalid webhook id: {id}")))
|
||||
}
|
||||
|
||||
async fn list_webhooks(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<Vec<Webhook>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.list().await?)))
|
||||
}
|
||||
|
||||
async fn get_webhook(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Webhook>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get(parse_id(&id)?).await?)))
|
||||
}
|
||||
|
||||
async fn create_webhook(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<CreateWebhookRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<Webhook>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let created = state.service.create(req).await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(created))))
|
||||
}
|
||||
|
||||
async fn update_webhook(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<UpdateWebhookRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<Webhook>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.update(parse_id(&id)?, req).await?)))
|
||||
}
|
||||
|
||||
async fn delete_webhook(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.delete(parse_id(&id)?).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn test_webhook(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.test(parse_id(&id)?).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn get_tag_setting(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(tag): Path<String>,
|
||||
) -> Result<Json<ApiResponse<TagSetting>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_tag_setting(&tag).await?)))
|
||||
}
|
||||
|
||||
async fn upsert_tag_setting(
|
||||
State(state): State<WebhookRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(tag): Path<String>,
|
||||
body: Result<Json<UpsertTagSettingRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<TagSetting>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.upsert_tag_setting(&tag, req).await?,
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Outbound webhook delivery. v1 supports Lark/飞书 custom bots.
|
||||
//!
|
||||
//! Signing + payload construction are pure functions so they can be unit-tested
|
||||
//! without a live HTTP server; `send_card` performs the actual POST.
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use nomifun_api_types::WebhookPlatform;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::WebhookError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Abstraction over a webhook platform's "send a notification card" operation.
|
||||
/// Kept as a trait so the completion notifier + tests can swap in a mock, and so
|
||||
/// future platforms can be added without touching callers.
|
||||
#[async_trait::async_trait]
|
||||
pub trait WebhookSender: Send + Sync {
|
||||
/// Send a titled card with `(label, value)` field rows to `url`. When
|
||||
/// `secret` is set, the request is signed (Lark 加签). `platform` selects
|
||||
/// the payload shape (Lark interactive card / Slack text / generic HTTP JSON).
|
||||
async fn send_card(
|
||||
&self,
|
||||
platform: WebhookPlatform,
|
||||
url: &str,
|
||||
secret: Option<&str>,
|
||||
title: &str,
|
||||
fields: &[(String, String)],
|
||||
) -> Result<(), WebhookError>;
|
||||
}
|
||||
|
||||
/// Platform-dispatching sender: builds the right payload per platform
|
||||
/// (Lark interactive card / Slack text / generic HTTP JSON) and POSTs it.
|
||||
#[derive(Clone)]
|
||||
pub struct DefaultWebhookSender {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Default for DefaultWebhookSender {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultWebhookSender {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the Lark custom-bot signature: `base64(HMAC-SHA256(key = "{ts}\n{secret}", msg = ""))`.
|
||||
pub fn lark_sign(secret: &str, timestamp: i64) -> Result<String, WebhookError> {
|
||||
let string_to_sign = format!("{timestamp}\n{secret}");
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(string_to_sign.as_bytes()).map_err(|e| WebhookError::Sign(e.to_string()))?;
|
||||
mac.update(b"");
|
||||
let code = mac.finalize().into_bytes();
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(code))
|
||||
}
|
||||
|
||||
/// Build the Lark interactive-card message body (without signing fields).
|
||||
pub fn build_lark_card(title: &str, fields: &[(String, String)]) -> Value {
|
||||
let elements: Vec<Value> = fields
|
||||
.iter()
|
||||
.map(|(label, value)| {
|
||||
json!({
|
||||
"tag": "div",
|
||||
"text": { "tag": "lark_md", "content": format!("**{label}**\n{value}") }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"config": { "wide_screen_mode": true },
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": title },
|
||||
"template": "blue"
|
||||
},
|
||||
"elements": elements
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the full request body, adding `timestamp`/`sign` when a secret is set.
|
||||
pub fn build_lark_body(
|
||||
secret: Option<&str>,
|
||||
timestamp: i64,
|
||||
title: &str,
|
||||
fields: &[(String, String)],
|
||||
) -> Result<Value, WebhookError> {
|
||||
let mut body = build_lark_card(title, fields);
|
||||
if let Some(secret) = secret.filter(|s| !s.is_empty()) {
|
||||
let sign = lark_sign(secret, timestamp)?;
|
||||
body["timestamp"] = json!(timestamp.to_string());
|
||||
body["sign"] = json!(sign);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Build a Slack incoming-webhook body: a single text blob with title + field lines.
|
||||
pub fn build_slack_body(title: &str, fields: &[(String, String)]) -> Value {
|
||||
let mut text = format!("*{title}*");
|
||||
for (label, value) in fields {
|
||||
text.push_str(&format!("\n*{label}*: {value}"));
|
||||
}
|
||||
json!({ "text": text })
|
||||
}
|
||||
|
||||
/// Build a generic HTTP JSON body: structured title + fields so any consumer can parse it.
|
||||
pub fn build_http_body(title: &str, fields: &[(String, String)]) -> Value {
|
||||
let field_objs: Vec<Value> = fields
|
||||
.iter()
|
||||
.map(|(label, value)| json!({ "label": label, "value": value }))
|
||||
.collect();
|
||||
json!({ "title": title, "fields": field_objs })
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebhookSender for DefaultWebhookSender {
|
||||
async fn send_card(
|
||||
&self,
|
||||
platform: WebhookPlatform,
|
||||
url: &str,
|
||||
secret: Option<&str>,
|
||||
title: &str,
|
||||
fields: &[(String, String)],
|
||||
) -> Result<(), WebhookError> {
|
||||
let body = match platform {
|
||||
WebhookPlatform::Lark => {
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
build_lark_body(secret, timestamp, title, fields)?
|
||||
}
|
||||
WebhookPlatform::Slack => build_slack_body(title, fields),
|
||||
WebhookPlatform::Http => build_http_body(title, fields),
|
||||
};
|
||||
let resp = self
|
||||
.client
|
||||
.post(url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WebhookError::Http(e.to_string()))?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(WebhookError::Remote(format!("HTTP {status}: {text}")));
|
||||
}
|
||||
// Lark replies {"code":0,...} (or legacy {"StatusCode":0,...}) on success;
|
||||
// Slack/HTTP treat any 2xx as success (response body is free-form).
|
||||
if matches!(platform, WebhookPlatform::Lark) {
|
||||
let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({}));
|
||||
let code = parsed
|
||||
.get("code")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| parsed.get("StatusCode").and_then(Value::as_i64))
|
||||
.unwrap_or(0);
|
||||
if code != 0 {
|
||||
return Err(WebhookError::Remote(format!("lark code {code}: {text}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_is_deterministic_and_base64() {
|
||||
let a = lark_sign("secret", 1_700_000_000).unwrap();
|
||||
let b = lark_sign("secret", 1_700_000_000).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.is_empty());
|
||||
// valid base64 decodes to 32 bytes (sha256 output)
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(&a).unwrap();
|
||||
assert_eq!(decoded.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_changes_with_timestamp() {
|
||||
let a = lark_sign("secret", 1).unwrap();
|
||||
let b = lark_sign("secret", 2).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_without_secret_has_no_sign() {
|
||||
let fields = [("需求名".to_string(), "build X".to_string())];
|
||||
let body = build_lark_body(None, 123, "title", &fields).unwrap();
|
||||
assert_eq!(body["msg_type"], "interactive");
|
||||
assert!(body.get("sign").is_none());
|
||||
assert!(body.get("timestamp").is_none());
|
||||
let content = body["card"]["elements"][0]["text"]["content"].as_str().unwrap();
|
||||
assert!(content.contains("需求名"));
|
||||
assert!(content.contains("build X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_with_secret_includes_sign_and_timestamp() {
|
||||
let body = build_lark_body(Some("s"), 999, "t", &[]).unwrap();
|
||||
assert_eq!(body["timestamp"], "999");
|
||||
assert!(body["sign"].as_str().is_some_and(|s| !s.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_secret_is_treated_as_unsigned() {
|
||||
let body = build_lark_body(Some(""), 999, "t", &[]).unwrap();
|
||||
assert!(body.get("sign").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_body_has_text_with_title_and_fields() {
|
||||
let fields = [("需求名".to_string(), "build X".to_string())];
|
||||
let body = build_slack_body("标题", &fields);
|
||||
let text = body["text"].as_str().unwrap();
|
||||
assert!(text.contains("标题"));
|
||||
assert!(text.contains("需求名"));
|
||||
assert!(text.contains("build X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_body_is_structured_json() {
|
||||
let fields = [("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())];
|
||||
let body = build_http_body("T", &fields);
|
||||
assert_eq!(body["title"], "T");
|
||||
assert_eq!(body["fields"][0]["label"], "a");
|
||||
assert_eq!(body["fields"][0]["value"], "1");
|
||||
assert_eq!(body["fields"][1]["label"], "b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Business logic for webhook CRUD + per-tag settings. No axum imports.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{
|
||||
CreateWebhookRequest, TagSetting, UpdateWebhookRequest, UpsertTagSettingRequest, Webhook, WebhookPlatform,
|
||||
};
|
||||
use nomifun_common::{AppError, now_ms};
|
||||
use nomifun_db::models::{TagSettingRow, WebhookRow};
|
||||
use nomifun_db::{ITagSettingRepository, IWebhookRepository};
|
||||
|
||||
use crate::sender::WebhookSender;
|
||||
|
||||
/// Map a DB row to the client DTO (dropping the secret; exposing `has_secret`).
|
||||
fn row_to_dto(row: &WebhookRow) -> Webhook {
|
||||
Webhook {
|
||||
id: row.id,
|
||||
name: row.name.clone(),
|
||||
platform: WebhookPlatform::from_db(&row.platform),
|
||||
url: row.url.clone(),
|
||||
description: row.description.clone(),
|
||||
has_secret: row.secret.as_deref().is_some_and(|s| !s.is_empty()),
|
||||
enabled: row.enabled,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_setting_to_dto(row: &TagSettingRow) -> TagSetting {
|
||||
TagSetting {
|
||||
tag: row.tag.clone(),
|
||||
webhook_id: row.webhook_id,
|
||||
description: row.description.clone(),
|
||||
notify_events: row.notify_events.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default notification event set (all three), matching the column default and
|
||||
/// the historical "fire on every completion transition" behavior.
|
||||
fn default_events() -> Vec<String> {
|
||||
vec!["done".into(), "failed".into(), "needs_review".into()]
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebhookService {
|
||||
webhooks: Arc<dyn IWebhookRepository>,
|
||||
tag_settings: Arc<dyn ITagSettingRepository>,
|
||||
sender: Arc<dyn WebhookSender>,
|
||||
}
|
||||
|
||||
impl WebhookService {
|
||||
pub fn new(
|
||||
webhooks: Arc<dyn IWebhookRepository>,
|
||||
tag_settings: Arc<dyn ITagSettingRepository>,
|
||||
sender: Arc<dyn WebhookSender>,
|
||||
) -> Self {
|
||||
Self {
|
||||
webhooks,
|
||||
tag_settings,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhook CRUD ────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<Webhook>, AppError> {
|
||||
let rows = self.webhooks.list_all().await?;
|
||||
Ok(rows.iter().map(row_to_dto).collect())
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: i64) -> Result<Webhook, AppError> {
|
||||
let row = self
|
||||
.webhooks
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
|
||||
Ok(row_to_dto(&row))
|
||||
}
|
||||
|
||||
pub async fn create(&self, req: CreateWebhookRequest) -> Result<Webhook, AppError> {
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name must not be empty".into()));
|
||||
}
|
||||
if req.url.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("url must not be empty".into()));
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut row = WebhookRow {
|
||||
id: 0, // ignored by insert(); the DB assigns the real id
|
||||
name: req.name,
|
||||
platform: req.platform.as_db().to_string(),
|
||||
url: req.url,
|
||||
secret: req.secret.filter(|s| !s.is_empty()),
|
||||
description: req.description,
|
||||
enabled: req.enabled.unwrap_or(true),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
row.id = self.webhooks.insert(&row).await?;
|
||||
Ok(row_to_dto(&row))
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: i64, req: UpdateWebhookRequest) -> Result<Webhook, AppError> {
|
||||
let mut row = self
|
||||
.webhooks
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
|
||||
if let Some(name) = req.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name must not be empty".into()));
|
||||
}
|
||||
row.name = name;
|
||||
}
|
||||
if let Some(url) = req.url {
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("url must not be empty".into()));
|
||||
}
|
||||
row.url = url;
|
||||
}
|
||||
if let Some(platform) = req.platform {
|
||||
row.platform = platform.as_db().to_string();
|
||||
}
|
||||
if let Some(description) = req.description {
|
||||
row.description = description;
|
||||
}
|
||||
// `Some(Some(v))` sets, `Some(None)` clears, `None` keeps current.
|
||||
if let Some(secret) = req.secret {
|
||||
row.secret = secret.filter(|s| !s.is_empty());
|
||||
}
|
||||
if let Some(enabled) = req.enabled {
|
||||
row.enabled = enabled;
|
||||
}
|
||||
row.updated_at = now_ms();
|
||||
self.webhooks.update(&row).await?;
|
||||
Ok(row_to_dto(&row))
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i64) -> Result<(), AppError> {
|
||||
self.webhooks.delete(id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a sample card to verify the endpoint works. Surfaces send errors to
|
||||
/// the caller as a 502 (this is the only path that exposes webhook errors).
|
||||
pub async fn test(&self, id: i64) -> Result<(), AppError> {
|
||||
let row = self
|
||||
.webhooks
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("webhook {id}")))?;
|
||||
let fields = vec![
|
||||
("Nomi".to_string(), "Webhook test message".to_string()),
|
||||
("Webhook".to_string(), row.name.clone()),
|
||||
];
|
||||
self.sender
|
||||
.send_card(
|
||||
WebhookPlatform::from_db(&row.platform),
|
||||
&row.url,
|
||||
row.secret.as_deref(),
|
||||
"Nomi Webhook Test",
|
||||
&fields,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::BadGateway(e.to_string()))
|
||||
}
|
||||
|
||||
// ── Tag settings ────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_tag_setting(&self, tag: &str) -> Result<TagSetting, AppError> {
|
||||
match self.tag_settings.get(tag).await? {
|
||||
Some(row) => Ok(tag_setting_to_dto(&row)),
|
||||
// A tag with no settings row yet → return an empty (unbound) default
|
||||
// so the client always gets a consistent shape.
|
||||
None => Ok(TagSetting {
|
||||
tag: tag.to_string(),
|
||||
webhook_id: None,
|
||||
description: String::new(),
|
||||
notify_events: default_events(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_tag_settings(&self) -> Result<Vec<TagSetting>, AppError> {
|
||||
let rows = self.tag_settings.list_all().await?;
|
||||
Ok(rows.iter().map(tag_setting_to_dto).collect())
|
||||
}
|
||||
|
||||
pub async fn upsert_tag_setting(&self, tag: &str, req: UpsertTagSettingRequest) -> Result<TagSetting, AppError> {
|
||||
if tag.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("tag must not be empty".into()));
|
||||
}
|
||||
// Merge onto the existing row so a partial update keeps other fields.
|
||||
let existing = self.tag_settings.get(tag).await?;
|
||||
let webhook_id = match req.webhook_id {
|
||||
Some(v) => v, // Some(Some)=bind, Some(None)=clear
|
||||
None => existing.as_ref().and_then(|r| r.webhook_id),
|
||||
};
|
||||
// If binding a webhook, verify it exists (clean 400 vs a dangling id).
|
||||
if let Some(wh_id) = webhook_id
|
||||
&& self.webhooks.get_by_id(wh_id).await?.is_none()
|
||||
{
|
||||
return Err(AppError::BadRequest(format!("webhook {wh_id} does not exist")));
|
||||
}
|
||||
let description = req
|
||||
.description
|
||||
.or_else(|| existing.as_ref().map(|r| r.description.clone()))
|
||||
.unwrap_or_default();
|
||||
let events = req
|
||||
.notify_events
|
||||
.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map(|r| r.notify_events.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect())
|
||||
})
|
||||
.unwrap_or_else(default_events);
|
||||
let row = TagSettingRow {
|
||||
tag: tag.to_string(),
|
||||
webhook_id,
|
||||
description,
|
||||
notify_events: events.join(","),
|
||||
updated_at: now_ms(),
|
||||
};
|
||||
self.tag_settings.upsert(&row).await?;
|
||||
Ok(tag_setting_to_dto(&row))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use crate::service::WebhookService;
|
||||
|
||||
/// Router state for the webhook + tag-settings endpoints.
|
||||
#[derive(Clone)]
|
||||
pub struct WebhookRouterState {
|
||||
pub service: WebhookService,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Tests for `CompletionNotifierImpl`: routes a finished requirement to its tag's
|
||||
//! bound + enabled webhook, skips otherwise. Uses real in-memory repos + a mock sender.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebhookPlatform;
|
||||
use nomifun_db::models::{RequirementRow, TagSettingRow, WebhookRow};
|
||||
use nomifun_db::{
|
||||
ITagSettingRepository, IWebhookRepository, SqliteTagSettingRepository, SqliteWebhookRepository,
|
||||
init_database_memory,
|
||||
};
|
||||
use nomifun_requirement::CompletionNotifier;
|
||||
use nomifun_webhook::{CompletionNotifierImpl, WebhookSender};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingSender {
|
||||
calls: Mutex<Vec<Vec<(String, String)>>>, // fields per call
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebhookSender for RecordingSender {
|
||||
async fn send_card(
|
||||
&self,
|
||||
_platform: WebhookPlatform,
|
||||
_url: &str,
|
||||
_secret: Option<&str>,
|
||||
_title: &str,
|
||||
fields: &[(String, String)],
|
||||
) -> Result<(), nomifun_webhook::WebhookError> {
|
||||
self.calls.lock().unwrap().push(fields.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn requirement(tag: &str) -> RequirementRow {
|
||||
RequirementRow {
|
||||
id: 1,
|
||||
title: "Build the thing".into(),
|
||||
content: "Implement feature X".into(),
|
||||
tag: tag.into(),
|
||||
order_key: "1".into(),
|
||||
sort_seq: "00000001".into(),
|
||||
status: "done".into(),
|
||||
priority: 0,
|
||||
completion_note: Some("did it".into()),
|
||||
owner_session_id: None,
|
||||
owner_kind: None,
|
||||
claimed_at: None,
|
||||
lease_expires_at: None,
|
||||
started_at: None,
|
||||
completed_at: Some(1),
|
||||
attempt_count: 1,
|
||||
created_by: "user".into(),
|
||||
extra: "{}".into(),
|
||||
created_at: 0,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
webhooks: Arc<dyn IWebhookRepository>,
|
||||
tags: Arc<dyn ITagSettingRepository>,
|
||||
sender: Arc<RecordingSender>,
|
||||
}
|
||||
|
||||
async fn ctx() -> Ctx {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let webhooks: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
|
||||
let tags: Arc<dyn ITagSettingRepository> = Arc::new(SqliteTagSettingRepository::new(db.pool().clone()));
|
||||
Box::leak(Box::new(db));
|
||||
Ctx {
|
||||
webhooks,
|
||||
tags,
|
||||
sender: Arc::new(RecordingSender::default()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_webhook(ctx: &Ctx, enabled: bool) -> i64 {
|
||||
ctx.webhooks
|
||||
.insert(&WebhookRow {
|
||||
id: 0, // ignored by insert(); DB assigns the real id
|
||||
name: "bot".into(),
|
||||
platform: "lark".into(),
|
||||
url: "https://example.com/hook".into(),
|
||||
secret: None,
|
||||
description: String::new(),
|
||||
enabled,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn bind_tag(ctx: &Ctx, tag: &str, webhook_id: Option<i64>) {
|
||||
ctx.tags
|
||||
.upsert(&TagSettingRow {
|
||||
tag: tag.into(),
|
||||
webhook_id,
|
||||
description: String::new(),
|
||||
notify_events: "done,failed,needs_review".into(),
|
||||
updated_at: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn notifier(ctx: &Ctx) -> CompletionNotifierImpl {
|
||||
CompletionNotifierImpl::new(ctx.tags.clone(), ctx.webhooks.clone(), ctx.sender.clone())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifies_bound_enabled_webhook_with_template_fields() {
|
||||
let ctx = ctx().await;
|
||||
let wh_id = add_webhook(&ctx, true).await;
|
||||
bind_tag(&ctx, "alpha", Some(wh_id)).await;
|
||||
|
||||
notifier(&ctx).notify_completion(&requirement("alpha")).await;
|
||||
|
||||
let calls = ctx.sender.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "bound + enabled → one send");
|
||||
let labels: Vec<&str> = calls[0].iter().map(|(l, _)| l.as_str()).collect();
|
||||
// Template: 【需求id】【需求名】【需求内容】【完成状态】【完成记录(报告)】
|
||||
assert!(labels.contains(&"需求id"));
|
||||
assert!(labels.contains(&"需求名"));
|
||||
assert!(labels.contains(&"需求内容"));
|
||||
assert!(labels.contains(&"完成状态"));
|
||||
assert!(labels.contains(&"完成记录(报告)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_tag_unbound() {
|
||||
let ctx = ctx().await;
|
||||
add_webhook(&ctx, true).await;
|
||||
// no bind_tag → tag "alpha" has no setting
|
||||
notifier(&ctx).notify_completion(&requirement("alpha")).await;
|
||||
assert!(ctx.sender.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_webhook_disabled() {
|
||||
let ctx = ctx().await;
|
||||
let wh_id = add_webhook(&ctx, false).await; // disabled
|
||||
bind_tag(&ctx, "alpha", Some(wh_id)).await;
|
||||
notifier(&ctx).notify_completion(&requirement("alpha")).await;
|
||||
assert!(ctx.sender.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_binding_has_no_webhook() {
|
||||
let ctx = ctx().await;
|
||||
bind_tag(&ctx, "alpha", None).await; // setting exists but no webhook bound
|
||||
notifier(&ctx).notify_completion(&requirement("alpha")).await;
|
||||
assert!(ctx.sender.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Integration tests for `WebhookService` (CRUD + tag settings + test) using a
|
||||
//! real in-memory DB and a recording mock sender.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::{CreateWebhookRequest, UpdateWebhookRequest, UpsertTagSettingRequest, WebhookPlatform};
|
||||
use nomifun_db::{
|
||||
ITagSettingRepository, IWebhookRepository, SqliteTagSettingRepository, SqliteWebhookRepository,
|
||||
init_database_memory,
|
||||
};
|
||||
use nomifun_webhook::{WebhookSender, WebhookService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockSender {
|
||||
calls: Mutex<Vec<(String, String)>>, // (url, title)
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WebhookSender for MockSender {
|
||||
async fn send_card(
|
||||
&self,
|
||||
_platform: WebhookPlatform,
|
||||
url: &str,
|
||||
_secret: Option<&str>,
|
||||
title: &str,
|
||||
_fields: &[(String, String)],
|
||||
) -> Result<(), nomifun_webhook::WebhookError> {
|
||||
self.calls.lock().unwrap().push((url.to_string(), title.to_string()));
|
||||
if self.fail {
|
||||
return Err(nomifun_webhook::WebhookError::Remote("boom".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn svc(sender: Arc<dyn WebhookSender>) -> WebhookService {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let webhooks: Arc<dyn IWebhookRepository> = Arc::new(SqliteWebhookRepository::new(db.pool().clone()));
|
||||
let tags: Arc<dyn ITagSettingRepository> = Arc::new(SqliteTagSettingRepository::new(db.pool().clone()));
|
||||
Box::leak(Box::new(db));
|
||||
WebhookService::new(webhooks, tags, sender)
|
||||
}
|
||||
|
||||
fn create_req() -> CreateWebhookRequest {
|
||||
CreateWebhookRequest {
|
||||
name: "Team bot".into(),
|
||||
url: "https://open.feishu.cn/open-apis/bot/v2/hook/abc".into(),
|
||||
platform: WebhookPlatform::Lark,
|
||||
description: "notify".into(),
|
||||
secret: Some("s3cr3t".into()),
|
||||
enabled: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_list_update_delete_and_secret_is_hidden() {
|
||||
let s = svc(Arc::new(MockSender::default())).await;
|
||||
|
||||
let created = s.create(create_req()).await.unwrap();
|
||||
assert_eq!(created.name, "Team bot");
|
||||
// secret must never be echoed; has_secret signals presence.
|
||||
assert!(created.has_secret);
|
||||
assert!(created.id > 0, "DB assigns a positive autoincrement id");
|
||||
|
||||
let list = s.list().await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
let updated = s
|
||||
.update(
|
||||
created.id,
|
||||
UpdateWebhookRequest {
|
||||
name: Some("Renamed".into()),
|
||||
enabled: Some(false),
|
||||
secret: Some(None), // clear the secret
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.name, "Renamed");
|
||||
assert!(!updated.enabled);
|
||||
assert!(!updated.has_secret, "secret cleared via Some(None)");
|
||||
|
||||
s.delete(created.id).await.unwrap();
|
||||
assert!(s.list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_validates_name_and_url() {
|
||||
let s = svc(Arc::new(MockSender::default())).await;
|
||||
let mut bad = create_req();
|
||||
bad.name = " ".into();
|
||||
assert!(s.create(bad).await.is_err());
|
||||
let mut bad = create_req();
|
||||
bad.url = "".into();
|
||||
assert!(s.create(bad).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sends_card_and_propagates_failure() {
|
||||
// success
|
||||
let ok_sender = Arc::new(MockSender::default());
|
||||
let s = svc(ok_sender.clone()).await;
|
||||
let wh = s.create(create_req()).await.unwrap();
|
||||
s.test(wh.id).await.unwrap();
|
||||
assert_eq!(ok_sender.calls.lock().unwrap().len(), 1);
|
||||
|
||||
// failure → BadGateway
|
||||
let fail_sender = Arc::new(MockSender {
|
||||
fail: true,
|
||||
..Default::default()
|
||||
});
|
||||
let s2 = svc(fail_sender).await;
|
||||
let wh2 = s2.create(create_req()).await.unwrap();
|
||||
let err = s2.test(wh2.id).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadGateway(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_setting_upsert_validates_webhook_exists() {
|
||||
let s = svc(Arc::new(MockSender::default())).await;
|
||||
// binding a non-existent webhook → BadRequest
|
||||
let err = s
|
||||
.upsert_tag_setting(
|
||||
"alpha",
|
||||
UpsertTagSettingRequest {
|
||||
webhook_id: Some(Some(999_999)),
|
||||
description: Some("x".into()),
|
||||
notify_events: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
|
||||
|
||||
// create a webhook, then bind it
|
||||
let wh = s.create(create_req()).await.unwrap();
|
||||
let setting = s
|
||||
.upsert_tag_setting(
|
||||
"alpha",
|
||||
UpsertTagSettingRequest {
|
||||
webhook_id: Some(Some(wh.id)),
|
||||
description: Some("queue alpha".into()),
|
||||
notify_events: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(setting.webhook_id, Some(wh.id));
|
||||
assert_eq!(setting.description, "queue alpha");
|
||||
|
||||
// get an unset tag → empty default shape
|
||||
let empty = s.get_tag_setting("never-set").await.unwrap();
|
||||
assert_eq!(empty.tag, "never-set");
|
||||
assert!(empty.webhook_id.is_none());
|
||||
|
||||
// partial update keeps webhook binding when only description changes
|
||||
let only_desc = s
|
||||
.upsert_tag_setting(
|
||||
"alpha",
|
||||
UpsertTagSettingRequest {
|
||||
webhook_id: None,
|
||||
description: Some("changed".into()),
|
||||
notify_events: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(only_desc.webhook_id, Some(wh.id));
|
||||
assert_eq!(only_desc.description, "changed");
|
||||
|
||||
// clear the binding via Some(None)
|
||||
let cleared = s
|
||||
.upsert_tag_setting(
|
||||
"alpha",
|
||||
UpsertTagSettingRequest {
|
||||
webhook_id: Some(None),
|
||||
description: None,
|
||||
notify_events: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cleared.webhook_id.is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user