Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,321 @@
use nomifun_common::now_ms;
use nomifun_db::models::{MailboxMessageRow, TeamAgentRow, TeamRow, TeamTaskRow};
use nomifun_db::{DbError, ITeamRepository, UpdateTaskParams, UpdateTeamAgentParams, UpdateTeamParams};
use std::sync::Mutex;
/// In-memory backing store for [`MockTeamRepo`].
///
/// Mirrors the post-primary-key-redesign schema: `team_agents` and
/// `team_task_deps` are first-class tables (was `teams.agents` /
/// `team_tasks.blocked_by`/`blocks` JSON arrays), and `mailbox.id` is an
/// autoincrement `i64`.
#[derive(Default)]
pub struct MockState {
pub teams: Vec<TeamRow>,
pub team_agents: Vec<TeamAgentRow>,
pub messages: Vec<MailboxMessageRow>,
pub next_message_id: i64,
pub tasks: Vec<TeamTaskRow>,
/// Dependency edges: `(blocker_task_id, blocked_task_id)`.
pub task_deps: Vec<(String, String)>,
}
pub struct MockTeamRepo {
pub state: Mutex<MockState>,
}
impl Default for MockTeamRepo {
fn default() -> Self {
Self::new()
}
}
impl MockTeamRepo {
pub fn new() -> Self {
Self {
state: Mutex::new(MockState {
next_message_id: 1,
..MockState::default()
}),
}
}
}
#[async_trait::async_trait]
impl ITeamRepository for MockTeamRepo {
// ── Team CRUD ───────────────────────────────────────────────────
async fn create_team(&self, row: &TeamRow) -> Result<(), DbError> {
self.state.lock().unwrap().teams.push(row.clone());
Ok(())
}
async fn list_teams(&self) -> Result<Vec<TeamRow>, DbError> {
Ok(self.state.lock().unwrap().teams.clone())
}
async fn get_team(&self, id: &str) -> Result<Option<TeamRow>, DbError> {
Ok(self.state.lock().unwrap().teams.iter().find(|t| t.id == id).cloned())
}
async fn update_team(&self, id: &str, params: &UpdateTeamParams) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
let team = state
.teams
.iter_mut()
.find(|t| t.id == id)
.ok_or_else(|| DbError::NotFound(id.to_owned()))?;
if let Some(ref name) = params.name {
team.name = name.clone();
}
if let Some(ref lead) = params.lead_agent_id {
team.lead_agent_id = Some(lead.clone());
}
team.updated_at = now_ms();
Ok(())
}
async fn delete_team(&self, id: &str) -> Result<(), DbError> {
// Emulate the FK ON DELETE CASCADE chain.
let mut state = self.state.lock().unwrap();
state.teams.retain(|t| t.id != id);
state.team_agents.retain(|a| a.team_id != id);
state.messages.retain(|m| m.team_id != id);
let task_ids: Vec<String> = state
.tasks
.iter()
.filter(|t| t.team_id == id)
.map(|t| t.id.clone())
.collect();
state.tasks.retain(|t| t.team_id != id);
state
.task_deps
.retain(|(blocker, blocked)| !task_ids.contains(blocker) && !task_ids.contains(blocked));
Ok(())
}
// ── Team agents (was teams.agents JSON array) ─────────────────────
async fn create_team_agent(&self, row: &TeamAgentRow) -> Result<(), DbError> {
self.state.lock().unwrap().team_agents.push(row.clone());
Ok(())
}
async fn list_team_agents(&self, team_id: &str) -> Result<Vec<TeamAgentRow>, DbError> {
let state = self.state.lock().unwrap();
let mut agents: Vec<TeamAgentRow> = state
.team_agents
.iter()
.filter(|a| a.team_id == team_id)
.cloned()
.collect();
agents.sort_by(|a, b| a.sort_order.cmp(&b.sort_order).then_with(|| a.slot_id.cmp(&b.slot_id)));
Ok(agents)
}
async fn get_team_agent(&self, slot_id: &str) -> Result<Option<TeamAgentRow>, DbError> {
Ok(self
.state
.lock()
.unwrap()
.team_agents
.iter()
.find(|a| a.slot_id == slot_id)
.cloned())
}
async fn update_team_agent(&self, slot_id: &str, params: &UpdateTeamAgentParams) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
let agent = state
.team_agents
.iter_mut()
.find(|a| a.slot_id == slot_id)
.ok_or_else(|| DbError::NotFound(slot_id.to_owned()))?;
if let Some(ref v) = params.name {
agent.name = v.clone();
}
if let Some(ref v) = params.role {
agent.role = v.clone();
}
if let Some(ref v) = params.conversation_id {
agent.conversation_id = Some(v.clone());
}
if let Some(ref v) = params.backend {
agent.backend = v.clone();
}
if let Some(ref v) = params.model {
agent.model = v.clone();
}
if let Some(ref v) = params.custom_agent_id {
agent.custom_agent_id = Some(v.clone());
}
if let Some(ref v) = params.status {
agent.status = Some(v.clone());
}
if let Some(ref v) = params.conversation_type {
agent.conversation_type = Some(v.clone());
}
if let Some(ref v) = params.cli_path {
agent.cli_path = Some(v.clone());
}
if let Some(v) = params.sort_order {
agent.sort_order = v;
}
Ok(())
}
async fn rename_team_agent(&self, slot_id: &str, name: &str) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
let agent = state
.team_agents
.iter_mut()
.find(|a| a.slot_id == slot_id)
.ok_or_else(|| DbError::NotFound(slot_id.to_owned()))?;
agent.name = name.to_owned();
Ok(())
}
async fn remove_team_agent(&self, slot_id: &str) -> Result<(), DbError> {
self.state.lock().unwrap().team_agents.retain(|a| a.slot_id != slot_id);
Ok(())
}
// ── Mailbox ─────────────────────────────────────────────────────
async fn write_message(&self, row: &MailboxMessageRow) -> Result<i64, DbError> {
let mut state = self.state.lock().unwrap();
let id = state.next_message_id;
state.next_message_id += 1;
let mut stored = row.clone();
stored.id = id;
state.messages.push(stored);
Ok(id)
}
async fn read_unread_and_mark(&self, team_id: &str, to_agent_id: &str) -> Result<Vec<MailboxMessageRow>, DbError> {
let mut state = self.state.lock().unwrap();
let mut result = vec![];
for msg in &mut state.messages {
if msg.team_id == team_id && msg.to_agent_id == to_agent_id && !msg.read {
msg.read = true;
result.push(msg.clone());
}
}
Ok(result)
}
async fn peek_unread(&self, team_id: &str, to_agent_id: &str) -> Result<Vec<MailboxMessageRow>, DbError> {
let state = self.state.lock().unwrap();
let result = state
.messages
.iter()
.filter(|m| m.team_id == team_id && m.to_agent_id == to_agent_id && !m.read)
.cloned()
.collect();
Ok(result)
}
async fn mark_read_batch(&self, ids: &[i64]) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
for msg in &mut state.messages {
if ids.contains(&msg.id) {
msg.read = true;
}
}
Ok(())
}
async fn get_history(
&self,
team_id: &str,
to_agent_id: &str,
limit: Option<i64>,
) -> Result<Vec<MailboxMessageRow>, DbError> {
let state = self.state.lock().unwrap();
let iter = state
.messages
.iter()
.filter(|m| m.team_id == team_id && m.to_agent_id == to_agent_id);
let msgs: Vec<_> = match limit {
Some(n) => iter.take(n as usize).cloned().collect(),
None => iter.cloned().collect(),
};
Ok(msgs)
}
// ── Tasks ────────────────────────────────────────────────────────
async fn create_task(&self, row: &TeamTaskRow) -> Result<(), DbError> {
self.state.lock().unwrap().tasks.push(row.clone());
Ok(())
}
async fn find_task_by_id(&self, team_id: &str, task_id: &str) -> Result<Option<TeamTaskRow>, DbError> {
let state = self.state.lock().unwrap();
let found = state
.tasks
.iter()
.find(|t| t.team_id == team_id && t.id == task_id)
.cloned();
Ok(found)
}
async fn update_task(&self, task_id: &str, params: &UpdateTaskParams) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
let task = state
.tasks
.iter_mut()
.find(|t| t.id == task_id)
.ok_or_else(|| DbError::NotFound(task_id.to_owned()))?;
if let Some(ref s) = params.status {
task.status = s.clone();
}
if let Some(ref d) = params.description {
task.description = Some(d.clone());
}
if let Some(ref o) = params.owner {
task.owner = Some(o.clone());
}
if let Some(ref m) = params.metadata {
task.metadata = Some(m.clone());
}
task.updated_at = now_ms();
Ok(())
}
async fn list_tasks(&self, team_id: &str) -> Result<Vec<TeamTaskRow>, DbError> {
let state = self.state.lock().unwrap();
let tasks = state.tasks.iter().filter(|t| t.team_id == team_id).cloned().collect();
Ok(tasks)
}
// ── Task dependencies (was blocked_by/blocks JSON arrays) ─────────
async fn add_task_dep(&self, blocker_task_id: &str, blocked_task_id: &str) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
let edge = (blocker_task_id.to_owned(), blocked_task_id.to_owned());
if !state.task_deps.contains(&edge) {
state.task_deps.push(edge);
}
Ok(())
}
async fn remove_task_dep(&self, blocker_task_id: &str, blocked_task_id: &str) -> Result<(), DbError> {
let mut state = self.state.lock().unwrap();
state
.task_deps
.retain(|(blocker, blocked)| !(blocker == blocker_task_id && blocked == blocked_task_id));
Ok(())
}
async fn list_blockers(&self, task_id: &str) -> Result<Vec<String>, DbError> {
let state = self.state.lock().unwrap();
Ok(state
.task_deps
.iter()
.filter(|(_, blocked)| blocked == task_id)
.map(|(blocker, _)| blocker.clone())
.collect())
}
async fn list_blocking(&self, task_id: &str) -> Result<Vec<String>, DbError> {
let state = self.state.lock().unwrap();
Ok(state
.task_deps
.iter()
.filter(|(blocker, _)| blocker == task_id)
.map(|(_, blocked)| blocked.clone())
.collect())
}
}
@@ -0,0 +1,338 @@
//! End-to-end smoke tests for the team subsystem.
//!
//! **Purpose:** guard against the "agent claims a tool works but it is an
//! empty shell" failure mode by exercising each user-visible capability
//! through its real wiring (TCP MCP server, mailbox, task board, scheduler)
//! and asserting the observable side effect — not just a success return
//! code.
//!
//! **Scenario status:**
//! - scenario 1 (create team → lead → MCP tools available) — `todo!()`,
//! `#[ignore]`. Unblocks when `spawn_agent` + MCP wiring lands.
//! - scenario 2 (`team_spawn_agent` creates a real session) — `todo!()`,
//! `#[ignore]`. Unblocks when `spawn_agent` is implemented
//! (see W5-D29a-* modules).
//! - scenario 3 (shutdown full protocol) — `todo!()`, `#[ignore]`. Unblocks
//! when shutdown_agent / shutdown_approved mailbox wiring lands.
//! - scenario 4 (crash → testament → leader wake) — `todo!()`, `#[ignore]`.
//! Unblocks when the crash handler is wired into the stream pipeline.
//! - scenario 5 (MCP tool execution is not a no-op) — **runs now**. Uses
//! only pieces that already exist (mailbox + task board + TeamMcpServer)
//! and is the first real e2e guard.
//!
//! All ignored scenarios must stay compiling so the scaffold itself never
//! rots between waves.
mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
use nomifun_team::mcp::protocol::{read_frame, write_frame};
use nomifun_team::{Mailbox, TaskBoard, TeamAgent, TeamMcpServer, TeammateManager, TeammateRole};
use serde_json::{Value, json};
use tokio::net::TcpStream;
// ---------------------------------------------------------------------------
// Shared helpers — local to this file to avoid touching common/mod.rs and
// keep the scaffold self-contained. If more tests start sharing these,
// promote to common/e2e_helpers.rs.
// ---------------------------------------------------------------------------
struct NullBroadcaster;
impl EventBroadcaster for NullBroadcaster {
fn broadcast(&self, _msg: WebSocketMessage<Value>) {}
}
/// Concrete handle returned by [`setup_team_with_lead`]. Holds every piece
/// a smoke test might need to assert a side effect.
struct SmokeEnv {
server: TeamMcpServer,
mailbox: Arc<Mailbox>,
task_board: Arc<TaskBoard>,
repo: Arc<MockTeamRepo>,
#[allow(dead_code)]
scheduler: Arc<TeammateManager>,
team_id: String,
lead_slot_id: String,
worker_slot_id: String,
auth_token: String,
}
/// Build a 2-agent team (lead + worker) wired through a real
/// `TeamMcpServer` listening on a random port, against an in-memory mock
/// team repo. Does not spin up a `TeamSessionService`, ACP agents, or
/// backends — those are exercised in the scenarios that need them.
async fn setup_team_with_lead() -> SmokeEnv {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo.clone()));
let broadcaster: Arc<dyn EventBroadcaster> = Arc::new(NullBroadcaster);
let team_id = "smoke-team".to_string();
let lead_slot_id = "lead-1".to_string();
let worker_slot_id = "worker-1".to_string();
let agents = vec![
TeamAgent {
slot_id: lead_slot_id.clone(),
name: "Leader".into(),
role: TeammateRole::Lead,
conversation_id: "conv-lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
},
TeamAgent {
slot_id: worker_slot_id.clone(),
name: "Worker".into(),
role: TeammateRole::Teammate,
conversation_id: "conv-worker".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
},
];
let scheduler = Arc::new(TeammateManager::new(
team_id.clone(),
&agents,
mailbox.clone(),
task_board.clone(),
broadcaster.clone(),
));
let auth_token = "smoke-token".to_string();
let server = TeamMcpServer::start(
auth_token.clone(),
scheduler.clone(),
team_id.clone(),
broadcaster,
std::sync::Weak::new(),
)
.await
.unwrap();
SmokeEnv {
server,
mailbox,
task_board,
repo,
scheduler,
team_id,
lead_slot_id,
worker_slot_id,
auth_token,
}
}
/// Connect to the MCP server, perform `initialize` as `slot_id`, and
/// return the authenticated stream.
async fn mcp_connect(env: &SmokeEnv, slot_id: &str) -> TcpStream {
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
.await
.expect("tcp connect to TeamMcpServer");
let init_req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"auth_token": env.auth_token,
"slot_id": slot_id,
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "smoke-test", "version": "1.0" }
}
});
mcp_send(&mut stream, &init_req).await;
let resp = mcp_recv(&mut stream).await;
assert!(
resp["result"]["serverInfo"]["name"].is_string(),
"initialize failed: {resp}"
);
stream
}
/// Send a JSON-RPC `tools/call` and return the raw response envelope.
async fn mcp_call(stream: &mut TcpStream, id: u64, tool: &str, args: Value) -> Value {
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": { "name": tool, "arguments": args }
});
mcp_send(stream, &req).await;
mcp_recv(stream).await
}
async fn mcp_send(stream: &mut TcpStream, req: &Value) {
let bytes = serde_json::to_vec(req).unwrap();
write_frame(stream, &bytes).await.unwrap();
}
async fn mcp_recv(stream: &mut TcpStream) -> Value {
let frame = read_frame(stream).await.unwrap();
serde_json::from_slice(&frame).unwrap()
}
fn is_error_response(resp: &Value) -> bool {
resp["result"]["isError"].as_bool().unwrap_or(false)
}
/// Assert the given agent's mailbox contains at least one message whose
/// content includes `needle`. Reads the DB-level history so it does not
/// mutate the unread flag (unlike `read_unread`).
async fn assert_mailbox_contains(mailbox: &Mailbox, team_id: &str, agent_id: &str, needle: &str) {
let history = mailbox
.get_history(team_id, agent_id, None)
.await
.expect("mailbox.get_history");
assert!(
history.iter().any(|m| m.content.contains(needle)),
"expected mailbox[{team_id}/{agent_id}] to contain {needle:?}, got {history:?}"
);
}
// ===========================================================================
// Scenario 1: create team → lead agent exists → MCP tools available
// ===========================================================================
/// User story: "I create a team; its lead is ready and the MCP surface
/// that lead will drive is actually wired (not an empty shell)."
///
/// Flow:
/// 1. `TeamSessionService::create_team` with a lead + one worker.
/// 2. Assert the returned team has a `lead_agent_id` and two agents.
/// 3. Assert `TeamMcpServer` is started for that team (ensure_session).
/// 4. `tools/list` returns the full 10-tool surface.
/// 5. `team_members` returns both agents.
#[tokio::test]
#[ignore = "unblocks when TeamSessionService e2e wiring is ready (spawn + ensure_session over real DB)"]
async fn smoke_create_team_and_verify_mcp_tools() {
todo!("scenario 1: fill once spawn_agent / ensure_session end-to-end is merged");
}
// ===========================================================================
// Scenario 2: team_spawn_agent actually creates a new agent session
// ===========================================================================
/// User story: "The lead calls `team_spawn_agent`; a real new agent shows
/// up in the team, has its own conversation row, and has a welcome
/// message in its mailbox — not a success return with no side effect."
///
/// Flow:
/// 1. Create a team with only a lead.
/// 2. Lead calls `team_spawn_agent(name=Helper, role=worker, backend=claude)`.
/// 3. `team_members` includes the new Helper.
/// 4. Conversation repo has a row for the new agent's conversation_id.
/// 5. Helper's mailbox has the welcome / kickoff message.
#[tokio::test]
#[ignore = "unblocks when W5-D29a-* spawn_agent lands"]
async fn smoke_spawn_agent_creates_real_session() {
todo!("scenario 2: fill once team_spawn_agent persists agent + conversation + welcome mail");
}
// ===========================================================================
// Scenario 3: shutdown agent — full request/approval protocol
// ===========================================================================
/// User story: "The lead asks a worker to shut down; the worker is
/// notified, approves, actually leaves the team, and the WS event is
/// broadcast so the UI can refresh."
///
/// Flow:
/// 1. Create team, spawn worker.
/// 2. Lead MCP-calls `team_shutdown_agent(slot_id=worker)`.
/// 3. Worker's mailbox receives a `shutdown_request`.
/// 4. Worker replies `shutdown_approved` via `team_send_message`.
/// 5. Worker is removed from the team roster.
/// 6. `team.agent.removed` WebSocket event is broadcast.
#[tokio::test]
#[ignore = "unblocks when shutdown_request/approved round-trip is wired (W5-D30a/b/c/d series)"]
async fn smoke_shutdown_agent_full_protocol() {
todo!("scenario 3: fill once shutdown round-trip + team.agent.removed event are wired");
}
// ===========================================================================
// Scenario 4: agent crash → testament → leader wake
// ===========================================================================
/// User story: "If a worker crashes mid-task, the lead gets a testament
/// mailbox message and is woken up to react — no silent failure."
///
/// Flow:
/// 1. Create team, spawn worker.
/// 2. Inject an Error stream chunk into the worker's agent manager.
/// 3. Lead's mailbox receives a crash testament.
/// 4. Worker's status transitions to `Error`.
/// 5. Lead is woken (wake_lock acquired / wake payload built).
#[tokio::test]
#[ignore = "unblocks when crash_detection is wired into the stream pipeline with real AcpAgentManager"]
async fn smoke_agent_crash_recovery() {
todo!("scenario 4: fill once crash detection → testament → wake lead is wired");
}
// ===========================================================================
// Scenario 5: MCP tool execution is not a no-op
// ===========================================================================
//
// This is the anchor scenario that guards against the core failure mode
// the user called out: a tool returning `success` with no observable side
// effect. It only uses pieces that already exist (mailbox + task board +
// TeamMcpServer), so it runs in CI today.
#[tokio::test]
async fn smoke_mcp_tool_execution_not_noop() {
let env = setup_team_with_lead().await;
let mut stream = mcp_connect(&env, &env.lead_slot_id).await;
// --- team_send_message → mailbox side effect -------------------------
let msg_resp = mcp_call(
&mut stream,
10,
"team_send_message",
json!({ "to": env.worker_slot_id, "message": "hello worker" }),
)
.await;
assert!(
!is_error_response(&msg_resp),
"team_send_message returned error: {msg_resp}"
);
// Guard against the exact failure mode: success envelope, nothing written.
assert_mailbox_contains(&env.mailbox, &env.team_id, &env.worker_slot_id, "hello worker").await;
// --- team_task_create → task board side effect -----------------------
let task_resp = mcp_call(
&mut stream,
11,
"team_task_create",
json!({ "subject": "Smoke test subject" }),
)
.await;
assert!(
!is_error_response(&task_resp),
"team_task_create returned error: {task_resp}"
);
let tasks = env.task_board.list_tasks(&env.team_id).await.unwrap();
assert!(
tasks.iter().any(|t| t.subject == "Smoke test subject"),
"team_task_create did not persist task, got {tasks:?}"
);
// --- repo-level cross-check: mailbox/task rows actually hit storage --
// Even if the service layer lies, the repo-level mock's state is the
// ground truth for "did data move through the stack".
let repo_state = env.repo.state.lock().unwrap();
assert!(!repo_state.messages.is_empty(), "no mailbox rows reached the repo");
assert!(!repo_state.tasks.is_empty(), "no task rows reached the repo");
drop(repo_state);
env.server.stop();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,229 @@
//! Black-box integration tests for `Mailbox` service.
//!
//! Exercises the service layer against a real SQLite database.
//!
//! Covers test-plan items:
//! - MW-1..MW-3 (write messages: text, idle_notification, shutdown_request)
//! - MR-1..MR-4 (atomic read + mark, re-read empty, no messages)
//! - MH-1..MH-3 (history query with/without limit)
//! - MD-1..MD-2 (delete by team, isolation)
use std::sync::Arc;
use nomifun_db::{ITeamRepository, SqliteTeamRepository, init_database_memory};
use nomifun_team::{Mailbox, MailboxMessageType};
/// Returns the `Mailbox` service plus the repo (so cascade-delete tests can
/// drop a team) and the owning `Database`. Seeds the `users` row and the two
/// teams (`t1`, `t2`) the tests write into — the `mailbox.team_id` FK
/// (CASCADE, spec §5.4) now requires the parent team to exist.
async fn setup() -> (Mailbox, Arc<dyn ITeamRepository>, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
for team_id in ["t1", "t2"] {
sqlx::query(
"INSERT INTO teams (id, user_id, name, workspace, workspace_mode, agents_version, created_at, updated_at) \
VALUES (?, 'user_1', 'Test Team', '/tmp/ws', 'shared', '1.0.0', 0, 0)",
)
.bind(team_id)
.execute(db.pool())
.await
.unwrap();
}
let repo = Arc::new(SqliteTeamRepository::new(db.pool().clone())) as Arc<dyn ITeamRepository>;
(Mailbox::new(repo.clone()), repo, db)
}
// -- MW: Write messages -------------------------------------------------------
#[tokio::test]
async fn mw1_write_text_message() {
let (mailbox, _repo, _db) = setup().await;
let msg = mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "hello", None)
.await
.unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::Message);
assert_eq!(msg.content, "hello");
assert!(!msg.read);
// `mailbox.id` is now an autoincrement i64 (spec §2 row 19) returned by
// `write_message` via `last_insert_rowid()`.
assert!(msg.id > 0);
}
#[tokio::test]
async fn mw2_write_idle_notification_with_summary() {
let (mailbox, _repo, _db) = setup().await;
let msg = mailbox
.write(
"t1",
"lead",
"a1",
MailboxMessageType::IdleNotification,
"done",
Some("Task finished"),
)
.await
.unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::IdleNotification);
assert_eq!(msg.summary.as_deref(), Some("Task finished"));
}
#[tokio::test]
async fn mw3_write_shutdown_request() {
let (mailbox, _repo, _db) = setup().await;
let msg = mailbox
.write(
"t1",
"a1",
"lead",
MailboxMessageType::ShutdownRequest,
"cleanup done",
None,
)
.await
.unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::ShutdownRequest);
}
// -- MR: Atomic read + mark ---------------------------------------------------
#[tokio::test]
async fn mr1_read_unread_returns_all_and_marks() {
let (mailbox, _repo, _db) = setup().await;
for i in 0..3 {
mailbox
.write(
"t1",
"a1",
"user",
MailboxMessageType::Message,
&format!("msg-{i}"),
None,
)
.await
.unwrap();
}
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
assert_eq!(unread.len(), 3);
}
#[tokio::test]
async fn mr2_second_read_returns_empty() {
let (mailbox, _repo, _db) = setup().await;
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
.await
.unwrap();
mailbox.read_unread("t1", "a1").await.unwrap();
let second = mailbox.read_unread("t1", "a1").await.unwrap();
assert!(second.is_empty());
}
#[tokio::test]
async fn mr4_no_unread_messages() {
let (mailbox, _repo, _db) = setup().await;
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
assert!(unread.is_empty());
}
// -- MH: History query --------------------------------------------------------
#[tokio::test]
async fn mh1_get_history_no_limit() {
let (mailbox, _repo, _db) = setup().await;
for i in 0..5 {
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, &format!("m{i}"), None)
.await
.unwrap();
}
mailbox.read_unread("t1", "a1").await.unwrap();
let history = mailbox.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 5);
}
#[tokio::test]
async fn mh2_get_history_with_limit() {
let (mailbox, _repo, _db) = setup().await;
for i in 0..10 {
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, &format!("m{i}"), None)
.await
.unwrap();
}
let history = mailbox.get_history("t1", "a1", Some(5)).await.unwrap();
assert_eq!(history.len(), 5);
}
#[tokio::test]
async fn mh3_empty_history() {
let (mailbox, _repo, _db) = setup().await;
let history = mailbox.get_history("t1", "a1", None).await.unwrap();
assert!(history.is_empty());
}
// -- MD: Delete team cascades to its mailbox ----------------------------------
#[tokio::test]
async fn md1_delete_team_removes_all_messages() {
let (mailbox, repo, _db) = setup().await;
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
.await
.unwrap();
mailbox
.write("t1", "a2", "user", MailboxMessageType::Message, "y", None)
.await
.unwrap();
// The mailbox is purged via FK ON DELETE CASCADE when the team goes away
// (spec §5.4) — there is no manual delete_mailbox_by_team.
repo.delete_team("t1").await.unwrap();
let h1 = mailbox.get_history("t1", "a1", None).await.unwrap();
let h2 = mailbox.get_history("t1", "a2", None).await.unwrap();
assert!(h1.is_empty());
assert!(h2.is_empty());
}
#[tokio::test]
async fn md2_delete_team_does_not_affect_other_teams() {
let (mailbox, repo, _db) = setup().await;
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
.await
.unwrap();
mailbox
.write("t2", "a1", "user", MailboxMessageType::Message, "y", None)
.await
.unwrap();
repo.delete_team("t1").await.unwrap();
let h2 = mailbox.get_history("t2", "a1", None).await.unwrap();
assert_eq!(h2.len(), 1);
}
// -- Agent scope isolation ----------------------------------------------------
#[tokio::test]
async fn read_unread_scoped_to_target_agent() {
let (mailbox, _repo, _db) = setup().await;
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "for-a1", None)
.await
.unwrap();
mailbox
.write("t1", "a2", "user", MailboxMessageType::Message, "for-a2", None)
.await
.unwrap();
let a1_msgs = mailbox.read_unread("t1", "a1").await.unwrap();
assert_eq!(a1_msgs.len(), 1);
assert_eq!(a1_msgs[0].content, "for-a1");
let a2_msgs = mailbox.read_unread("t1", "a2").await.unwrap();
assert_eq!(a2_msgs.len(), 1);
assert_eq!(a2_msgs[0].content, "for-a2");
}
@@ -0,0 +1,987 @@
mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
use nomifun_team::mcp::protocol::{read_frame, write_frame};
use nomifun_team::{Mailbox, TaskBoard, TeamAgent, TeamMcpServer, TeammateManager, TeammateRole};
use serde_json::{Value, json};
use tokio::net::TcpStream;
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(vec![]),
}
}
fn events(&self) -> Vec<WebSocketMessage<Value>> {
self.events.lock().unwrap().clone()
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<Value>) {
self.events.lock().unwrap().push(event);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_agents() -> Vec<TeamAgent> {
vec![
TeamAgent {
slot_id: "lead-1".into(),
name: "Leader".into(),
role: TeammateRole::Lead,
conversation_id: "conv-lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
},
TeamAgent {
slot_id: "worker-1".into(),
name: "Worker".into(),
role: TeammateRole::Teammate,
conversation_id: "conv-worker".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
},
]
}
struct TestEnv {
server: TeamMcpServer,
_repo: Arc<MockTeamRepo>,
broadcaster: Arc<RecordingBroadcaster>,
}
async fn setup() -> TestEnv {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo.clone()));
let recorder = Arc::new(RecordingBroadcaster::new());
let broadcaster: Arc<dyn EventBroadcaster> = recorder.clone();
let agents = make_agents();
let scheduler = Arc::new(TeammateManager::new(
"team-1".into(),
&agents,
mailbox,
task_board,
broadcaster.clone(),
));
// W5-D29e: standalone MCP server without a live TeamSessionService —
// the Weak cannot upgrade, so `team_spawn_agent` will surface the
// service-unavailable error. Non-spawn tools still exercise scheduler
// flows directly and do not hit this path.
let server = TeamMcpServer::start(
"test-token-123".into(),
scheduler,
"team-1".into(),
broadcaster,
std::sync::Weak::new(),
)
.await
.unwrap();
TestEnv {
server,
_repo: repo,
broadcaster: recorder,
}
}
async fn connect_and_init(port: u16, token: &str, slot_id: &str) -> TcpStream {
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
let init_req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"auth_token": token,
"slot_id": slot_id,
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "1.0" }
}
});
send_request(&mut stream, &init_req).await;
let resp = read_response(&mut stream).await;
assert!(resp["result"]["serverInfo"]["name"].is_string());
stream
}
async fn send_request(stream: &mut TcpStream, request: &Value) {
let data = serde_json::to_vec(request).unwrap();
write_frame(stream, &data).await.unwrap();
}
async fn read_response(stream: &mut TcpStream) -> Value {
let frame = read_frame(stream).await.unwrap();
serde_json::from_slice(&frame).unwrap()
}
async fn call_tool(stream: &mut TcpStream, id: u64, tool: &str, args: Value) -> Value {
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": tool,
"arguments": args
}
});
send_request(stream, &req).await;
read_response(stream).await
}
fn extract_text(resp: &Value) -> String {
resp["result"]["content"][0]["text"].as_str().unwrap_or("").to_string()
}
fn is_error_response(resp: &Value) -> bool {
resp["result"]["isError"].as_bool().unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Tests: Connection & Authentication (MC-1, MC-2, MC-3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn mc1_correct_token_connects() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let req = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
});
send_request(&mut stream, &req).await;
let resp = read_response(&mut stream).await;
let tools = resp["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 10);
env.server.stop();
}
#[tokio::test]
async fn mc2_wrong_token_rejected() {
let env = setup().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
.await
.unwrap();
let init_req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "auth_token": "wrong-token", "slot_id": "s1" }
});
send_request(&mut stream, &init_req).await;
let resp = read_response(&mut stream).await;
assert!(
resp["error"]["message"]
.as_str()
.unwrap()
.contains("Authentication failed")
);
env.server.stop();
}
#[tokio::test]
async fn mc3_no_token_rejected() {
let env = setup().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
.await
.unwrap();
let init_req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
});
send_request(&mut stream, &init_req).await;
let resp = read_response(&mut stream).await;
assert!(
resp["error"]["message"]
.as_str()
.unwrap()
.contains("Authentication failed")
);
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: tools/list (TTL-1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tools_list_returns_all_10_tools() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let req = json!({
"jsonrpc": "2.0",
"id": 10,
"method": "tools/list"
});
send_request(&mut stream, &req).await;
let resp = read_response(&mut stream).await;
let tools = resp["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 10);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"team_send_message"));
assert!(names.contains(&"team_spawn_agent"));
assert!(names.contains(&"team_task_create"));
assert!(names.contains(&"team_task_update"));
assert!(names.contains(&"team_task_list"));
assert!(names.contains(&"team_members"));
assert!(names.contains(&"team_rename_agent"));
assert!(names.contains(&"team_shutdown_agent"));
assert!(names.contains(&"team_list_models"));
assert!(names.contains(&"team_describe_assistant"));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_send_message (TS-1, TS-2, TS-3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ts1_send_message_to_agent() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "worker-1", "message": "Hello worker"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("worker-1"));
env.server.stop();
}
#[tokio::test]
async fn ts2_broadcast_message() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "*", "message": "Attention all"}),
)
.await;
assert!(!is_error_response(&resp));
env.server.stop();
}
#[tokio::test]
async fn ts3_send_message_to_nonexistent_agent() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "nonexistent", "message": "Hello?"}),
)
.await;
assert!(is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("No agent matches 'nonexistent'"));
env.server.stop();
}
#[tokio::test]
async fn ts_shutdown_approved_intercepted() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": "shutdown_approved"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
let payload: Value = serde_json::from_str(&text).expect("interception payload is JSON");
assert_eq!(payload["status"], "shutdown_approved_received");
env.server.stop();
}
#[tokio::test]
async fn ts_shutdown_rejected_intercepted() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": "shutdown_rejected: still finishing task"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert_eq!(text, "shutdown_rejected: still finishing task");
env.server.stop();
}
#[tokio::test]
async fn ts_regular_message_not_intercepted() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": "just a normal update"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("lead-1"));
assert!(!text.contains("shutdown_approved_received"));
assert!(!text.contains("shutdown_rejected_received"));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_spawn_agent (SP-1, SP-2, SP-3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sp1_lead_spawn_requires_live_session_service() {
// W5-D29e: this standalone test env spins up TeamMcpServer with
// `Weak::new()` (no live TeamSessionService), so a well-formed Lead
// spawn now surfaces the service-unavailable error. Real session-level
// spawn success is covered by `tests/e2e_smoke.rs` scenario 2 and by
// lib unit tests in `src/session.rs` that wire a TeamSessionService.
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_spawn_agent",
json!({"name": "Helper", "role": "worker", "backend": "claude"}),
)
.await;
assert!(is_error_response(&resp));
let text = extract_text(&resp);
assert!(
text.contains("Team service not available"),
"expected service-unavailable error, got {text:?}"
);
env.server.stop();
}
#[tokio::test]
async fn sp2_non_whitelisted_backend_rejected() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_spawn_agent",
json!({"name": "X", "backend": "malicious"}),
)
.await;
assert!(is_error_response(&resp));
let text = extract_text(&resp);
// Without a live TeamSessionService the spawn fails at capability check or service access.
assert!(
text.contains("not allowed") || text.contains("not available"),
"unexpected error: {text}"
);
env.server.stop();
}
#[tokio::test]
async fn sp3_teammate_cannot_spawn() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_spawn_agent",
json!({"name": "Helper", "backend": "claude"}),
)
.await;
assert!(is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("Only Lead"));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_task_create / team_task_list (TTC-1, TTC-2, TTL-1, TTL-2)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ttc1_create_basic_task() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_task_create",
json!({"subject": "Implement feature X"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("Implement feature X"));
env.server.stop();
}
#[tokio::test]
async fn ttc2_create_task_with_dependency() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
let list_resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
let tasks: Vec<Value> = serde_json::from_str(&extract_text(&list_resp)).unwrap();
let task_a_id = tasks[0]["id"].as_str().unwrap();
let resp = call_tool(
&mut stream,
4,
"team_task_create",
json!({"subject": "Task B", "blocked_by": [task_a_id]}),
)
.await;
assert!(!is_error_response(&resp));
let list_resp2 = call_tool(&mut stream, 5, "team_task_list", json!({})).await;
let tasks2: Vec<Value> = serde_json::from_str(&extract_text(&list_resp2)).unwrap();
assert_eq!(tasks2.len(), 2);
let task_b = tasks2.iter().find(|t| t["subject"] == "Task B").unwrap();
let blocked_by: Vec<String> = serde_json::from_value(task_b["blocked_by"].clone()).unwrap_or_default();
assert!(blocked_by.contains(&task_a_id.to_string()));
env.server.stop();
}
#[tokio::test]
async fn ttl2_task_list_empty() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(&mut stream, 2, "team_task_list", json!({})).await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
let tasks: Vec<Value> = serde_json::from_str(&text).unwrap();
assert!(tasks.is_empty());
env.server.stop();
}
#[tokio::test]
async fn ttl1_task_list_after_create() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
let resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
let text = extract_text(&resp);
let tasks: Vec<Value> = serde_json::from_str(&text).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0]["subject"], "Task A");
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_task_update (TTU-1, TTU-2, TTU-3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ttu1_update_task_status() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
let list_resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
let tasks: Vec<Value> = serde_json::from_str(&extract_text(&list_resp)).unwrap();
let task_id = tasks[0]["id"].as_str().unwrap();
let resp = call_tool(
&mut stream,
4,
"team_task_update",
json!({"task_id": task_id, "status": "completed"}),
)
.await;
assert!(!is_error_response(&resp));
let list_resp2 = call_tool(&mut stream, 5, "team_task_list", json!({})).await;
let tasks2: Vec<Value> = serde_json::from_str(&extract_text(&list_resp2)).unwrap();
assert_eq!(tasks2[0]["status"], "completed");
env.server.stop();
}
#[tokio::test]
async fn ttu3_update_nonexistent_task() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_task_update",
json!({"task_id": "nonexistent-id", "status": "completed"}),
)
.await;
assert!(is_error_response(&resp));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_members (TM-1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tm1_list_all_members() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(&mut stream, 2, "team_members", json!({})).await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
let members: Vec<Value> = serde_json::from_str(&text).unwrap();
assert_eq!(members.len(), 2);
let names: Vec<&str> = members.iter().map(|m| m["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Leader"));
assert!(names.contains(&"Worker"));
// Regression: cold-start agents (including the lead before its first
// wake) must report an explicit `idle` status — never `null` — so MCP
// clients do not misread a live teammate as offline.
for m in &members {
assert_eq!(
m["status"].as_str(),
Some("idle"),
"team_members must report idle status for cold-start agents, got {:?}",
m["status"]
);
}
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_rename_agent (TRA-1, TRA-2)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tra1_rename_existing_agent() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_rename_agent",
json!({"slot_id": "worker-1", "new_name": "Senior Worker"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("renamed"));
env.server.stop();
}
#[tokio::test]
async fn tra2_rename_nonexistent_agent() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_rename_agent",
json!({"slot_id": "nonexistent", "new_name": "X"}),
)
.await;
assert!(is_error_response(&resp));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: team_shutdown_agent (TSA-1, TSA-4)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tsa1_lead_sends_shutdown_request() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let resp = call_tool(
&mut stream,
2,
"team_shutdown_agent",
json!({"slot_id": "worker-1", "reason": "Task complete"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("Shutdown request sent"));
env.server.stop();
}
#[tokio::test]
async fn tsa4_non_lead_cannot_shutdown() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(&mut stream, 2, "team_shutdown_agent", json!({"slot_id": "lead-1"})).await;
assert!(is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("Only Lead"));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: Unknown method / non-initialize first request
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unknown_method_returns_error() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
let req = json!({
"jsonrpc": "2.0",
"id": 99,
"method": "unknown/method"
});
send_request(&mut stream, &req).await;
let resp = read_response(&mut stream).await;
assert!(resp["error"]["code"].as_i64().unwrap() == -32601);
env.server.stop();
}
#[tokio::test]
async fn non_initialize_first_request_rejected() {
let env = setup().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
.await
.unwrap();
let req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
});
send_request(&mut stream, &req).await;
let resp = read_response(&mut stream).await;
assert!(resp["error"]["message"].as_str().unwrap().contains("initialize"));
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: Server stop (SS-2)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ss2_stop_server_closes_listener() {
let env = setup().await;
let port = env.server.port();
let _stream = connect_and_init(port, "test-token-123", "lead-1").await;
env.server.stop();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let result = TcpStream::connect(format!("127.0.0.1:{port}")).await;
assert!(result.is_err());
}
// ---------------------------------------------------------------------------
// Tests: stdio bridge config (SB-1, SB-3)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sb1_bridge_config_generation() {
use nomifun_team::{TeamMcpStdioConfig, TeamMcpStdioServerSpec};
let env = setup().await;
let config = TeamMcpStdioConfig {
team_id: "team-test".into(),
port: env.server.port(),
token: env.server.auth_token().to_string(),
slot_id: "lead-1".into(),
binary_path: "/bin/nomicore".into(),
};
let spec = TeamMcpStdioServerSpec::from_config("/bin/nomicore", &config);
let env_map: std::collections::HashMap<_, _> = spec.env.iter().cloned().collect();
assert_eq!(env_map[TeamMcpStdioConfig::ENV_PORT], env.server.port().to_string());
assert_eq!(env_map[TeamMcpStdioConfig::ENV_TOKEN], "test-token-123");
assert_eq!(env_map[TeamMcpStdioConfig::ENV_SLOT_ID], "lead-1");
env.server.stop();
}
#[tokio::test]
async fn sb3_different_agents_get_different_slot_ids() {
use nomifun_team::{TeamMcpStdioConfig, TeamMcpStdioServerSpec};
let env = setup().await;
let port = env.server.port();
let token = env.server.auth_token().to_string();
let cfg_lead = TeamMcpStdioConfig {
team_id: "t".into(),
port,
token: token.clone(),
slot_id: "lead-1".into(),
binary_path: "/b".into(),
};
let cfg_worker = TeamMcpStdioConfig {
team_id: "t".into(),
port,
token,
slot_id: "worker-1".into(),
binary_path: "/b".into(),
};
let spec_lead = TeamMcpStdioServerSpec::from_config("/b", &cfg_lead);
let spec_worker = TeamMcpStdioServerSpec::from_config("/b", &cfg_worker);
let kv_lead: std::collections::HashMap<_, _> = spec_lead.env.iter().cloned().collect();
let kv_worker: std::collections::HashMap<_, _> = spec_worker.env.iter().cloned().collect();
assert_eq!(
kv_lead[TeamMcpStdioConfig::ENV_PORT],
kv_worker[TeamMcpStdioConfig::ENV_PORT]
);
assert_ne!(
kv_lead[TeamMcpStdioConfig::ENV_SLOT_ID],
kv_worker[TeamMcpStdioConfig::ENV_SLOT_ID]
);
}
// ---------------------------------------------------------------------------
// Tests: mcpStatus broadcast (W5-D31b-1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn mcp_status_tcp_ready_is_broadcast_on_successful_bind() {
use nomifun_api_types::{TeamMcpPhase, TeamMcpStatusPayload};
let env = setup().await;
let port = env.server.port();
let events = env.broadcaster.events();
let status_events: Vec<_> = events.iter().filter(|e| e.name == "team.mcpStatus").collect();
assert_eq!(
status_events.len(),
1,
"expected exactly one team.mcpStatus event after bind, got {}",
status_events.len()
);
let payload: TeamMcpStatusPayload = serde_json::from_value(status_events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "team-1");
assert_eq!(payload.slot_id, "");
assert!(matches!(payload.phase, TeamMcpPhase::TcpReady));
assert_eq!(payload.port, Some(port));
assert!(payload.server_count.is_none());
assert!(payload.error.is_none());
env.server.stop();
env.server.stop();
}
// ---------------------------------------------------------------------------
// Tests: W5-D30b — shutdown_rejected detection in team_send_message
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tsr1_shutdown_rejected_notifies_lead_and_preserves_agent() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": "shutdown_rejected: still working"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(
text.contains("shutdown_rejected"),
"response should echo the sentinel, got: {text}"
);
assert!(
text.contains("still working"),
"response should echo the reason, got: {text}"
);
// Leader mailbox contains the notification, worker did not receive a
// literal copy of the sentinel.
let state = env._repo.state.lock().unwrap();
let lead_msgs: Vec<_> = state.messages.iter().filter(|m| m.to_agent_id == "lead-1").collect();
assert_eq!(lead_msgs.len(), 1, "expected exactly one message to lead");
assert_eq!(lead_msgs[0].from_agent_id, "worker-1");
assert!(lead_msgs[0].content.contains("Worker"));
assert!(lead_msgs[0].content.contains("declined shutdown"));
assert!(lead_msgs[0].content.contains("still working"));
let lead_self_msgs: Vec<_> = state
.messages
.iter()
.filter(|m| m.to_agent_id == "lead-1" && m.content == "shutdown_rejected: still working")
.collect();
assert!(
lead_self_msgs.is_empty(),
"raw sentinel must not be delivered as a normal message"
);
drop(state);
env.server.stop();
}
#[tokio::test]
async fn tsr2_shutdown_rejected_with_whitespace_reason() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": " shutdown_rejected: need more time "}),
)
.await;
assert!(!is_error_response(&resp));
let state = env._repo.state.lock().unwrap();
let lead_msgs: Vec<_> = state.messages.iter().filter(|m| m.to_agent_id == "lead-1").collect();
assert_eq!(lead_msgs.len(), 1);
// Reason is trimmed before inclusion in the notification.
assert!(lead_msgs[0].content.contains("need more time"));
assert!(!lead_msgs[0].content.contains(" need more time "));
drop(state);
env.server.stop();
}
#[tokio::test]
async fn tsr3_send_message_without_sentinel_still_routes_normally() {
let env = setup().await;
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
let resp = call_tool(
&mut stream,
2,
"team_send_message",
json!({"to": "lead-1", "message": "regular update"}),
)
.await;
assert!(!is_error_response(&resp));
let text = extract_text(&resp);
assert!(text.contains("Message sent"));
// The literal message lands in the lead mailbox unchanged.
let state = env._repo.state.lock().unwrap();
let lead_msg = state
.messages
.iter()
.find(|m| m.to_agent_id == "lead-1")
.expect("message should be delivered");
assert_eq!(lead_msg.content, "regular update");
drop(state);
env.server.stop();
}
@@ -0,0 +1,397 @@
mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::{
TeamAgentRemovedPayload, TeamAgentRenamedPayload, TeamAgentSpawnedPayload, TeamAgentStatusPayload, WebSocketMessage,
};
use nomifun_realtime::EventBroadcaster;
use nomifun_team::events::TeamEventEmitter;
use nomifun_team::prompts::{build_lead_prompt, build_teammate_prompt, build_wake_payload};
use nomifun_team::types::{
MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, TeamTask, TeammateRole, TeammateStatus,
};
use nomifun_team::{Mailbox, TaskBoard, TeammateManager};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(vec![]),
}
}
fn events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
self.events.lock().unwrap().clone()
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent {
TeamAgent {
slot_id: slot_id.into(),
name: name.into(),
role,
conversation_id: format!("conv-{slot_id}"),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
// ===========================================================================
// Test-plan §9: Prompt Templates
// ===========================================================================
fn default_agent_types() -> Vec<(String, String)> {
vec![
("claude".into(), "Claude".into()),
("codex".into(), "Codex".into()),
("gemini".into(), "Gemini".into()),
]
}
// -- LP-1: Lead prompt contains member list ----------------------------------
#[test]
fn lp1_lead_prompt_contains_member_list() {
let members = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Alice", TeammateRole::Teammate),
make_agent("w2", "Bob", TeammateRole::Teammate),
];
let types = default_agent_types();
let prompt = build_lead_prompt("Alpha", &members, &types);
// Nomi bullet format: `- {name} ({backend}, status: {status})`
assert!(prompt.contains("- Lead ("), "lead name missing");
assert!(prompt.contains("- Alice ("), "teammate Alice missing");
assert!(prompt.contains("- Bob ("), "teammate Bob missing");
}
// -- LP-2: Lead prompt contains tool descriptions ----------------------------
#[test]
fn lp2_lead_prompt_contains_tool_descriptions() {
let prompt = build_lead_prompt("Beta", &[], &default_agent_types());
// Nomi lead prompt references the `team_*` coordination tools that the
// leader must use; the MCP layer enumerates them with arguments, so the
// prompt mentions each tool at least once.
let expected_tools = [
"team_send_message",
"team_spawn_agent",
"team_task_create",
"team_task_list",
"team_members",
"team_rename_agent",
"team_shutdown_agent",
"team_list_models",
];
for tool in expected_tools {
assert!(prompt.contains(tool), "missing tool: {tool}");
}
}
// -- LP-3: Lead prompt contains task management guidance ---------------------
#[test]
fn lp3_lead_prompt_contains_task_management_guidance() {
let prompt = build_lead_prompt("Gamma", &[], &default_agent_types());
assert!(
prompt.contains("Break the work into tasks"),
"missing decompose guidance"
);
assert!(prompt.contains("Assign tasks"), "missing assign guidance");
assert!(prompt.contains("dependency"), "missing dependency guidance");
assert!(
prompt.contains("When teammates report back"),
"missing teammate result-review guidance"
);
}
// -- TP-1: Teammate prompt contains execution guidance -----------------------
#[test]
fn tp1_teammate_prompt_contains_execution_guidance() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let prompt = build_teammate_prompt(&agent, "Alpha");
assert!(prompt.contains("execute tasks"), "missing execution guidance");
assert!(prompt.contains("team_send_message"), "missing communication tool");
assert!(prompt.contains("team_task_update"), "missing task update tool");
assert!(prompt.contains("shutdown_request"), "missing shutdown protocol");
assert!(prompt.contains("shutdown_approved"), "missing shutdown_approved");
}
// -- TP-2: Teammate prompt contains team name --------------------------------
#[test]
fn tp2_teammate_prompt_contains_team_name() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let prompt = build_teammate_prompt(&agent, "Project Falcon");
assert!(prompt.contains("\"Project Falcon\""));
}
// -- WP-1: Wake payload includes unread messages -----------------------------
#[test]
fn wp1_wake_payload_includes_unread_messages() {
let agent = make_agent("lead-1", "Lead", TeammateRole::Lead);
let messages = vec![
MailboxMessage {
id: 1,
team_id: "t1".into(),
to_agent_id: "lead-1".into(),
from_agent_id: "w1".into(),
msg_type: MailboxMessageType::Message,
content: "Feature X is done".into(),
summary: None,
files: None,
read: false,
created_at: 0,
},
MailboxMessage {
id: 2,
team_id: "t1".into(),
to_agent_id: "lead-1".into(),
from_agent_id: "w2".into(),
msg_type: MailboxMessageType::IdleNotification,
content: "idle".into(),
summary: Some("Finished task Y".into()),
files: None,
read: false,
created_at: 0,
},
];
let payload = build_wake_payload(&agent, &[], &messages);
assert!(payload.contains("Feature X is done"));
assert!(payload.contains("`w1`"));
assert!(payload.contains("[message]"));
assert!(payload.contains("`w2`"));
assert!(payload.contains("[idle_notification]"));
assert!(payload.contains("Summary: Finished task Y"));
}
// -- WP-2: Wake payload includes current task list ---------------------------
#[test]
fn wp2_wake_payload_includes_task_list() {
let agent = make_agent("lead-1", "Lead", TeammateRole::Lead);
let tasks = vec![
TeamTask {
id: "aaaaaaaa-1111-2222-3333-444444444444".into(),
team_id: "t1".into(),
subject: "Implement auth".into(),
description: None,
status: TaskStatus::InProgress,
owner: Some("w1".into()),
blocked_by: vec![],
blocks: vec![],
metadata: None,
created_at: 0,
updated_at: 0,
},
TeamTask {
id: "bbbbbbbb-1111-2222-3333-444444444444".into(),
team_id: "t1".into(),
subject: "Write tests".into(),
description: None,
status: TaskStatus::Pending,
owner: Some("w2".into()),
blocked_by: vec!["aaaaaaaa-1111-2222-3333-444444444444".into()],
blocks: vec![],
metadata: None,
created_at: 0,
updated_at: 0,
},
];
let payload = build_wake_payload(&agent, &tasks, &[]);
assert!(payload.contains("Current Task Board"));
assert!(payload.contains("Implement auth"));
assert!(payload.contains("in_progress"));
assert!(payload.contains("Write tests"));
assert!(payload.contains("pending"));
assert!(payload.contains("w1"));
assert!(payload.contains("w2"));
assert!(
payload.contains("aaaaaaaa-1111-2222-3333-444444444444"),
"blocker task ID should appear in blocked_by column"
);
}
// -- WP-3: Wake payload with no messages and no tasks builds normally --------
#[test]
fn wp3_wake_payload_empty_builds_normally() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let payload = build_wake_payload(&agent, &[], &[]);
assert!(payload.contains("No new messages"));
assert!(payload.contains("No tasks on the board"));
assert!(payload.contains("**Worker1**"));
assert!(payload.contains("teammate"));
}
// ===========================================================================
// Test-plan §8: WebSocket Event Broadcasting
// ===========================================================================
// -- WE-1: Agent status change event -----------------------------------------
#[tokio::test]
async fn we1_agent_status_change_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.status");
let payload: TeamAgentStatusPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
assert_eq!(payload.status, "working");
}
// -- WE-2: Agent spawned event -----------------------------------------------
#[tokio::test]
async fn we2_agent_spawned_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![make_agent("lead-1", "Lead", TeammateRole::Lead)];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
let new_agent = make_agent("w2", "NewWorker", TeammateRole::Teammate);
mgr.add_agent(&new_agent).await;
let spawned: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.spawned")
.collect();
assert_eq!(spawned.len(), 1);
let payload: TeamAgentSpawnedPayload = serde_json::from_value(spawned[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.agent.slot_id, "w2");
assert_eq!(payload.agent.name, "NewWorker");
}
// -- WE-3: Agent removed event -----------------------------------------------
#[tokio::test]
async fn we3_agent_removed_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.remove_agent("w1").await.unwrap();
let removed: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.removed")
.collect();
assert_eq!(removed.len(), 1);
let payload: TeamAgentRemovedPayload = serde_json::from_value(removed[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
}
// -- WE-4: Agent renamed event -----------------------------------------------
#[tokio::test]
async fn we4_agent_renamed_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.rename_agent("w1", "SuperWorker").await.unwrap();
let renamed: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.renamed")
.collect();
assert_eq!(renamed.len(), 1);
let payload: TeamAgentRenamedPayload = serde_json::from_value(renamed[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
assert_eq!(payload.name, "SuperWorker");
}
// -- Direct TeamEventEmitter test (event payloads use typed structs) ----------
#[test]
fn event_emitter_uses_typed_payloads() {
let bc = Arc::new(RecordingBroadcaster::new());
let emitter = TeamEventEmitter::new("team-x".into(), bc.clone());
let agent = make_agent("s1", "A", TeammateRole::Teammate);
emitter.broadcast_agent_status("s1", TeammateStatus::Thinking);
emitter.broadcast_agent_spawned(&agent);
emitter.broadcast_agent_removed("s1");
emitter.broadcast_agent_renamed("s1", "B");
let events = bc.events();
assert_eq!(events.len(), 4);
let p1: TeamAgentStatusPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(p1.status, "thinking");
let p2: TeamAgentSpawnedPayload = serde_json::from_value(events[1].data.clone()).unwrap();
assert_eq!(p2.agent.slot_id, "s1");
let p3: TeamAgentRemovedPayload = serde_json::from_value(events[2].data.clone()).unwrap();
assert_eq!(p3.slot_id, "s1");
let p4: TeamAgentRenamedPayload = serde_json::from_value(events[3].data.clone()).unwrap();
assert_eq!(p4.name, "B");
}
@@ -0,0 +1,574 @@
mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
use nomifun_team::{
Mailbox, MailboxMessageType, SchedulerAction, TaskBoard, TeamAgent, TeammateManager, TeammateRole, TeammateStatus,
WAKE_TIMEOUT_MS,
};
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(vec![]),
}
}
fn events_by_name(&self, name: &str) -> Vec<WebSocketMessage<serde_json::Value>> {
self.events
.lock()
.unwrap()
.iter()
.filter(|e| e.name == name)
.cloned()
.collect()
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent {
TeamAgent {
slot_id: slot_id.into(),
name: name.into(),
role,
conversation_id: format!("conv-{slot_id}"),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
struct TestHarness {
mgr: TeammateManager,
mailbox: Arc<Mailbox>,
task_board: Arc<TaskBoard>,
broadcaster: Arc<RecordingBroadcaster>,
}
fn setup_team(agents: &[TeamAgent]) -> TestHarness {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = TeammateManager::new(
"team-1".into(),
agents,
mailbox.clone(),
task_board.clone(),
broadcaster.clone(),
);
TestHarness {
mgr,
mailbox,
task_board,
broadcaster,
}
}
// ===========================================================================
// Test-plan §7: Agent 调度引擎
// ===========================================================================
// -- AW-1: Wake idle Agent → status idle→working, payload has tasks+unread --
#[tokio::test]
async fn aw1_wake_idle_agent_transitions_to_working_with_payload() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.task_board
.create_task("team-1", "Task A", None, Some("w1"), &[])
.await
.unwrap();
h.mailbox
.write("team-1", "w1", "lead", MailboxMessageType::Message, "Do it", None)
.await
.unwrap();
let payload = h.mgr.try_wake("w1").await.unwrap();
assert!(payload.is_some());
let p = payload.unwrap();
assert_eq!(p.agent.slot_id, "w1");
assert_eq!(p.tasks.len(), 1);
assert_eq!(p.tasks[0].subject, "Task A");
assert_eq!(p.unread_messages.len(), 1);
assert_eq!(p.unread_messages[0].content, "Do it");
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Working);
}
// -- AW-2: Wake → agent completes → finalize_turn → actions executed --------
#[tokio::test]
async fn aw2_wake_complete_finalize_executes_actions() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let actions = vec![
SchedulerAction::TaskCreate {
subject: "Write tests".into(),
description: Some("Unit tests for API".into()),
owner: Some("w1".into()),
blocked_by: vec![],
},
SchedulerAction::SendMessage {
to: "w2".into(),
message: "Please review when done".into(),
},
];
h.mgr.finalize_turn("w1", &actions).await.unwrap();
let tasks = h.task_board.list_tasks("team-1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Write tests");
let w2_msgs = h.mailbox.read_unread("team-1", "w2").await.unwrap();
assert_eq!(w2_msgs.len(), 1);
assert_eq!(w2_msgs[0].content, "Please review when done");
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Idle);
}
// -- AW-3: WAKE_TIMEOUT_MS constant is 60000 --------------------------------
#[tokio::test]
async fn aw3_wake_timeout_constant() {
assert_eq!(WAKE_TIMEOUT_MS, 60_000);
}
// -- AW-4: Wake non-idle agent → skip (no duplicate wake) -------------------
#[tokio::test]
async fn aw4_wake_non_idle_agent_skipped() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let payload = h.mgr.try_wake("w1").await.unwrap();
assert!(payload.is_none());
}
// -- DL-1: Lead completes turn → stays idle, no auto-wake -------------------
#[tokio::test]
async fn dl1_lead_finalize_stays_idle_no_auto_wake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("lead", TeammateStatus::Working).await.unwrap();
let actions = vec![SchedulerAction::SendMessage {
to: "w1".into(),
message: "Go work".into(),
}];
let wake_signal = h.mgr.finalize_turn("lead", &actions).await.unwrap();
assert!(wake_signal.is_none());
assert_eq!(h.mgr.get_status("lead").await.unwrap(), TeammateStatus::Idle);
}
// -- DL-2: All teammates idle → wake leader ---------------------------------
#[tokio::test]
async fn dl2_all_teammates_idle_wakes_leader() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let wake1 = h.mgr.finalize_turn("w1", &[]).await.unwrap();
assert!(wake1.is_none());
let wake2 = h.mgr.finalize_turn("w2", &[]).await.unwrap();
assert_eq!(wake2.as_deref(), Some("lead"));
}
// -- DL-3: Partial teammates idle → leader NOT woken ------------------------
#[tokio::test]
async fn dl3_partial_teammates_idle_no_leader_wake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let wake = h.mgr.finalize_turn("w1", &[]).await.unwrap();
assert!(wake.is_none());
}
// -- AE-1: send_message action → mailbox + wake target ----------------------
#[tokio::test]
async fn ae1_send_message_action_writes_mailbox() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::SendMessage {
to: "w1".into(),
message: "Hello worker".into(),
},
)
.await
.unwrap();
let msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "Hello worker");
assert_eq!(msgs[0].from_agent_id, "lead");
}
// -- AE-2: task_create action → task created --------------------------------
#[tokio::test]
async fn ae2_task_create_action() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::TaskCreate {
subject: "Build feature".into(),
description: Some("With tests".into()),
owner: None,
blocked_by: vec![],
},
)
.await
.unwrap();
let tasks = h.task_board.list_tasks("team-1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Build feature");
}
// -- AE-3: idle_notification → agent marked idle + check all-idle -----------
#[tokio::test]
async fn ae3_idle_notification_marks_idle_and_notifies_lead() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let wake_signal = h
.mgr
.execute_action(
"w1",
&SchedulerAction::IdleNotification {
summary: Some("Done".into()),
},
)
.await
.unwrap();
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Idle);
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(lead_msgs.len(), 1);
assert_eq!(lead_msgs[0].msg_type, MailboxMessageType::IdleNotification);
assert_eq!(wake_signal.as_deref(), Some("lead"));
}
// -- AE-4: spawn_agent action → logged (actual creation via TeamSession) ----
#[tokio::test]
async fn ae4_spawn_agent_action_logged() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
let result = h
.mgr
.execute_action(
"lead",
&SchedulerAction::SpawnAgent {
name: "Helper".into(),
role: "teammate".into(),
backend: "claude".into(),
},
)
.await;
assert!(result.is_ok());
}
// ===========================================================================
// Test-plan §8: WebSocket 事件推送
// ===========================================================================
// -- WE-1: Agent status change broadcasts team.agent.status -----------------
#[tokio::test]
async fn we1_status_change_broadcasts_event() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.status");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["team_id"], "team-1");
assert_eq!(events[0].data["slot_id"], "w1");
assert_eq!(events[0].data["status"], "working");
}
// -- WE-2: Dynamic agent creation broadcasts team.agent.spawned -------------
#[tokio::test]
async fn we2_add_agent_broadcasts_spawned() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
let new = make_agent("w1", "Worker", TeammateRole::Teammate);
h.mgr.add_agent(&new).await;
let events = h.broadcaster.events_by_name("team.agent.spawned");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["team_id"], "team-1");
assert!(events[0].data["agent"].is_object());
}
// -- WE-3: Remove agent broadcasts team.agent.removed ----------------------
#[tokio::test]
async fn we3_remove_agent_broadcasts_removed() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.remove_agent("w1").await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.removed");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["slot_id"], "w1");
}
// -- WE-4: Rename agent broadcasts team.agent.renamed ----------------------
#[tokio::test]
async fn we4_rename_agent_broadcasts_renamed() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.rename_agent("w1", "SuperWorker").await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.renamed");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["slot_id"], "w1");
assert_eq!(events[0].data["name"], "SuperWorker");
}
// ===========================================================================
// Full workflow: Lead delegates → Workers complete → Leader re-woken
// ===========================================================================
#[tokio::test]
async fn full_workflow_lead_delegate_workers_idle_lead_rewake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
// 1. Wake lead with user message
h.mailbox
.write("team-1", "lead", "user", MailboxMessageType::Message, "Build X", None)
.await
.unwrap();
let payload = h.mgr.try_wake("lead").await.unwrap().unwrap();
assert_eq!(payload.unread_messages.len(), 1);
// 2. Lead delegates work to workers
let lead_actions = vec![
SchedulerAction::TaskCreate {
subject: "Implement X".into(),
description: None,
owner: Some("w1".into()),
blocked_by: vec![],
},
SchedulerAction::SendMessage {
to: "w1".into(),
message: "Implement X".into(),
},
SchedulerAction::SendMessage {
to: "w2".into(),
message: "Write tests for X".into(),
},
SchedulerAction::IdleNotification {
summary: Some("Delegated".into()),
},
];
let wake_signal = h.mgr.finalize_turn("lead", &lead_actions).await.unwrap();
assert!(wake_signal.is_none(), "lead idle → no auto-wake");
// 3. Workers start working
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
// 4. Worker1 finishes
let w1_actions = vec![SchedulerAction::IdleNotification {
summary: Some("Implemented X".into()),
}];
let wake = h.mgr.finalize_turn("w1", &w1_actions).await.unwrap();
assert!(wake.is_none(), "w2 still working");
// 5. Worker2 finishes → all idle → leader should be woken
let w2_actions = vec![SchedulerAction::IdleNotification {
summary: Some("Tests written".into()),
}];
let wake = h.mgr.finalize_turn("w2", &w2_actions).await.unwrap();
assert_eq!(wake.as_deref(), Some("lead"), "all teammates idle → wake leader");
// 6. Verify lead has idle notifications from both workers
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(lead_msgs.len(), 2);
let summaries: Vec<_> = lead_msgs.iter().map(|m| m.content.as_str()).collect();
assert!(summaries.contains(&"Implemented X"));
assert!(summaries.contains(&"Tests written"));
}
// ===========================================================================
// Shutdown flow: Lead initiates → target receives shutdown_request
// ===========================================================================
#[tokio::test]
async fn shutdown_flow_lead_sends_request_to_target() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::ShutdownAgent {
slot_id: "w1".into(),
reason: Some("No longer needed".into()),
},
)
.await
.unwrap();
let msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].msg_type, MailboxMessageType::ShutdownRequest);
assert_eq!(msgs[0].content, "No longer needed");
}
#[tokio::test]
async fn shutdown_flow_non_lead_rejected() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
let result = h
.mgr
.execute_action(
"w1",
&SchedulerAction::ShutdownAgent {
slot_id: "w2".into(),
reason: None,
},
)
.await;
assert!(result.is_err());
}
// ===========================================================================
// Broadcast message (to="*") sends to all except sender
// ===========================================================================
#[tokio::test]
async fn broadcast_message_sends_to_all_except_sender() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::SendMessage {
to: "*".into(),
message: "Attention everyone".into(),
},
)
.await
.unwrap();
let w1_msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
let w2_msgs = h.mailbox.read_unread("team-1", "w2").await.unwrap();
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(w1_msgs.len(), 1);
assert_eq!(w2_msgs.len(), 1);
assert!(lead_msgs.is_empty());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,338 @@
//! Black-box integration tests for `TaskBoard` service.
//!
//! Exercises the service layer against a real SQLite database.
//!
//! Covers test-plan items:
//! - TK-1..TK-4 (create tasks: no deps, single dep, multi-dep, nonexistent dep)
//! - TU-1..TU-5 (update status, description, owner, nonexistent)
//! - CU-1..CU-4 (check_unblocks: single, multiple, partial, no downstream)
//! - TT-1..TT-3 (list tasks, empty, with deps)
//! - DC-4 (blockedBy/blocks bidirectional consistency)
use std::sync::Arc;
use nomifun_db::{ITeamRepository, SqliteTeamRepository, init_database_memory};
use nomifun_team::{TaskBoard, TaskStatus, TaskUpdate};
async fn setup() -> (TaskBoard, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
// Seed the parent user + team: `team_tasks.team_id` now carries a FK
// (CASCADE) to `teams(id)` (spec §4.2), so the team must exist before any
// task can be inserted.
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('user_1', 'tester', 'hash', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO teams (id, user_id, name, workspace, workspace_mode, agents_version, created_at, updated_at) \
VALUES ('t1', 'user_1', 'Test Team', '/tmp/ws', 'shared', '1.0.0', 0, 0)",
)
.execute(db.pool())
.await
.unwrap();
let repo = Arc::new(SqliteTeamRepository::new(db.pool().clone())) as Arc<dyn ITeamRepository>;
(TaskBoard::new(repo), db)
}
// -- TK: Create tasks ---------------------------------------------------------
#[tokio::test]
async fn tk1_create_task_no_dependencies() {
let (board, _db) = setup().await;
let task = board
.create_task("t1", "Implement feature", None, None, &[])
.await
.unwrap();
assert_eq!(task.subject, "Implement feature");
assert_eq!(task.status, TaskStatus::Pending);
assert!(task.blocked_by.is_empty());
assert!(task.blocks.is_empty());
}
#[tokio::test]
async fn tk2_create_task_with_single_dependency() {
let (board, _db) = setup().await;
let task_a = board.create_task("t1", "Task A", None, None, &[]).await.unwrap();
let task_b = board
.create_task("t1", "Task B", None, None, std::slice::from_ref(&task_a.id))
.await
.unwrap();
assert_eq!(task_b.blocked_by, vec![task_a.id.clone()]);
let tasks = board.list_tasks("t1").await.unwrap();
let a = tasks.iter().find(|t| t.id == task_a.id).unwrap();
assert_eq!(a.blocks, vec![task_b.id]);
}
#[tokio::test]
async fn tk3_create_task_with_multiple_dependencies() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board.create_task("t1", "B", None, None, &[]).await.unwrap();
let c = board
.create_task("t1", "C", None, None, &[a.id.clone(), b.id.clone()])
.await
.unwrap();
assert_eq!(c.blocked_by.len(), 2);
let tasks = board.list_tasks("t1").await.unwrap();
let a_updated = tasks.iter().find(|t| t.id == a.id).unwrap();
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
assert!(a_updated.blocks.contains(&c.id));
assert!(b_updated.blocks.contains(&c.id));
}
#[tokio::test]
async fn tk4_create_task_nonexistent_dependency_fails() {
let (board, _db) = setup().await;
let result = board.create_task("t1", "X", None, None, &["nonexistent".into()]).await;
assert!(result.is_err());
}
// -- TU: Update tasks ---------------------------------------------------------
#[tokio::test]
async fn tu1_update_status_pending_to_in_progress() {
let (board, _db) = setup().await;
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
status: Some(TaskStatus::InProgress),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.status, TaskStatus::InProgress);
}
#[tokio::test]
async fn tu2_update_status_to_completed_triggers_unblock() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
board
.update_task(
"t1",
&a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
assert!(b_updated.blocked_by.is_empty());
}
#[tokio::test]
async fn tu3_update_description() {
let (board, _db) = setup().await;
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
description: Some("Updated description".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.description.as_deref(), Some("Updated description"));
}
#[tokio::test]
async fn tu4_update_owner() {
let (board, _db) = setup().await;
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
owner: Some("agent-2".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.owner.as_deref(), Some("agent-2"));
}
#[tokio::test]
async fn tu5_update_nonexistent_task_fails() {
let (board, _db) = setup().await;
let result = board.update_task("t1", "nonexistent", &TaskUpdate::default()).await;
assert!(result.is_err());
}
// -- CU: Check unblocks ------------------------------------------------------
#[tokio::test]
async fn cu1_complete_unblocks_single_downstream() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
board
.update_task(
"t1",
&a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
assert!(b_updated.blocked_by.is_empty());
}
#[tokio::test]
async fn cu2_complete_unblocks_multiple_downstream() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
let c = board
.create_task("t1", "C", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
board
.update_task(
"t1",
&a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
let c_updated = tasks.iter().find(|t| t.id == c.id).unwrap();
assert!(b_updated.blocked_by.is_empty());
assert!(c_updated.blocked_by.is_empty());
}
#[tokio::test]
async fn cu3_partial_unblock_preserves_other_deps() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let x = board.create_task("t1", "X", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, &[a.id.clone(), x.id.clone()])
.await
.unwrap();
board
.update_task(
"t1",
&a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
assert_eq!(b_updated.blocked_by, vec![x.id]);
}
#[tokio::test]
async fn cu4_complete_no_downstream_is_noop() {
let (board, _db) = setup().await;
let task = board.create_task("t1", "Solo", None, None, &[]).await.unwrap();
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.status, TaskStatus::Completed);
}
// -- TT: List tasks -----------------------------------------------------------
#[tokio::test]
async fn tt1_list_all_tasks() {
let (board, _db) = setup().await;
board.create_task("t1", "A", None, None, &[]).await.unwrap();
board.create_task("t1", "B", None, None, &[]).await.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
assert_eq!(tasks.len(), 2);
}
#[tokio::test]
async fn tt2_list_empty() {
let (board, _db) = setup().await;
let tasks = board.list_tasks("t1").await.unwrap();
assert!(tasks.is_empty());
}
#[tokio::test]
async fn tt3_list_includes_dependency_info() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b_found = tasks.iter().find(|t| t.id == b.id).unwrap();
assert_eq!(b_found.blocked_by, vec![a.id.clone()]);
let a_found = tasks.iter().find(|t| t.id == a.id).unwrap();
assert!(a_found.blocks.contains(&b.id));
}
// -- DC-4: Bidirectional consistency ------------------------------------------
#[tokio::test]
async fn dc4_blocked_by_blocks_bidirectional_consistency() {
let (board, _db) = setup().await;
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
let b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let a_found = tasks.iter().find(|t| t.id == a.id).unwrap();
let b_found = tasks.iter().find(|t| t.id == b.id).unwrap();
assert!(a_found.blocks.contains(&b.id));
assert!(b_found.blocked_by.contains(&a.id));
}