things3_cli/mcp/
test_harness.rs

1//! Test harness for MCP server testing
2
3use crate::mcp::{
4    CallToolRequest, CallToolResult, Content, GetPromptRequest, GetPromptResult, McpError,
5    ReadResourceRequest, ReadResourceResult, ThingsMcpServer,
6};
7use serde_json::Value;
8use std::path::Path;
9use tempfile::NamedTempFile;
10use things3_core::{config::ThingsConfig, ThingsDatabase};
11// use std::sync::Arc; // Not needed for test harness
12
13/// Test harness for MCP server operations
14pub struct McpTestHarness {
15    server: ThingsMcpServer,
16    temp_file: NamedTempFile,
17}
18
19impl McpTestHarness {
20    /// Create a new test harness with a fresh database
21    ///
22    /// # Panics
23    /// Panics if the database cannot be creationDate or the server cannot be initialized
24    #[must_use]
25    pub fn new() -> Self {
26        Self::new_with_config(crate::mcp::MiddlewareConfig::default())
27    }
28
29    /// Create a new test harness with a fresh database and custom middleware config
30    ///
31    /// # Panics
32    /// Panics if the database cannot be creationDate or the server cannot be initialized
33    #[must_use]
34    pub fn new_with_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
35        let temp_file = NamedTempFile::new().unwrap();
36        let db_path = temp_file.path().to_path_buf();
37        let db_path_clone = db_path.clone();
38
39        // Create test database synchronously to avoid nested runtime issues
40        let db = std::thread::spawn(move || {
41            tokio::runtime::Runtime::new()
42                .unwrap()
43                .block_on(async { Self::create_test_database(&db_path_clone).await })
44        })
45        .join()
46        .unwrap();
47
48        let config = ThingsConfig::new(&db_path, false);
49        let server = ThingsMcpServer::with_middleware_config(db, config, middleware_config);
50
51        Self { server, temp_file }
52    }
53
54    /// Create a test harness with custom middleware configuration
55    ///
56    /// # Panics
57    /// Panics if the database cannot be creationDate or the server cannot be initialized
58    #[must_use]
59    pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
60        Self::new_with_config(middleware_config)
61    }
62
63    /// Get a reference to the MCP server
64    #[must_use]
65    pub fn server(&self) -> &ThingsMcpServer {
66        &self.server
67    }
68
69    /// Get the database path for additional testing
70    #[must_use]
71    pub fn db_path(&self) -> &Path {
72        self.temp_file.path()
73    }
74
75    /// Call a tool and return the result
76    ///
77    /// # Panics
78    /// Panics if the tool call fails
79    pub async fn call_tool(&self, name: &str, arguments: Option<Value>) -> CallToolResult {
80        let request = CallToolRequest {
81            name: name.to_string(),
82            arguments,
83        };
84        self.server.call_tool(request).await.unwrap()
85    }
86
87    /// Call a tool with fallback error handling
88    pub async fn call_tool_with_fallback(
89        &self,
90        name: &str,
91        arguments: Option<Value>,
92    ) -> CallToolResult {
93        let request = CallToolRequest {
94            name: name.to_string(),
95            arguments,
96        };
97        self.server.call_tool_with_fallback(request).await
98    }
99
100    /// Read a resource and return the result
101    ///
102    /// # Panics
103    /// Panics if the resource read fails
104    pub async fn read_resource(&self, uri: &str) -> ReadResourceResult {
105        let request = ReadResourceRequest {
106            uri: uri.to_string(),
107        };
108        self.server.read_resource(request).await.unwrap()
109    }
110
111    /// Read a resource and return the result or error
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the resource cannot be read or if the MCP server is not available.
116    pub async fn read_resource_result(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
117        let request = ReadResourceRequest {
118            uri: uri.to_string(),
119        };
120        self.server.read_resource(request).await
121    }
122
123    /// Read a resource with fallback error handling
124    pub async fn read_resource_with_fallback(&self, uri: &str) -> ReadResourceResult {
125        let request = ReadResourceRequest {
126            uri: uri.to_string(),
127        };
128        self.server.read_resource_with_fallback(request).await
129    }
130
131    /// Get a prompt
132    ///
133    /// # Panics
134    /// Panics if the prompt request fails
135    pub async fn get_prompt(&self, name: &str, arguments: Option<Value>) -> GetPromptResult {
136        let request = GetPromptRequest {
137            name: name.to_string(),
138            arguments,
139        };
140        self.server.get_prompt(request).await.unwrap()
141    }
142
143    /// Get a prompt and return the result or error
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the prompt cannot be retrieved or if the MCP server is not available.
148    pub async fn get_prompt_result(
149        &self,
150        name: &str,
151        arguments: Option<Value>,
152    ) -> Result<GetPromptResult, McpError> {
153        let request = GetPromptRequest {
154            name: name.to_string(),
155            arguments,
156        };
157        self.server.get_prompt(request).await
158    }
159
160    /// Get a prompt with fallback error handling
161    pub async fn get_prompt_with_fallback(
162        &self,
163        name: &str,
164        arguments: Option<Value>,
165    ) -> GetPromptResult {
166        let request = GetPromptRequest {
167            name: name.to_string(),
168            arguments,
169        };
170        self.server.get_prompt_with_fallback(request).await
171    }
172
173    /// Assert that a tool call succeeds
174    ///
175    /// # Panics
176    /// Panics if the tool call fails
177    pub async fn assert_tool_succeeds(
178        &self,
179        name: &str,
180        arguments: Option<Value>,
181    ) -> CallToolResult {
182        let result = self.call_tool(name, arguments).await;
183        assert!(
184            !result.is_error,
185            "Tool call '{name}' should succeed but failed"
186        );
187        result
188    }
189
190    /// Assert that a tool call fails with expected error
191    ///
192    /// # Panics
193    /// Panics if the tool call succeeds when it should fail
194    pub async fn assert_tool_fails_with<F>(
195        &self,
196        name: &str,
197        arguments: Option<Value>,
198        _expected_error: F,
199    ) where
200        F: FnOnce(&McpError) -> bool,
201    {
202        let result = self.call_tool_with_fallback(name, arguments).await;
203        assert!(
204            result.is_error,
205            "Tool call '{name}' should fail but succeeded"
206        );
207    }
208
209    /// Assert that a resource read succeeds
210    ///
211    /// # Panics
212    /// Panics if the resource read fails
213    pub async fn assert_resource_succeeds(&self, uri: &str) -> ReadResourceResult {
214        let result = self.read_resource(uri).await;
215        assert!(
216            !result.contents.is_empty(),
217            "Resource read '{uri}' should succeed"
218        );
219        result
220    }
221
222    /// Assert that a resource read fails with expected error
223    ///
224    /// # Panics
225    /// Panics if the resource read succeeds when it should fail
226    pub async fn assert_resource_fails_with<F>(&self, uri: &str, expected_error: F)
227    where
228        F: FnOnce(&McpError) -> bool,
229    {
230        let result = self.read_resource_result(uri).await;
231        match result {
232            Ok(_) => panic!("Resource read '{uri}' should fail but succeeded"),
233            Err(e) => assert!(
234                expected_error(&e),
235                "Resource read '{uri}' failed with unexpected error: {e:?}"
236            ),
237        }
238    }
239
240    /// Assert that a prompt succeeds
241    ///
242    /// # Panics
243    /// Panics if the prompt request fails
244    pub async fn assert_prompt_succeeds(
245        &self,
246        name: &str,
247        arguments: Option<Value>,
248    ) -> GetPromptResult {
249        let result = self.get_prompt(name, arguments).await;
250        assert!(
251            !result.is_error,
252            "Prompt '{name}' should succeed but failed"
253        );
254        result
255    }
256
257    /// Assert that a prompt fails with expected error
258    ///
259    /// # Panics
260    /// Panics if the prompt request succeeds when it should fail
261    pub async fn assert_prompt_fails_with<F>(
262        &self,
263        name: &str,
264        arguments: Option<Value>,
265        expected_error: F,
266    ) where
267        F: FnOnce(&McpError) -> bool,
268    {
269        let result = self.get_prompt_result(name, arguments).await;
270        match result {
271            Ok(_) => panic!("Prompt '{name}' should fail but succeeded"),
272            Err(e) => assert!(
273                expected_error(&e),
274                "Prompt '{name}' failed with unexpected error: {e:?}"
275            ),
276        }
277    }
278
279    /// Assert that a tool call returns valid JSON
280    ///
281    /// # Panics
282    /// Panics if the tool call fails or returns invalid JSON
283    pub async fn assert_tool_returns_json(&self, name: &str, arguments: Option<Value>) -> Value {
284        let result = self.assert_tool_succeeds(name, arguments).await;
285        assert!(
286            !result.content.is_empty(),
287            "Tool call should return content"
288        );
289
290        match &result.content[0] {
291            Content::Text { text } => {
292                serde_json::from_str(text).expect("Tool call should return valid JSON")
293            }
294        }
295    }
296
297    /// Assert that a resource read returns valid JSON
298    ///
299    /// # Panics
300    /// Panics if the resource read fails or returns invalid JSON
301    pub async fn assert_resource_returns_json(&self, uri: &str) -> Value {
302        let result = self.assert_resource_succeeds(uri).await;
303        assert!(
304            !result.contents.is_empty(),
305            "Resource read should return content"
306        );
307
308        match &result.contents[0] {
309            Content::Text { text } => {
310                serde_json::from_str(text).expect("Resource read should return valid JSON")
311            }
312        }
313    }
314
315    /// Assert that a prompt returns valid text
316    ///
317    /// # Panics
318    /// Panics if the prompt request fails or returns no text content
319    pub async fn assert_prompt_returns_text(&self, name: &str, arguments: Option<Value>) -> String {
320        let result = self.assert_prompt_succeeds(name, arguments).await;
321        assert!(!result.content.is_empty(), "Prompt should return content");
322
323        match &result.content[0] {
324            Content::Text { text } => text.clone(),
325        }
326    }
327
328    /// Create a comprehensive test database with mock data
329    #[allow(clippy::too_many_lines)]
330    async fn create_test_database<P: AsRef<Path>>(db_path: P) -> ThingsDatabase {
331        use sqlx::SqlitePool;
332
333        let database_url = format!("sqlite:{}", db_path.as_ref().display());
334        let pool = SqlitePool::connect(&database_url).await.unwrap();
335
336        // Create the Things 3 schema - matches real database structure
337        sqlx::query(
338            r"
339            -- TMTask table (main tasks table) - matches real Things 3 schema
340            CREATE TABLE IF NOT EXISTS TMTask (
341                uuid TEXT PRIMARY KEY,
342                title TEXT NOT NULL,
343                type INTEGER NOT NULL DEFAULT 0,
344                status INTEGER NOT NULL DEFAULT 0,
345                notes TEXT,
346                startDate INTEGER,
347                deadline INTEGER,
348                stopDate REAL,
349                creationDate REAL NOT NULL,
350                userModificationDate REAL NOT NULL,
351                project TEXT,
352                area TEXT,
353                heading TEXT,
354                trashed INTEGER NOT NULL DEFAULT 0,
355                tags TEXT DEFAULT '[]',
356                cachedTags BLOB,
357                todayIndex INTEGER
358            )
359            ",
360        )
361        .execute(&pool)
362        .await
363        .unwrap();
364
365        // Note: Projects are stored in TMTask table with type=1, no separate TMProject table
366
367        sqlx::query(
368            r"
369            -- TMArea table (areas table) - matches real Things 3 schema
370            CREATE TABLE IF NOT EXISTS TMArea (
371                uuid TEXT PRIMARY KEY,
372                title TEXT NOT NULL,
373                visible INTEGER NOT NULL DEFAULT 1,
374                'index' INTEGER NOT NULL DEFAULT 0,
375                creationDate REAL NOT NULL,
376                userModificationDate REAL NOT NULL
377            )
378            ",
379        )
380        .execute(&pool)
381        .await
382        .unwrap();
383
384        // Insert test data
385        // Use a safe conversion for timestamp to avoid precision loss
386        let timestamp_i64 = chrono::Utc::now().timestamp();
387        let now_timestamp = if timestamp_i64 <= i64::from(i32::MAX) {
388            f64::from(i32::try_from(timestamp_i64).unwrap_or(0))
389        } else {
390            // For very large timestamps, use a reasonable test value
391            1_700_000_000.0 // Represents a date around 2023
392        };
393
394        // Insert test areas
395        let now = chrono::Utc::now().timestamp() as f64;
396
397        sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
398            .bind("550e8400-e29b-41d4-a716-446655440001")
399            .bind("Work")
400            .bind(1) // visible
401            .bind(0) // index
402            .bind(now)
403            .bind(now)
404            .execute(&pool)
405            .await
406            .unwrap();
407
408        sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
409            .bind("550e8400-e29b-41d4-a716-446655440002")
410            .bind("Personal")
411            .bind(1) // visible
412            .bind(1) // index
413            .bind(now)
414            .bind(now)
415            .execute(&pool)
416            .await
417            .unwrap();
418
419        // Insert test projects (as TMTask with type=1)
420        sqlx::query(
421            "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
422        )
423        .bind("550e8400-e29b-41d4-a716-446655440010")
424        .bind("Website Redesign")
425        .bind(1) // type: project
426        .bind(0) // status: active
427        .bind("Complete redesign of company website")
428        .bind(now_timestamp)
429        .bind(now_timestamp)
430        .bind("550e8400-e29b-41d4-a716-446655440001") // work area
431        .bind(0) // not trashed
432        .bind("[\"work\", \"web\"]")
433        .execute(&pool).await.unwrap();
434
435        sqlx::query(
436            "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
437        )
438        .bind("550e8400-e29b-41d4-a716-446655440011")
439        .bind("Learn Rust")
440        .bind(1) // type: project
441        .bind(0) // status: active
442        .bind("Learn the Rust programming language")
443        .bind(now_timestamp)
444        .bind(now_timestamp)
445        .bind("550e8400-e29b-41d4-a716-446655440002")
446        .bind(0) // not trashed
447        .bind("[\"personal\", \"learning\"]")
448        .execute(&pool).await.unwrap();
449
450        // Insert test tasks - one in inbox (no project), one in project
451        sqlx::query(
452            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
453        )
454        .bind("550e8400-e29b-41d4-a716-446655440099")
455        .bind("Inbox Task")
456        .bind(0)
457        .bind(0)
458        .bind("A task in the inbox")
459        .bind::<Option<i64>>(None) // startDate: NULL
460        .bind::<Option<i64>>(None) // deadline: NULL
461        .bind(now_timestamp)
462        .bind(now_timestamp)
463        .bind::<Option<String>>(None) // No project (inbox) - use NULL instead of empty string
464        .bind("550e8400-e29b-41d4-a716-446655440001") // area: work area
465        .bind("") // heading: empty for top-level task
466        .bind(0) // not trashed
467        .bind("[\"inbox\"]")
468        .execute(&pool).await.unwrap();
469
470        sqlx::query(
471            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
472        )
473        .bind("550e8400-e29b-41d4-a716-446655440100")
474        .bind("Research competitors")
475        .bind(0)
476        .bind(0)
477        .bind("Look at competitor websites for inspiration")
478        .bind::<Option<i64>>(None) // startDate: NULL
479        .bind::<Option<i64>>(None) // deadline: NULL
480        .bind(now_timestamp)
481        .bind(now_timestamp)
482        .bind("550e8400-e29b-41d4-a716-446655440010")
483        .bind("550e8400-e29b-41d4-a716-446655440001") // area: work area
484        .bind("") // heading: empty for top-level task
485        .bind(0) // not trashed
486        .bind("[\"research\"]")
487        .execute(&pool).await.unwrap();
488
489        sqlx::query(
490            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
491        )
492        .bind("550e8400-e29b-41d4-a716-446655440101")
493        .bind("Read Rust book")
494        .bind(0)
495        .bind(0)
496        .bind("Read The Rust Programming Language book")
497        .bind::<Option<i64>>(None) // startDate: NULL
498        .bind::<Option<i64>>(None) // deadline: NULL
499        .bind(now_timestamp)
500        .bind(now_timestamp)
501        .bind("550e8400-e29b-41d4-a716-446655440011")
502        .bind("550e8400-e29b-41d4-a716-446655440002") // area: personal area
503        .bind("") // heading: empty for top-level task
504        .bind(0) // not trashed
505        .bind("[\"reading\"]")
506        .execute(&pool).await.unwrap();
507
508        pool.close().await;
509        ThingsDatabase::new(db_path.as_ref()).await.unwrap()
510    }
511}
512
513impl Default for McpTestHarness {
514    fn default() -> Self {
515        panic!("McpTestHarness::default() cannot be used in async context. Use McpTestHarness::new().await instead.")
516    }
517}
518
519/// Mock database for testing without real database dependencies
520pub struct MockDatabase {
521    pub tasks: Vec<MockTask>,
522    pub projects: Vec<MockProject>,
523    pub areas: Vec<MockArea>,
524}
525
526#[derive(Debug, Clone)]
527pub struct MockTask {
528    pub uuid: String,
529    pub title: String,
530    pub status: String,
531    pub project: Option<String>,
532    pub area: Option<String>,
533}
534
535#[derive(Debug, Clone)]
536pub struct MockProject {
537    pub uuid: String,
538    pub title: String,
539    pub area: Option<String>,
540    pub status: String,
541}
542
543#[derive(Debug, Clone)]
544pub struct MockArea {
545    pub uuid: String,
546    pub title: String,
547    pub visible: bool,
548}
549
550impl MockDatabase {
551    #[must_use]
552    pub fn new() -> Self {
553        Self {
554            tasks: Vec::new(),
555            projects: Vec::new(),
556            areas: Vec::new(),
557        }
558    }
559
560    pub fn add_task(&mut self, task: MockTask) {
561        self.tasks.push(task);
562    }
563
564    pub fn add_project(&mut self, project: MockProject) {
565        self.projects.push(project);
566    }
567
568    pub fn add_area(&mut self, area: MockArea) {
569        self.areas.push(area);
570    }
571
572    #[must_use]
573    pub fn get_task(&self, uuid: &str) -> Option<&MockTask> {
574        self.tasks.iter().find(|t| t.uuid == uuid)
575    }
576
577    #[must_use]
578    pub fn get_project(&self, uuid: &str) -> Option<&MockProject> {
579        self.projects.iter().find(|p| p.uuid == uuid)
580    }
581
582    #[must_use]
583    pub fn get_area(&self, uuid: &str) -> Option<&MockArea> {
584        self.areas.iter().find(|a| a.uuid == uuid)
585    }
586
587    #[must_use]
588    pub fn get_tasks_by_status(&self, status: &str) -> Vec<&MockTask> {
589        self.tasks.iter().filter(|t| t.status == status).collect()
590    }
591
592    #[must_use]
593    pub fn get_tasks_by_project(&self, project: &str) -> Vec<&MockTask> {
594        self.tasks
595            .iter()
596            .filter(|t| t.project.as_ref() == Some(&project.to_string()))
597            .collect()
598    }
599
600    #[must_use]
601    pub fn get_tasks_by_area(&self, area: &str) -> Vec<&MockTask> {
602        self.tasks
603            .iter()
604            .filter(|t| t.area.as_ref() == Some(&area.to_string()))
605            .collect()
606    }
607}
608
609impl Default for MockDatabase {
610    fn default() -> Self {
611        Self::new()
612    }
613}
614
615/// Test utilities for common MCP operations
616pub struct McpTestUtils;
617
618impl McpTestUtils {
619    /// Create a test tool request
620    #[must_use]
621    pub fn create_tool_request(name: &str, arguments: Option<Value>) -> CallToolRequest {
622        CallToolRequest {
623            name: name.to_string(),
624            arguments,
625        }
626    }
627
628    /// Create a test resource request
629    #[must_use]
630    pub fn create_resource_request(uri: &str) -> ReadResourceRequest {
631        ReadResourceRequest {
632            uri: uri.to_string(),
633        }
634    }
635
636    /// Create a test prompt request
637    #[must_use]
638    pub fn create_prompt_request(name: &str, arguments: Option<Value>) -> GetPromptRequest {
639        GetPromptRequest {
640            name: name.to_string(),
641            arguments,
642        }
643    }
644
645    /// Assert that a tool result contains expected content
646    ///
647    /// # Panics
648    /// Panics if the tool result is an error or doesn't contain the expected content
649    pub fn assert_tool_result_contains(result: &CallToolResult, expected_content: &str) {
650        assert!(!result.is_error, "Tool call should succeed");
651        assert!(
652            !result.content.is_empty(),
653            "Tool call should return content"
654        );
655
656        match &result.content[0] {
657            Content::Text { text } => {
658                assert!(
659                    text.contains(expected_content),
660                    "Tool result should contain: {expected_content}"
661                );
662            }
663        }
664    }
665
666    /// Assert that a resource result contains expected content
667    ///
668    /// # Panics
669    /// Panics if the resource result is empty or doesn't contain the expected content
670    pub fn assert_resource_result_contains(result: &ReadResourceResult, expected_content: &str) {
671        assert!(!result.contents.is_empty(), "Resource read should succeed");
672
673        match &result.contents[0] {
674            Content::Text { text } => {
675                assert!(
676                    text.contains(expected_content),
677                    "Resource result should contain: {expected_content}"
678                );
679            }
680        }
681    }
682
683    /// Assert that a prompt result contains expected content
684    ///
685    /// # Panics
686    /// Panics if the prompt result is an error or doesn't contain the expected content
687    pub fn assert_prompt_result_contains(result: &GetPromptResult, expected_content: &str) {
688        assert!(!result.is_error, "Prompt should succeed");
689        assert!(!result.content.is_empty(), "Prompt should return content");
690
691        match &result.content[0] {
692            Content::Text { text } => {
693                assert!(
694                    text.contains(expected_content),
695                    "Prompt result should contain: {expected_content}"
696                );
697            }
698        }
699    }
700
701    /// Assert that a tool result is valid JSON
702    ///
703    /// # Panics
704    /// Panics if the tool result is an error or contains invalid JSON
705    #[must_use]
706    pub fn assert_tool_result_is_json(result: &CallToolResult) -> Value {
707        assert!(!result.is_error, "Tool call should succeed");
708        assert!(
709            !result.content.is_empty(),
710            "Tool call should return content"
711        );
712
713        match &result.content[0] {
714            Content::Text { text } => {
715                serde_json::from_str(text).expect("Tool result should be valid JSON")
716            }
717        }
718    }
719
720    /// Assert that a resource result is valid JSON
721    ///
722    /// # Panics
723    /// Panics if the resource result is empty or contains invalid JSON
724    #[must_use]
725    pub fn assert_resource_result_is_json(result: &ReadResourceResult) -> Value {
726        assert!(!result.contents.is_empty(), "Resource read should succeed");
727
728        match &result.contents[0] {
729            Content::Text { text } => {
730                serde_json::from_str(text).expect("Resource result should be valid JSON")
731            }
732        }
733    }
734
735    /// Create test data for various scenarios
736    #[must_use]
737    pub fn create_test_data() -> MockDatabase {
738        Self::create_test_data_with_scenarios()
739    }
740
741    /// Create test data with specific scenarios
742    #[must_use]
743    pub fn create_test_data_with_scenarios() -> MockDatabase {
744        let mut db = MockDatabase::new();
745
746        // Add test areas
747        db.add_area(MockArea {
748            uuid: "area-1".to_string(),
749            title: "Work".to_string(),
750            visible: true,
751        });
752
753        db.add_area(MockArea {
754            uuid: "area-2".to_string(),
755            title: "Personal".to_string(),
756            visible: true,
757        });
758
759        // Add test projects
760        db.add_project(MockProject {
761            uuid: "project-1".to_string(),
762            title: "Website Redesign".to_string(),
763            area: Some("area-1".to_string()),
764            status: "incomplete".to_string(),
765        });
766
767        db.add_project(MockProject {
768            uuid: "project-2".to_string(),
769            title: "Another Project".to_string(),
770            area: Some("area-2".to_string()),
771            status: "incomplete".to_string(),
772        });
773
774        // Add test areas
775        db.add_area(MockArea {
776            uuid: "area-3".to_string(),
777            title: "Health".to_string(),
778            visible: true,
779        });
780
781        // Add test tasks
782        db.add_task(MockTask {
783            uuid: "task-1".to_string(),
784            title: "Research competitors".to_string(),
785            status: "incomplete".to_string(),
786            project: Some("project-1".to_string()),
787            area: None,
788        });
789
790        db.add_task(MockTask {
791            uuid: "task-urgent".to_string(),
792            title: "Urgent Task".to_string(),
793            status: "incomplete".to_string(),
794            project: Some("project-1".to_string()),
795            area: None,
796        });
797
798        db.add_task(MockTask {
799            uuid: "task-completed".to_string(),
800            title: "Completed Task".to_string(),
801            status: "completed".to_string(),
802            project: Some("project-2".to_string()),
803            area: None,
804        });
805
806        db.add_task(MockTask {
807            uuid: "task-2".to_string(),
808            title: "Read Rust book".to_string(),
809            status: "completed".to_string(),
810            project: Some("project-2".to_string()),
811            area: None,
812        });
813
814        db
815    }
816}
817
818/// Performance testing utilities for MCP operations
819pub struct McpPerformanceTest {
820    start_time: std::time::Instant,
821}
822
823impl McpPerformanceTest {
824    #[must_use]
825    pub fn new() -> Self {
826        Self {
827            start_time: std::time::Instant::now(),
828        }
829    }
830
831    #[must_use]
832    pub fn elapsed(&self) -> std::time::Duration {
833        self.start_time.elapsed()
834    }
835
836    /// Assert that the elapsed time is under the threshold
837    ///
838    /// # Panics
839    /// Panics if the operation took longer than the specified threshold
840    pub fn assert_under_threshold(&self, threshold: std::time::Duration) {
841        let elapsed = self.elapsed();
842        assert!(
843            elapsed < threshold,
844            "Operation took {elapsed:?}, which exceeds threshold of {threshold:?}"
845        );
846    }
847
848    pub fn assert_under_ms(&self, threshold_ms: u64) {
849        self.assert_under_threshold(std::time::Duration::from_millis(threshold_ms));
850    }
851}
852
853impl Default for McpPerformanceTest {
854    fn default() -> Self {
855        Self::new()
856    }
857}
858
859/// Integration test utilities for full MCP workflows
860pub struct McpIntegrationTest {
861    harness: McpTestHarness,
862}
863
864impl McpIntegrationTest {
865    #[must_use]
866    pub fn new() -> Self {
867        Self {
868            harness: McpTestHarness::new(),
869        }
870    }
871
872    #[must_use]
873    pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
874        Self {
875            harness: McpTestHarness::with_middleware_config(middleware_config),
876        }
877    }
878
879    #[must_use]
880    pub fn harness(&self) -> &McpTestHarness {
881        &self.harness
882    }
883
884    /// Test a complete workflow: list tools -> call tool -> verify result
885    ///
886    /// # Panics
887    /// Panics if the tool is not found or the workflow fails
888    pub async fn test_tool_workflow(
889        &self,
890        tool_name: &str,
891        arguments: Option<Value>,
892    ) -> CallToolResult {
893        // List tools first
894        let tools = self.harness.server().list_tools().unwrap();
895        assert!(!tools.tools.is_empty(), "Should have tools available");
896
897        // Call the tool
898        self.harness.call_tool(tool_name, arguments).await
899    }
900
901    /// Test a complete resource workflow: list resources -> read resource -> verify result
902    ///
903    /// # Panics
904    /// Panics if the resource is not found or the workflow fails
905    pub async fn test_resource_workflow(&self, uri: &str) -> ReadResourceResult {
906        // List resources first
907        let resources = self.harness.server().list_resources().unwrap();
908        assert!(
909            !resources.resources.is_empty(),
910            "Should have resources available"
911        );
912
913        // Read the resource
914        self.harness.read_resource(uri).await
915    }
916
917    /// Test a complete prompt workflow: list prompts -> get prompt -> verify result
918    ///
919    /// # Panics
920    /// Panics if the prompt is not found or the workflow fails
921    pub async fn test_prompt_workflow(
922        &self,
923        name: &str,
924        arguments: Option<Value>,
925    ) -> GetPromptResult {
926        // List prompts first
927        let prompts = self.harness.server().list_prompts().unwrap();
928        assert!(!prompts.prompts.is_empty(), "Should have prompts available");
929
930        // Get the prompt
931        self.harness.get_prompt(name, arguments).await
932    }
933
934    /// Test error handling workflow
935    ///
936    /// # Panics
937    /// Panics if the error handling test fails
938    pub async fn test_error_handling_workflow(&self) {
939        // Test tool error handling
940        let result = self
941            .harness
942            .call_tool_with_fallback("nonexistent_tool", None)
943            .await;
944        assert!(result.is_error, "Nonexistent tool should fail");
945
946        // Test resource error handling
947        let result = self
948            .harness
949            .read_resource_with_fallback("things://nonexistent")
950            .await;
951        // The fallback method returns error content, so we check that it contains an error message
952        assert!(
953            !result.contents.is_empty(),
954            "Nonexistent resource should return error content"
955        );
956        let Content::Text { text } = &result.contents[0];
957        assert!(
958            text.contains("not found"),
959            "Error message should indicate resource not found"
960        );
961
962        // Test prompt error handling
963        let result = self
964            .harness
965            .get_prompt_with_fallback("nonexistent_prompt", None)
966            .await;
967        assert!(result.is_error, "Nonexistent prompt should fail");
968
969        // Test specific error types - simplified for now
970        // if let Some(error) = result.error {
971        //     assert!(matches!(error, McpError::PromptNotFound { .. }));
972        // }
973    }
974
975    /// Test performance workflow
976    pub async fn test_performance_workflow(&self) {
977        let perf_test = McpPerformanceTest::new();
978
979        // Test tool performance
980        self.harness.call_tool("get_inbox", None).await;
981        perf_test.assert_under_ms(1000);
982
983        // Test resource performance
984        self.harness.read_resource("things://inbox").await;
985        perf_test.assert_under_ms(1000);
986
987        // Test prompt performance
988        self.harness
989            .get_prompt(
990                "task_review",
991                Some(serde_json::json!({"task_title": "Test"})),
992            )
993            .await;
994        perf_test.assert_under_ms(1000);
995    }
996}
997
998impl Default for McpIntegrationTest {
999    fn default() -> Self {
1000        panic!("McpIntegrationTest::default() cannot be used in async context. Use McpIntegrationTest::new().await instead.")
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use serde_json::json;
1008
1009    #[tokio::test]
1010    async fn test_mcp_test_harness_creation() {
1011        let harness = McpTestHarness::new();
1012        assert!(!harness.server().list_tools().unwrap().tools.is_empty());
1013    }
1014
1015    #[tokio::test]
1016    async fn test_mcp_tool_call() {
1017        let harness = McpTestHarness::new();
1018        let result = harness.call_tool("get_inbox", None).await;
1019        assert!(!result.is_error);
1020    }
1021
1022    #[tokio::test]
1023    async fn test_mcp_resource_read() {
1024        let harness = McpTestHarness::new();
1025        let result = harness.read_resource("things://inbox").await;
1026        assert!(!result.contents.is_empty());
1027    }
1028
1029    #[tokio::test]
1030    async fn test_mcp_prompt_get() {
1031        let harness = McpTestHarness::new();
1032        let result = harness
1033            .get_prompt("task_review", Some(json!({"task_title": "Test"})))
1034            .await;
1035        assert!(!result.is_error);
1036    }
1037
1038    #[tokio::test]
1039    async fn test_mcp_tool_json_result() {
1040        let harness = McpTestHarness::new();
1041        let json_result = harness.assert_tool_returns_json("get_inbox", None).await;
1042        assert!(json_result.is_array());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_mcp_mock_database() {
1047        let mut db = MockDatabase::new();
1048        db.add_task(MockTask {
1049            uuid: "test-task".to_string(),
1050            title: "Test Task".to_string(),
1051            status: "incomplete".to_string(),
1052            project: None,
1053            area: None,
1054        });
1055
1056        let task = db.get_task("test-task");
1057        assert!(task.is_some());
1058        assert_eq!(task.unwrap().title, "Test Task");
1059
1060        let completed_tasks = db.get_tasks_by_status("completed");
1061        assert_eq!(completed_tasks.len(), 0);
1062    }
1063
1064    #[tokio::test]
1065    async fn test_mcp_test_utils() {
1066        let request =
1067            McpTestUtils::create_tool_request("test_tool", Some(json!({"param": "value"})));
1068        assert_eq!(request.name, "test_tool");
1069        assert!(request.arguments.is_some());
1070
1071        let request = McpTestUtils::create_resource_request("things://test");
1072        assert_eq!(request.uri, "things://test");
1073
1074        let request =
1075            McpTestUtils::create_prompt_request("test_prompt", Some(json!({"param": "value"})));
1076        assert_eq!(request.name, "test_prompt");
1077        assert!(request.arguments.is_some());
1078    }
1079
1080    #[tokio::test]
1081    async fn test_mcp_performance_test() {
1082        let perf_test = McpPerformanceTest::new();
1083        std::thread::sleep(std::time::Duration::from_millis(10));
1084        let elapsed = perf_test.elapsed();
1085        assert!(elapsed.as_millis() >= 10);
1086
1087        let perf_test = McpPerformanceTest::new();
1088        perf_test.assert_under_ms(1000); // Should pass
1089    }
1090
1091    #[tokio::test]
1092    async fn test_mcp_integration_test() {
1093        let integration_test = McpIntegrationTest::new();
1094
1095        // Test tool workflow
1096        let result = integration_test.test_tool_workflow("get_inbox", None).await;
1097        assert!(!result.is_error);
1098
1099        // Test resource workflow
1100        let result = integration_test
1101            .test_resource_workflow("things://inbox")
1102            .await;
1103        assert!(!result.contents.is_empty());
1104
1105        // Test prompt workflow
1106        let result = integration_test
1107            .test_prompt_workflow("task_review", Some(json!({"task_title": "Test"})))
1108            .await;
1109        assert!(!result.is_error);
1110
1111        // Test error handling workflow
1112        integration_test.test_error_handling_workflow().await;
1113
1114        // Test performance workflow
1115        integration_test.test_performance_workflow().await;
1116    }
1117}