Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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