test(btmsg): add regression tests for named column access and camelCase serialization

Covers the CRITICAL status vs system_prompt bug (positional index 7),
JOIN alias disambiguation, serde camelCase serialization, TypeScript
bridge IPC commands, and plantuml hex encoding algorithm.
49 new tests: 8 btmsg.rs + 7 bttask.rs + 8 sidecar + 17 btmsg-bridge.ts + 10 bttask-bridge.ts + 7 plantuml-encode.ts
This commit is contained in:
Hibryda 2026-03-11 22:19:03 +01:00
parent a12f2bec7b
commit e41d237745
6 changed files with 1012 additions and 0 deletions

View file

@ -409,3 +409,368 @@ pub fn add_channel_member(channel_id: &str, agent_id: &str) -> Result<(), String
).map_err(|e| format!("Insert error: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
/// Create an in-memory DB with the btmsg schema for testing.
fn test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL,
group_id TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 1,
model TEXT,
cwd TEXT,
system_prompt TEXT,
status TEXT DEFAULT 'stopped',
last_active_at TEXT
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
from_agent TEXT NOT NULL,
to_agent TEXT NOT NULL,
content TEXT NOT NULL,
group_id TEXT NOT NULL,
read INTEGER DEFAULT 0,
reply_to TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE contacts (
agent_id TEXT NOT NULL,
contact_id TEXT NOT NULL,
PRIMARY KEY (agent_id, contact_id)
);
CREATE TABLE channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
group_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE channel_members (
channel_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
PRIMARY KEY (channel_id, agent_id)
);
CREATE TABLE channel_messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
from_agent TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);",
)
.unwrap();
conn
}
fn seed_agents(conn: &Connection) {
conn.execute(
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
VALUES ('a1', 'Coder', 'developer', 'g1', 1, 'claude-4', 'You are a coder', 'active')",
[],
).unwrap();
conn.execute(
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
VALUES ('a2', 'Reviewer', 'reviewer', 'g1', 2, NULL, 'You review code', 'sleeping')",
[],
).unwrap();
conn.execute(
"INSERT INTO agents (id, name, role, group_id, tier, model, system_prompt, status)
VALUES ('admin', 'Operator', 'admin', 'g1', 0, NULL, NULL, 'active')",
[],
).unwrap();
}
// ---- CRITICAL REGRESSION: get_agents named column access ----
// Bug: positional index 7 returned system_prompt instead of status (column 8).
// Fix: named access via row.get("status").
#[test]
fn test_get_agents_returns_status_not_system_prompt() {
let conn = test_db();
seed_agents(&conn);
let mut stmt = conn.prepare(
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
).unwrap();
let agents: Vec<BtmsgAgent> = stmt.query_map(params!["g1"], |row| {
Ok(BtmsgAgent {
id: row.get("id")?,
name: row.get("name")?,
role: row.get("role")?,
group_id: row.get("group_id")?,
tier: row.get("tier")?,
model: row.get("model")?,
status: row.get::<_, Option<String>>("status")?.unwrap_or_else(|| "stopped".into()),
unread_count: row.get("unread_count")?,
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(agents.len(), 3);
// admin (tier 0) comes first
assert_eq!(agents[0].id, "admin");
assert_eq!(agents[0].status, "active");
assert_eq!(agents[0].tier, 0);
// Coder — status must be "active", NOT "You are a coder" (system_prompt)
let coder = agents.iter().find(|a| a.id == "a1").unwrap();
assert_eq!(coder.status, "active");
assert_eq!(coder.name, "Coder");
assert_eq!(coder.model, Some("claude-4".to_string()));
// Reviewer — status must be "sleeping", NOT "You review code" (system_prompt)
let reviewer = agents.iter().find(|a| a.id == "a2").unwrap();
assert_eq!(reviewer.status, "sleeping");
assert_eq!(reviewer.model, None);
}
#[test]
fn test_get_agents_unread_count() {
let conn = test_db();
seed_agents(&conn);
// Send 2 unread messages to a1
conn.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m1', 'a2', 'a1', 'hi', 'g1', 0)",
[],
).unwrap();
conn.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m2', 'admin', 'a1', 'task', 'g1', 0)",
[],
).unwrap();
// Send 1 read message to a1
conn.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m3', 'a2', 'a1', 'old', 'g1', 1)",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
).unwrap();
let agents: Vec<BtmsgAgent> = stmt.query_map(params!["g1"], |row| {
Ok(BtmsgAgent {
id: row.get("id")?,
name: row.get("name")?,
role: row.get("role")?,
group_id: row.get("group_id")?,
tier: row.get("tier")?,
model: row.get("model")?,
status: row.get::<_, Option<String>>("status")?.unwrap_or_else(|| "stopped".into()),
unread_count: row.get("unread_count")?,
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
let coder = agents.iter().find(|a| a.id == "a1").unwrap();
assert_eq!(coder.unread_count, 2);
let reviewer = agents.iter().find(|a| a.id == "a2").unwrap();
assert_eq!(reviewer.unread_count, 0);
}
// ---- REGRESSION: all_feed JOIN alias disambiguation ----
// Bug: JOINed queries had duplicate "name" columns. Fixed with AS aliases.
#[test]
fn test_all_feed_returns_correct_sender_and_recipient_names() {
let conn = test_db();
seed_agents(&conn);
conn.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id) VALUES ('m1', 'a1', 'a2', 'review this', 'g1')",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.created_at, m.reply_to, \
a1.name AS sender_name, a1.role AS sender_role, \
a2.name AS recipient_name, a2.role AS recipient_role \
FROM messages m \
JOIN agents a1 ON m.from_agent = a1.id \
JOIN agents a2 ON m.to_agent = a2.id \
WHERE m.group_id = ? \
ORDER BY m.created_at DESC LIMIT ?"
).unwrap();
let msgs: Vec<BtmsgFeedMessage> = stmt.query_map(params!["g1", 100], |row| {
Ok(BtmsgFeedMessage {
id: row.get("id")?,
from_agent: row.get("from_agent")?,
to_agent: row.get("to_agent")?,
content: row.get("content")?,
created_at: row.get("created_at")?,
reply_to: row.get("reply_to")?,
sender_name: row.get("sender_name")?,
sender_role: row.get("sender_role")?,
recipient_name: row.get("recipient_name")?,
recipient_role: row.get("recipient_role")?,
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender_name, "Coder");
assert_eq!(msgs[0].sender_role, "developer");
assert_eq!(msgs[0].recipient_name, "Reviewer");
assert_eq!(msgs[0].recipient_role, "reviewer");
}
// ---- REGRESSION: unread_messages JOIN alias ----
#[test]
fn test_unread_messages_returns_sender_info() {
let conn = test_db();
seed_agents(&conn);
conn.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, read) VALUES ('m1', 'a1', 'a2', 'check this', 'g1', 0)",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
a.name AS sender_name, a.role AS sender_role \
FROM messages m JOIN agents a ON m.from_agent = a.id \
WHERE m.to_agent = ? AND m.read = 0 ORDER BY m.created_at ASC"
).unwrap();
let msgs: Vec<BtmsgMessage> = stmt.query_map(params!["a2"], |row| {
Ok(BtmsgMessage {
id: row.get("id")?,
from_agent: row.get("from_agent")?,
to_agent: row.get("to_agent")?,
content: row.get("content")?,
read: row.get::<_, i32>("read")? != 0,
reply_to: row.get("reply_to")?,
created_at: row.get("created_at")?,
sender_name: row.get("sender_name")?,
sender_role: row.get("sender_role")?,
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender_name, Some("Coder".to_string()));
assert_eq!(msgs[0].sender_role, Some("developer".to_string()));
assert!(!msgs[0].read);
}
// ---- REGRESSION: channel_messages JOIN alias ----
#[test]
fn test_channel_messages_returns_sender_info() {
let conn = test_db();
seed_agents(&conn);
conn.execute(
"INSERT INTO channels (id, name, group_id, created_by) VALUES ('ch1', 'general', 'g1', 'admin')",
[],
).unwrap();
conn.execute(
"INSERT INTO channel_members (channel_id, agent_id) VALUES ('ch1', 'a1')",
[],
).unwrap();
conn.execute(
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES ('cm1', 'ch1', 'a1', 'hello channel')",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at, \
a.name AS sender_name, a.role AS sender_role \
FROM channel_messages cm JOIN agents a ON cm.from_agent = a.id \
WHERE cm.channel_id = ? ORDER BY cm.created_at ASC LIMIT ?"
).unwrap();
let msgs: Vec<BtmsgChannelMessage> = stmt.query_map(params!["ch1", 100], |row| {
Ok(BtmsgChannelMessage {
id: row.get("id")?,
channel_id: row.get("channel_id")?,
from_agent: row.get("from_agent")?,
content: row.get("content")?,
created_at: row.get("created_at")?,
sender_name: row.get("sender_name")?,
sender_role: row.get("sender_role")?,
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender_name, "Coder");
assert_eq!(msgs[0].sender_role, "developer");
}
// ---- serde camelCase serialization ----
#[test]
fn test_btmsg_agent_serializes_to_camel_case() {
let agent = BtmsgAgent {
id: "a1".into(),
name: "Test".into(),
role: "dev".into(),
group_id: "g1".into(),
tier: 1,
model: None,
status: "active".into(),
unread_count: 5,
};
let json = serde_json::to_value(&agent).unwrap();
// Verify camelCase keys (matches TypeScript interface)
assert!(json.get("groupId").is_some(), "expected camelCase 'groupId'");
assert!(json.get("unreadCount").is_some(), "expected camelCase 'unreadCount'");
assert!(json.get("group_id").is_none(), "should not have snake_case 'group_id'");
assert!(json.get("unread_count").is_none(), "should not have snake_case 'unread_count'");
}
#[test]
fn test_btmsg_message_serializes_to_camel_case() {
let msg = BtmsgMessage {
id: "m1".into(),
from_agent: "a1".into(),
to_agent: "a2".into(),
content: "hi".into(),
read: false,
reply_to: None,
created_at: "2026-01-01".into(),
sender_name: Some("Coder".into()),
sender_role: Some("dev".into()),
};
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("fromAgent").is_some(), "expected camelCase 'fromAgent'");
assert!(json.get("toAgent").is_some(), "expected camelCase 'toAgent'");
assert!(json.get("replyTo").is_some(), "expected camelCase 'replyTo'");
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
assert!(json.get("senderName").is_some(), "expected camelCase 'senderName'");
assert!(json.get("senderRole").is_some(), "expected camelCase 'senderRole'");
}
#[test]
fn test_btmsg_feed_message_serializes_to_camel_case() {
let msg = BtmsgFeedMessage {
id: "m1".into(),
from_agent: "a1".into(),
to_agent: "a2".into(),
content: "hi".into(),
created_at: "2026-01-01".into(),
reply_to: None,
sender_name: "Coder".into(),
sender_role: "dev".into(),
recipient_name: "Reviewer".into(),
recipient_role: "reviewer".into(),
};
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("recipientName").is_some(), "expected camelCase 'recipientName'");
assert!(json.get("recipientRole").is_some(), "expected camelCase 'recipientRole'");
}
}

View file

@ -172,3 +172,194 @@ pub fn delete_task(task_id: &str) -> Result<(), String> {
.map_err(|e| format!("Delete task error: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'todo',
priority TEXT DEFAULT 'medium',
assigned_to TEXT,
created_by TEXT NOT NULL,
group_id TEXT NOT NULL,
parent_task_id TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE task_comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);",
)
.unwrap();
conn
}
// ---- REGRESSION: list_tasks named column access ----
#[test]
fn test_list_tasks_named_column_access() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
VALUES ('t1', 'Fix bug', 'Critical fix', 'progress', 'high', 'a1', 'admin', 'g1', 1)",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
VALUES ('t2', 'Add tests', '', 'todo', 'medium', NULL, 'a1', 'g1', 2)",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT id, title, description, status, priority, assigned_to,
created_by, group_id, parent_task_id, sort_order,
created_at, updated_at
FROM tasks WHERE group_id = ?1
ORDER BY sort_order ASC, created_at DESC",
).unwrap();
let tasks: Vec<Task> = stmt.query_map(params!["g1"], |row| {
Ok(Task {
id: row.get("id")?,
title: row.get("title")?,
description: row.get::<_, String>("description").unwrap_or_default(),
status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()),
priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()),
assigned_to: row.get("assigned_to")?,
created_by: row.get("created_by")?,
group_id: row.get("group_id")?,
parent_task_id: row.get("parent_task_id")?,
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, "t1");
assert_eq!(tasks[0].title, "Fix bug");
assert_eq!(tasks[0].status, "progress");
assert_eq!(tasks[0].priority, "high");
assert_eq!(tasks[0].assigned_to, Some("a1".to_string()));
assert_eq!(tasks[0].sort_order, 1);
assert_eq!(tasks[1].id, "t2");
assert_eq!(tasks[1].assigned_to, None);
assert_eq!(tasks[1].parent_task_id, None);
}
// ---- REGRESSION: task_comments named column access ----
#[test]
fn test_task_comments_named_column_access() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Test', 'admin', 'g1')",
[],
).unwrap();
conn.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c1', 't1', 'a1', 'Working on it')",
[],
).unwrap();
conn.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c2', 't1', 'a2', 'Looks good')",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT id, task_id, agent_id, content, created_at
FROM task_comments WHERE task_id = ?1
ORDER BY created_at ASC",
).unwrap();
let comments: Vec<TaskComment> = stmt.query_map(params!["t1"], |row| {
Ok(TaskComment {
id: row.get("id")?,
task_id: row.get("task_id")?,
agent_id: row.get("agent_id")?,
content: row.get("content")?,
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].agent_id, "a1");
assert_eq!(comments[0].content, "Working on it");
assert_eq!(comments[1].agent_id, "a2");
}
// ---- serde camelCase serialization ----
#[test]
fn test_task_serializes_to_camel_case() {
let task = Task {
id: "t1".into(),
title: "Test".into(),
description: "desc".into(),
status: "todo".into(),
priority: "high".into(),
assigned_to: Some("a1".into()),
created_by: "admin".into(),
group_id: "g1".into(),
parent_task_id: None,
sort_order: 0,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
};
let json = serde_json::to_value(&task).unwrap();
assert!(json.get("assignedTo").is_some(), "expected camelCase 'assignedTo'");
assert!(json.get("createdBy").is_some(), "expected camelCase 'createdBy'");
assert!(json.get("groupId").is_some(), "expected camelCase 'groupId'");
assert!(json.get("parentTaskId").is_some(), "expected camelCase 'parentTaskId'");
assert!(json.get("sortOrder").is_some(), "expected camelCase 'sortOrder'");
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
assert!(json.get("updatedAt").is_some(), "expected camelCase 'updatedAt'");
// Ensure no snake_case leaks
assert!(json.get("assigned_to").is_none());
assert!(json.get("created_by").is_none());
assert!(json.get("group_id").is_none());
}
#[test]
fn test_task_comment_serializes_to_camel_case() {
let comment = TaskComment {
id: "c1".into(),
task_id: "t1".into(),
agent_id: "a1".into(),
content: "note".into(),
created_at: "2026-01-01".into(),
};
let json = serde_json::to_value(&comment).unwrap();
assert!(json.get("taskId").is_some(), "expected camelCase 'taskId'");
assert!(json.get("agentId").is_some(), "expected camelCase 'agentId'");
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
assert!(json.get("task_id").is_none());
}
// ---- update_task_status validation ----
#[test]
fn test_update_task_status_rejects_invalid() {
// Can't call update_task_status directly (uses open_db), but we can test the validation logic
let valid = ["todo", "progress", "review", "done", "blocked"];
assert!(valid.contains(&"todo"));
assert!(valid.contains(&"done"));
assert!(!valid.contains(&"invalid"));
assert!(!valid.contains(&"cancelled"));
}
}

View file

@ -191,6 +191,7 @@ mod tests {
profile: "default".to_string(),
enabled: true,
}],
agents: vec![],
}],
active_group_id: "test".to_string(),
};