Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-requirement/tests/notify.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

97 lines
3.2 KiB
Rust

//! Verifies `RequirementService::set_status` fires the `CompletionNotifier`
//! exactly once on a terminal transition, and not on no-op / non-terminal ones.
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use nomifun_api_types::{CreateRequirementRequest, RequirementStatus};
use nomifun_db::models::RequirementRow;
use nomifun_db::{IRequirementRepository, SqliteRequirementRepository, init_database_memory};
use nomifun_realtime::EventBroadcaster;
use nomifun_requirement::{CompletionNotifier, RequirementEventEmitter, RequirementService};
#[derive(Default)]
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
#[derive(Default)]
struct RecordingNotifier {
ids: Mutex<Vec<i64>>,
}
#[async_trait]
impl CompletionNotifier for RecordingNotifier {
async fn notify_completion(&self, requirement: &RequirementRow) {
self.ids.lock().unwrap().push(requirement.id);
}
}
async fn svc(notifier: Arc<RecordingNotifier>) -> RequirementService {
let db = init_database_memory().await.unwrap();
let repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(db.pool().clone()));
let emitter = RequirementEventEmitter::new(Arc::new(NoopBroadcaster));
Box::leak(Box::new(db));
RequirementService::new(repo, emitter).with_completion_notifier(notifier)
}
fn new_req(tag: &str) -> CreateRequirementRequest {
CreateRequirementRequest {
title: "T".into(),
content: "body".into(),
tag: tag.into(),
order_key: Some("1".into()),
status: None,
created_by: None,
attachments: vec![],
}
}
/// Let the detached `tokio::spawn`(notify) task run.
async fn settle() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn fires_once_on_done() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::Done, Some("note".into()))
.await
.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().as_slice(), std::slice::from_ref(&r.id));
// Re-setting the same terminal status is an idempotent no-op → no extra fire.
let _ = s.set_status(r.id, RequirementStatus::Done, None).await.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn fires_on_failed() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::Failed, Some("oops".into()))
.await
.unwrap();
settle().await;
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn does_not_fire_on_non_terminal() {
let notifier = Arc::new(RecordingNotifier::default());
let s = svc(notifier.clone()).await;
let r = s.create(new_req("alpha")).await.unwrap();
s.set_status(r.id, RequirementStatus::InProgress, None).await.unwrap();
settle().await;
assert!(notifier.ids.lock().unwrap().is_empty());
}