Skip to main content

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