From e41d2377455ec0a4d069fca37ee755325e1466da Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 22:19:03 +0100 Subject: [PATCH] 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 --- v2/src-tauri/src/btmsg.rs | 365 ++++++++++++++++++++++ v2/src-tauri/src/bttask.rs | 191 +++++++++++ v2/src-tauri/src/groups.rs | 1 + v2/src/lib/adapters/btmsg-bridge.test.ts | 238 ++++++++++++++ v2/src/lib/adapters/bttask-bridge.test.ts | 150 +++++++++ v2/src/lib/utils/plantuml-encode.test.ts | 67 ++++ 6 files changed, 1012 insertions(+) create mode 100644 v2/src/lib/adapters/btmsg-bridge.test.ts create mode 100644 v2/src/lib/adapters/bttask-bridge.test.ts create mode 100644 v2/src/lib/utils/plantuml-encode.test.ts diff --git a/v2/src-tauri/src/btmsg.rs b/v2/src-tauri/src/btmsg.rs index 48a53b5..e554d91 100644 --- a/v2/src-tauri/src/btmsg.rs +++ b/v2/src-tauri/src/btmsg.rs @@ -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 = 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>("status")?.unwrap_or_else(|| "stopped".into()), + unread_count: row.get("unread_count")?, + }) + }).unwrap().collect::, _>>().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 = 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>("status")?.unwrap_or_else(|| "stopped".into()), + unread_count: row.get("unread_count")?, + }) + }).unwrap().collect::, _>>().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 = 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::, _>>().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 = 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::, _>>().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 = 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::, _>>().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'"); + } +} diff --git a/v2/src-tauri/src/bttask.rs b/v2/src-tauri/src/bttask.rs index 00984d0..e708b5b 100644 --- a/v2/src-tauri/src/bttask.rs +++ b/v2/src-tauri/src/bttask.rs @@ -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 = 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::, _>>().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 = 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::, _>>().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")); + } +} diff --git a/v2/src-tauri/src/groups.rs b/v2/src-tauri/src/groups.rs index e0afb42..26b7942 100644 --- a/v2/src-tauri/src/groups.rs +++ b/v2/src-tauri/src/groups.rs @@ -191,6 +191,7 @@ mod tests { profile: "default".to_string(), enabled: true, }], + agents: vec![], }], active_group_id: "test".to_string(), }; diff --git a/v2/src/lib/adapters/btmsg-bridge.test.ts b/v2/src/lib/adapters/btmsg-bridge.test.ts new file mode 100644 index 0000000..e92bc11 --- /dev/null +++ b/v2/src/lib/adapters/btmsg-bridge.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockInvoke } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +import { + getGroupAgents, + getUnreadCount, + getUnreadMessages, + getHistory, + sendMessage, + setAgentStatus, + ensureAdmin, + getAllFeed, + markRead, + getChannels, + getChannelMessages, + sendChannelMessage, + createChannel, + addChannelMember, + type BtmsgAgent, + type BtmsgMessage, + type BtmsgFeedMessage, + type BtmsgChannel, + type BtmsgChannelMessage, +} from './btmsg-bridge'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('btmsg-bridge', () => { + // ---- REGRESSION: camelCase field names ---- + // Bug: TypeScript interfaces used snake_case (group_id, unread_count, from_agent, etc.) + // but Rust serde(rename_all = "camelCase") sends camelCase. + + describe('BtmsgAgent camelCase fields', () => { + it('receives camelCase fields from Rust backend', async () => { + const agent: BtmsgAgent = { + id: 'a1', + name: 'Coder', + role: 'developer', + groupId: 'g1', // was: group_id + tier: 1, + model: 'claude-4', + status: 'active', + unreadCount: 3, // was: unread_count + }; + mockInvoke.mockResolvedValue([agent]); + + const result = await getGroupAgents('g1'); + + expect(result).toHaveLength(1); + expect(result[0].groupId).toBe('g1'); + expect(result[0].unreadCount).toBe(3); + // Verify snake_case fields do NOT exist + expect((result[0] as Record)['group_id']).toBeUndefined(); + expect((result[0] as Record)['unread_count']).toBeUndefined(); + }); + + it('invokes btmsg_get_agents with groupId', async () => { + mockInvoke.mockResolvedValue([]); + await getGroupAgents('g1'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' }); + }); + }); + + describe('BtmsgMessage camelCase fields', () => { + it('receives camelCase fields from Rust backend', async () => { + const msg: BtmsgMessage = { + id: 'm1', + fromAgent: 'a1', // was: from_agent + toAgent: 'a2', // was: to_agent + content: 'hello', + read: false, + replyTo: null, // was: reply_to + createdAt: '2026-01-01', // was: created_at + senderName: 'Coder', // was: sender_name + senderRole: 'dev', // was: sender_role + }; + mockInvoke.mockResolvedValue([msg]); + + const result = await getUnreadMessages('a2'); + + expect(result[0].fromAgent).toBe('a1'); + expect(result[0].toAgent).toBe('a2'); + expect(result[0].replyTo).toBeNull(); + expect(result[0].createdAt).toBe('2026-01-01'); + expect(result[0].senderName).toBe('Coder'); + expect(result[0].senderRole).toBe('dev'); + }); + }); + + describe('BtmsgFeedMessage camelCase fields', () => { + it('receives camelCase fields including recipient info', async () => { + const feed: BtmsgFeedMessage = { + id: 'm1', + fromAgent: 'a1', + toAgent: 'a2', + content: 'review this', + createdAt: '2026-01-01', + replyTo: null, + senderName: 'Coder', + senderRole: 'developer', + recipientName: 'Reviewer', + recipientRole: 'reviewer', + }; + mockInvoke.mockResolvedValue([feed]); + + const result = await getAllFeed('g1'); + + expect(result[0].senderName).toBe('Coder'); + expect(result[0].recipientName).toBe('Reviewer'); + expect(result[0].recipientRole).toBe('reviewer'); + }); + }); + + describe('BtmsgChannel camelCase fields', () => { + it('receives camelCase fields', async () => { + const channel: BtmsgChannel = { + id: 'ch1', + name: 'general', + groupId: 'g1', // was: group_id + createdBy: 'admin', // was: created_by + memberCount: 5, // was: member_count + createdAt: '2026-01-01', + }; + mockInvoke.mockResolvedValue([channel]); + + const result = await getChannels('g1'); + + expect(result[0].groupId).toBe('g1'); + expect(result[0].createdBy).toBe('admin'); + expect(result[0].memberCount).toBe(5); + }); + }); + + describe('BtmsgChannelMessage camelCase fields', () => { + it('receives camelCase fields', async () => { + const msg: BtmsgChannelMessage = { + id: 'cm1', + channelId: 'ch1', // was: channel_id + fromAgent: 'a1', + content: 'hello', + createdAt: '2026-01-01', + senderName: 'Coder', + senderRole: 'dev', + }; + mockInvoke.mockResolvedValue([msg]); + + const result = await getChannelMessages('ch1'); + + expect(result[0].channelId).toBe('ch1'); + expect(result[0].fromAgent).toBe('a1'); + expect(result[0].senderName).toBe('Coder'); + }); + }); + + // ---- IPC command name tests ---- + + describe('IPC commands', () => { + it('getUnreadCount invokes btmsg_unread_count', async () => { + mockInvoke.mockResolvedValue(5); + const result = await getUnreadCount('a1'); + expect(result).toBe(5); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' }); + }); + + it('getHistory invokes btmsg_history', async () => { + mockInvoke.mockResolvedValue([]); + await getHistory('a1', 'a2', 50); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 }); + }); + + it('getHistory defaults limit to 20', async () => { + mockInvoke.mockResolvedValue([]); + await getHistory('a1', 'a2'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 }); + }); + + it('sendMessage invokes btmsg_send', async () => { + mockInvoke.mockResolvedValue('msg-id'); + const result = await sendMessage('a1', 'a2', 'hello'); + expect(result).toBe('msg-id'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' }); + }); + + it('setAgentStatus invokes btmsg_set_status', async () => { + mockInvoke.mockResolvedValue(undefined); + await setAgentStatus('a1', 'active'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' }); + }); + + it('ensureAdmin invokes btmsg_ensure_admin', async () => { + mockInvoke.mockResolvedValue(undefined); + await ensureAdmin('g1'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' }); + }); + + it('markRead invokes btmsg_mark_read', async () => { + mockInvoke.mockResolvedValue(undefined); + await markRead('a2', 'a1'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' }); + }); + + it('sendChannelMessage invokes btmsg_channel_send', async () => { + mockInvoke.mockResolvedValue('cm-id'); + const result = await sendChannelMessage('ch1', 'a1', 'hello channel'); + expect(result).toBe('cm-id'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' }); + }); + + it('createChannel invokes btmsg_create_channel', async () => { + mockInvoke.mockResolvedValue('ch-id'); + const result = await createChannel('general', 'g1', 'admin'); + expect(result).toBe('ch-id'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' }); + }); + + it('addChannelMember invokes btmsg_add_channel_member', async () => { + mockInvoke.mockResolvedValue(undefined); + await addChannelMember('ch1', 'a1'); + expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' }); + }); + }); + + describe('error propagation', () => { + it('propagates invoke errors', async () => { + mockInvoke.mockRejectedValue(new Error('btmsg database not found')); + await expect(getGroupAgents('g1')).rejects.toThrow('btmsg database not found'); + }); + }); +}); diff --git a/v2/src/lib/adapters/bttask-bridge.test.ts b/v2/src/lib/adapters/bttask-bridge.test.ts new file mode 100644 index 0000000..779d127 --- /dev/null +++ b/v2/src/lib/adapters/bttask-bridge.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockInvoke } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +import { + listTasks, + getTaskComments, + updateTaskStatus, + addTaskComment, + createTask, + deleteTask, + type Task, + type TaskComment, +} from './bttask-bridge'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('bttask-bridge', () => { + // ---- REGRESSION: camelCase field names ---- + + describe('Task camelCase fields', () => { + it('receives camelCase fields from Rust backend', async () => { + const task: Task = { + id: 't1', + title: 'Fix bug', + description: 'Critical fix', + status: 'progress', + priority: 'high', + assignedTo: 'a1', // was: assigned_to + createdBy: 'admin', // was: created_by + groupId: 'g1', // was: group_id + parentTaskId: null, // was: parent_task_id + sortOrder: 1, // was: sort_order + createdAt: '2026-01-01', // was: created_at + updatedAt: '2026-01-01', // was: updated_at + }; + mockInvoke.mockResolvedValue([task]); + + const result = await listTasks('g1'); + + expect(result).toHaveLength(1); + expect(result[0].assignedTo).toBe('a1'); + expect(result[0].createdBy).toBe('admin'); + expect(result[0].groupId).toBe('g1'); + expect(result[0].parentTaskId).toBeNull(); + expect(result[0].sortOrder).toBe(1); + // Verify no snake_case leaks + expect((result[0] as Record)['assigned_to']).toBeUndefined(); + expect((result[0] as Record)['created_by']).toBeUndefined(); + expect((result[0] as Record)['group_id']).toBeUndefined(); + }); + }); + + describe('TaskComment camelCase fields', () => { + it('receives camelCase fields from Rust backend', async () => { + const comment: TaskComment = { + id: 'c1', + taskId: 't1', // was: task_id + agentId: 'a1', // was: agent_id + content: 'Working on it', + createdAt: '2026-01-01', + }; + mockInvoke.mockResolvedValue([comment]); + + const result = await getTaskComments('t1'); + + expect(result[0].taskId).toBe('t1'); + expect(result[0].agentId).toBe('a1'); + expect((result[0] as Record)['task_id']).toBeUndefined(); + expect((result[0] as Record)['agent_id']).toBeUndefined(); + }); + }); + + // ---- IPC command name tests ---- + + describe('IPC commands', () => { + it('listTasks invokes bttask_list', async () => { + mockInvoke.mockResolvedValue([]); + await listTasks('g1'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' }); + }); + + it('getTaskComments invokes bttask_comments', async () => { + mockInvoke.mockResolvedValue([]); + await getTaskComments('t1'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' }); + }); + + it('updateTaskStatus invokes bttask_update_status', async () => { + mockInvoke.mockResolvedValue(undefined); + await updateTaskStatus('t1', 'done'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'done' }); + }); + + it('addTaskComment invokes bttask_add_comment', async () => { + mockInvoke.mockResolvedValue('c-id'); + const result = await addTaskComment('t1', 'a1', 'Done!'); + expect(result).toBe('c-id'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' }); + }); + + it('createTask invokes bttask_create with all fields', async () => { + mockInvoke.mockResolvedValue('t-id'); + const result = await createTask('Fix bug', 'desc', 'high', 'g1', 'admin', 'a1'); + expect(result).toBe('t-id'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_create', { + title: 'Fix bug', + description: 'desc', + priority: 'high', + groupId: 'g1', + createdBy: 'admin', + assignedTo: 'a1', + }); + }); + + it('createTask invokes bttask_create without assignedTo', async () => { + mockInvoke.mockResolvedValue('t-id'); + await createTask('Add tests', '', 'medium', 'g1', 'a1'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_create', { + title: 'Add tests', + description: '', + priority: 'medium', + groupId: 'g1', + createdBy: 'a1', + assignedTo: undefined, + }); + }); + + it('deleteTask invokes bttask_delete', async () => { + mockInvoke.mockResolvedValue(undefined); + await deleteTask('t1'); + expect(mockInvoke).toHaveBeenCalledWith('bttask_delete', { taskId: 't1' }); + }); + }); + + describe('error propagation', () => { + it('propagates invoke errors', async () => { + mockInvoke.mockRejectedValue(new Error('btmsg database not found')); + await expect(listTasks('g1')).rejects.toThrow('btmsg database not found'); + }); + }); +}); diff --git a/v2/src/lib/utils/plantuml-encode.test.ts b/v2/src/lib/utils/plantuml-encode.test.ts new file mode 100644 index 0000000..ef8d1d6 --- /dev/null +++ b/v2/src/lib/utils/plantuml-encode.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; + +// ---- REGRESSION: PlantUML hex encoding ---- +// Bug: ArchitectureTab had a broken encoding chain (rawDeflate returned input unchanged, +// encode64 was hex encoding masquerading as base64). Fixed by collapsing to single +// plantumlEncode function using ~h hex prefix (plantuml.com text encoding standard). +// +// This test validates the encoding algorithm matches what ArchitectureTab.svelte uses. + +/** Reimplementation of the plantumlEncode function from ArchitectureTab.svelte */ +function plantumlEncode(text: string): string { + const bytes = unescape(encodeURIComponent(text)); + let hex = '~h'; + for (let i = 0; i < bytes.length; i++) { + hex += bytes.charCodeAt(i).toString(16).padStart(2, '0'); + } + return hex; +} + +describe('plantumlEncode', () => { + it('produces ~h prefix for hex encoding', () => { + const result = plantumlEncode('@startuml\n@enduml'); + expect(result.startsWith('~h')).toBe(true); + }); + + it('encodes ASCII correctly', () => { + const result = plantumlEncode('AB'); + // A=0x41, B=0x42 + expect(result).toBe('~h4142'); + }); + + it('encodes simple PlantUML source', () => { + const result = plantumlEncode('@startuml\n@enduml'); + // Each character maps to its hex code + const expected = '~h' + Array.from('@startuml\n@enduml') + .map(c => c.charCodeAt(0).toString(16).padStart(2, '0')) + .join(''); + expect(result).toBe(expected); + }); + + it('handles Unicode characters', () => { + // UTF-8 multi-byte: é = 0xc3 0xa9 + const result = plantumlEncode('café'); + expect(result.startsWith('~h')).toBe(true); + // c=63, a=61, f=66, é=c3a9 + expect(result).toBe('~h636166c3a9'); + }); + + it('handles empty string', () => { + expect(plantumlEncode('')).toBe('~h'); + }); + + it('produces valid URL-safe output (no special chars beyond hex digits)', () => { + const result = plantumlEncode('@startuml\ntitle Test\nA -> B\n@enduml'); + // After ~h prefix, only hex digits [0-9a-f] + const hexPart = result.slice(2); + expect(hexPart).toMatch(/^[0-9a-f]+$/); + }); + + it('generates correct URL for plantuml.com', () => { + const source = '@startuml\nA -> B\n@enduml'; + const encoded = plantumlEncode(source); + const url = `https://www.plantuml.com/plantuml/svg/${encoded}`; + expect(url).toContain('plantuml.com/plantuml/svg/~h'); + expect(url.length).toBeGreaterThan(50); + }); +});