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    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, true);
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        sqlx::query(
385            r"
386            CREATE TABLE IF NOT EXISTS TMTag (
387                uuid TEXT PRIMARY KEY,
388                title TEXT,
389                shortcut TEXT,
390                usedDate REAL,
391                parent TEXT,
392                'index' INTEGER,
393                experimental BLOB
394            )
395            ",
396        )
397        .execute(&pool)
398        .await
399        .unwrap();
400
401        sqlx::query(
402            r"
403            CREATE TABLE IF NOT EXISTS TMTaskTag (
404                tasks TEXT NOT NULL,
405                tags  TEXT NOT NULL,
406                PRIMARY KEY (tasks, tags)
407            )
408            ",
409        )
410        .execute(&pool)
411        .await
412        .unwrap();
413
414        // Insert test data
415        // Use a safe conversion for timestamp to avoid precision loss
416        let timestamp_i64 = chrono::Utc::now().timestamp();
417        let now_timestamp = if timestamp_i64 <= i64::from(i32::MAX) {
418            f64::from(i32::try_from(timestamp_i64).unwrap_or(0))
419        } else {
420            // For very large timestamps, use a reasonable test value
421            1_700_000_000.0 // Represents a date around 2023
422        };
423
424        // Insert test areas
425        let now = chrono::Utc::now().timestamp() as f64;
426
427        sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
428            .bind("550e8400-e29b-41d4-a716-446655440001")
429            .bind("Work")
430            .bind(1) // visible
431            .bind(0) // index
432            .bind(now)
433            .bind(now)
434            .execute(&pool)
435            .await
436            .unwrap();
437
438        sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
439            .bind("550e8400-e29b-41d4-a716-446655440002")
440            .bind("Personal")
441            .bind(1) // visible
442            .bind(1) // index
443            .bind(now)
444            .bind(now)
445            .execute(&pool)
446            .await
447            .unwrap();
448
449        // Insert test projects (as TMTask with type=1)
450        sqlx::query(
451            "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
452        )
453        .bind("550e8400-e29b-41d4-a716-446655440010")
454        .bind("Website Redesign")
455        .bind(1) // type: project
456        .bind(0) // status: active
457        .bind("Complete redesign of company website")
458        .bind(now_timestamp)
459        .bind(now_timestamp)
460        .bind("550e8400-e29b-41d4-a716-446655440001") // work area
461        .bind(0) // not trashed
462        .bind("[\"work\", \"web\"]")
463        .execute(&pool).await.unwrap();
464
465        sqlx::query(
466            "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
467        )
468        .bind("550e8400-e29b-41d4-a716-446655440011")
469        .bind("Learn Rust")
470        .bind(1) // type: project
471        .bind(0) // status: active
472        .bind("Learn the Rust programming language")
473        .bind(now_timestamp)
474        .bind(now_timestamp)
475        .bind("550e8400-e29b-41d4-a716-446655440002")
476        .bind(0) // not trashed
477        .bind("[\"personal\", \"learning\"]")
478        .execute(&pool).await.unwrap();
479
480        // Insert test tasks - one in inbox (no project), one in project
481        sqlx::query(
482            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
483        )
484        .bind("550e8400-e29b-41d4-a716-446655440099")
485        .bind("Inbox Task")
486        .bind(0)
487        .bind(0)
488        .bind("A task in the inbox")
489        .bind::<Option<i64>>(None) // startDate: NULL
490        .bind::<Option<i64>>(None) // deadline: NULL
491        .bind(now_timestamp)
492        .bind(now_timestamp)
493        .bind::<Option<String>>(None) // No project (inbox) - use NULL instead of empty string
494        .bind("550e8400-e29b-41d4-a716-446655440001") // area: work area
495        .bind("") // heading: empty for top-level task
496        .bind(0) // not trashed
497        .bind("[\"inbox\"]")
498        .execute(&pool).await.unwrap();
499
500        sqlx::query(
501            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
502        )
503        .bind("550e8400-e29b-41d4-a716-446655440100")
504        .bind("Research competitors")
505        .bind(0)
506        .bind(0)
507        .bind("Look at competitor websites for inspiration")
508        .bind::<Option<i64>>(None) // startDate: NULL
509        .bind::<Option<i64>>(None) // deadline: NULL
510        .bind(now_timestamp)
511        .bind(now_timestamp)
512        .bind("550e8400-e29b-41d4-a716-446655440010")
513        .bind("550e8400-e29b-41d4-a716-446655440001") // area: work area
514        .bind("") // heading: empty for top-level task
515        .bind(0) // not trashed
516        .bind("[\"research\"]")
517        .execute(&pool).await.unwrap();
518
519        sqlx::query(
520            "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
521        )
522        .bind("550e8400-e29b-41d4-a716-446655440101")
523        .bind("Read Rust book")
524        .bind(0)
525        .bind(0)
526        .bind("Read The Rust Programming Language book")
527        .bind::<Option<i64>>(None) // startDate: NULL
528        .bind::<Option<i64>>(None) // deadline: NULL
529        .bind(now_timestamp)
530        .bind(now_timestamp)
531        .bind("550e8400-e29b-41d4-a716-446655440011")
532        .bind("550e8400-e29b-41d4-a716-446655440002") // area: personal area
533        .bind("") // heading: empty for top-level task
534        .bind(0) // not trashed
535        .bind("[\"reading\"]")
536        .execute(&pool).await.unwrap();
537
538        pool.close().await;
539        ThingsDatabase::new(db_path.as_ref()).await.unwrap()
540    }
541}
542
543impl Default for McpTestHarness {
544    fn default() -> Self {
545        panic!("McpTestHarness::default() cannot be used in async context. Use McpTestHarness::new().await instead.")
546    }
547}
548
549/// Mock database for testing without real database dependencies
550pub struct MockDatabase {
551    pub tasks: Vec<MockTask>,
552    pub projects: Vec<MockProject>,
553    pub areas: Vec<MockArea>,
554}
555
556#[derive(Debug, Clone)]
557pub struct MockTask {
558    pub uuid: String,
559    pub title: String,
560    pub status: String,
561    pub project: Option<String>,
562    pub area: Option<String>,
563}
564
565#[derive(Debug, Clone)]
566pub struct MockProject {
567    pub uuid: String,
568    pub title: String,
569    pub area: Option<String>,
570    pub status: String,
571}
572
573#[derive(Debug, Clone)]
574pub struct MockArea {
575    pub uuid: String,
576    pub title: String,
577    pub visible: bool,
578}
579
580impl MockDatabase {
581    #[must_use]
582    pub fn new() -> Self {
583        Self {
584            tasks: Vec::new(),
585            projects: Vec::new(),
586            areas: Vec::new(),
587        }
588    }
589
590    pub fn add_task(&mut self, task: MockTask) {
591        self.tasks.push(task);
592    }
593
594    pub fn add_project(&mut self, project: MockProject) {
595        self.projects.push(project);
596    }
597
598    pub fn add_area(&mut self, area: MockArea) {
599        self.areas.push(area);
600    }
601
602    #[must_use]
603    pub fn get_task(&self, uuid: &str) -> Option<&MockTask> {
604        self.tasks.iter().find(|t| t.uuid == uuid)
605    }
606
607    #[must_use]
608    pub fn get_project(&self, uuid: &str) -> Option<&MockProject> {
609        self.projects.iter().find(|p| p.uuid == uuid)
610    }
611
612    #[must_use]
613    pub fn get_area(&self, uuid: &str) -> Option<&MockArea> {
614        self.areas.iter().find(|a| a.uuid == uuid)
615    }
616
617    #[must_use]
618    pub fn get_tasks_by_status(&self, status: &str) -> Vec<&MockTask> {
619        self.tasks.iter().filter(|t| t.status == status).collect()
620    }
621
622    #[must_use]
623    pub fn get_tasks_by_project(&self, project: &str) -> Vec<&MockTask> {
624        self.tasks
625            .iter()
626            .filter(|t| t.project.as_ref() == Some(&project.to_string()))
627            .collect()
628    }
629
630    #[must_use]
631    pub fn get_tasks_by_area(&self, area: &str) -> Vec<&MockTask> {
632        self.tasks
633            .iter()
634            .filter(|t| t.area.as_ref() == Some(&area.to_string()))
635            .collect()
636    }
637}
638
639impl Default for MockDatabase {
640    fn default() -> Self {
641        Self::new()
642    }
643}
644
645/// Test utilities for common MCP operations
646pub struct McpTestUtils;
647
648impl McpTestUtils {
649    /// Create a test tool request
650    #[must_use]
651    pub fn create_tool_request(name: &str, arguments: Option<Value>) -> CallToolRequest {
652        CallToolRequest {
653            name: name.to_string(),
654            arguments,
655        }
656    }
657
658    /// Create a test resource request
659    #[must_use]
660    pub fn create_resource_request(uri: &str) -> ReadResourceRequest {
661        ReadResourceRequest {
662            uri: uri.to_string(),
663        }
664    }
665
666    /// Create a test prompt request
667    #[must_use]
668    pub fn create_prompt_request(name: &str, arguments: Option<Value>) -> GetPromptRequest {
669        GetPromptRequest {
670            name: name.to_string(),
671            arguments,
672        }
673    }
674
675    /// Assert that a tool result contains expected content
676    ///
677    /// # Panics
678    /// Panics if the tool result is an error or doesn't contain the expected content
679    pub fn assert_tool_result_contains(result: &CallToolResult, expected_content: &str) {
680        assert!(!result.is_error, "Tool call should succeed");
681        assert!(
682            !result.content.is_empty(),
683            "Tool call should return content"
684        );
685
686        match &result.content[0] {
687            Content::Text { text } => {
688                assert!(
689                    text.contains(expected_content),
690                    "Tool result should contain: {expected_content}"
691                );
692            }
693        }
694    }
695
696    /// Assert that a resource result contains expected content
697    ///
698    /// # Panics
699    /// Panics if the resource result is empty or doesn't contain the expected content
700    pub fn assert_resource_result_contains(result: &ReadResourceResult, expected_content: &str) {
701        assert!(!result.contents.is_empty(), "Resource read should succeed");
702
703        match &result.contents[0] {
704            Content::Text { text } => {
705                assert!(
706                    text.contains(expected_content),
707                    "Resource result should contain: {expected_content}"
708                );
709            }
710        }
711    }
712
713    /// Assert that a prompt result contains expected content
714    ///
715    /// # Panics
716    /// Panics if the prompt result is an error or doesn't contain the expected content
717    pub fn assert_prompt_result_contains(result: &GetPromptResult, expected_content: &str) {
718        assert!(!result.is_error, "Prompt should succeed");
719        assert!(!result.content.is_empty(), "Prompt should return content");
720
721        match &result.content[0] {
722            Content::Text { text } => {
723                assert!(
724                    text.contains(expected_content),
725                    "Prompt result should contain: {expected_content}"
726                );
727            }
728        }
729    }
730
731    /// Assert that a tool result is valid JSON
732    ///
733    /// # Panics
734    /// Panics if the tool result is an error or contains invalid JSON
735    #[must_use]
736    pub fn assert_tool_result_is_json(result: &CallToolResult) -> Value {
737        assert!(!result.is_error, "Tool call should succeed");
738        assert!(
739            !result.content.is_empty(),
740            "Tool call should return content"
741        );
742
743        match &result.content[0] {
744            Content::Text { text } => {
745                serde_json::from_str(text).expect("Tool result should be valid JSON")
746            }
747        }
748    }
749
750    /// Assert that a resource result is valid JSON
751    ///
752    /// # Panics
753    /// Panics if the resource result is empty or contains invalid JSON
754    #[must_use]
755    pub fn assert_resource_result_is_json(result: &ReadResourceResult) -> Value {
756        assert!(!result.contents.is_empty(), "Resource read should succeed");
757
758        match &result.contents[0] {
759            Content::Text { text } => {
760                serde_json::from_str(text).expect("Resource result should be valid JSON")
761            }
762        }
763    }
764
765    /// Create test data for various scenarios
766    #[must_use]
767    pub fn create_test_data() -> MockDatabase {
768        Self::create_test_data_with_scenarios()
769    }
770
771    /// Create test data with specific scenarios
772    #[must_use]
773    pub fn create_test_data_with_scenarios() -> MockDatabase {
774        let mut db = MockDatabase::new();
775
776        // Add test areas
777        db.add_area(MockArea {
778            uuid: "area-1".to_string(),
779            title: "Work".to_string(),
780            visible: true,
781        });
782
783        db.add_area(MockArea {
784            uuid: "area-2".to_string(),
785            title: "Personal".to_string(),
786            visible: true,
787        });
788
789        // Add test projects
790        db.add_project(MockProject {
791            uuid: "project-1".to_string(),
792            title: "Website Redesign".to_string(),
793            area: Some("area-1".to_string()),
794            status: "incomplete".to_string(),
795        });
796
797        db.add_project(MockProject {
798            uuid: "project-2".to_string(),
799            title: "Another Project".to_string(),
800            area: Some("area-2".to_string()),
801            status: "incomplete".to_string(),
802        });
803
804        // Add test areas
805        db.add_area(MockArea {
806            uuid: "area-3".to_string(),
807            title: "Health".to_string(),
808            visible: true,
809        });
810
811        // Add test tasks
812        db.add_task(MockTask {
813            uuid: "task-1".to_string(),
814            title: "Research competitors".to_string(),
815            status: "incomplete".to_string(),
816            project: Some("project-1".to_string()),
817            area: None,
818        });
819
820        db.add_task(MockTask {
821            uuid: "task-urgent".to_string(),
822            title: "Urgent Task".to_string(),
823            status: "incomplete".to_string(),
824            project: Some("project-1".to_string()),
825            area: None,
826        });
827
828        db.add_task(MockTask {
829            uuid: "task-completed".to_string(),
830            title: "Completed Task".to_string(),
831            status: "completed".to_string(),
832            project: Some("project-2".to_string()),
833            area: None,
834        });
835
836        db.add_task(MockTask {
837            uuid: "task-2".to_string(),
838            title: "Read Rust book".to_string(),
839            status: "completed".to_string(),
840            project: Some("project-2".to_string()),
841            area: None,
842        });
843
844        db
845    }
846}
847
848/// Performance testing utilities for MCP operations
849pub struct McpPerformanceTest {
850    start_time: std::time::Instant,
851}
852
853impl McpPerformanceTest {
854    #[must_use]
855    pub fn new() -> Self {
856        Self {
857            start_time: std::time::Instant::now(),
858        }
859    }
860
861    #[must_use]
862    pub fn elapsed(&self) -> std::time::Duration {
863        self.start_time.elapsed()
864    }
865
866    /// Assert that the elapsed time is under the threshold
867    ///
868    /// # Panics
869    /// Panics if the operation took longer than the specified threshold
870    pub fn assert_under_threshold(&self, threshold: std::time::Duration) {
871        let elapsed = self.elapsed();
872        assert!(
873            elapsed < threshold,
874            "Operation took {elapsed:?}, which exceeds threshold of {threshold:?}"
875        );
876    }
877
878    pub fn assert_under_ms(&self, threshold_ms: u64) {
879        self.assert_under_threshold(std::time::Duration::from_millis(threshold_ms));
880    }
881}
882
883impl Default for McpPerformanceTest {
884    fn default() -> Self {
885        Self::new()
886    }
887}
888
889/// Integration test utilities for full MCP workflows
890pub struct McpIntegrationTest {
891    harness: McpTestHarness,
892}
893
894impl McpIntegrationTest {
895    #[must_use]
896    pub fn new() -> Self {
897        Self {
898            harness: McpTestHarness::new(),
899        }
900    }
901
902    #[must_use]
903    pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
904        Self {
905            harness: McpTestHarness::with_middleware_config(middleware_config),
906        }
907    }
908
909    #[must_use]
910    pub fn harness(&self) -> &McpTestHarness {
911        &self.harness
912    }
913
914    /// Test a complete workflow: list tools -> call tool -> verify result
915    ///
916    /// # Panics
917    /// Panics if the tool is not found or the workflow fails
918    pub async fn test_tool_workflow(
919        &self,
920        tool_name: &str,
921        arguments: Option<Value>,
922    ) -> CallToolResult {
923        // List tools first
924        let tools = self.harness.server().list_tools().unwrap();
925        assert!(!tools.tools.is_empty(), "Should have tools available");
926
927        // Call the tool
928        self.harness.call_tool(tool_name, arguments).await
929    }
930
931    /// Test a complete resource workflow: list resources -> read resource -> verify result
932    ///
933    /// # Panics
934    /// Panics if the resource is not found or the workflow fails
935    pub async fn test_resource_workflow(&self, uri: &str) -> ReadResourceResult {
936        // List resources first
937        let resources = self.harness.server().list_resources().unwrap();
938        assert!(
939            !resources.resources.is_empty(),
940            "Should have resources available"
941        );
942
943        // Read the resource
944        self.harness.read_resource(uri).await
945    }
946
947    /// Test a complete prompt workflow: list prompts -> get prompt -> verify result
948    ///
949    /// # Panics
950    /// Panics if the prompt is not found or the workflow fails
951    pub async fn test_prompt_workflow(
952        &self,
953        name: &str,
954        arguments: Option<Value>,
955    ) -> GetPromptResult {
956        // List prompts first
957        let prompts = self.harness.server().list_prompts().unwrap();
958        assert!(!prompts.prompts.is_empty(), "Should have prompts available");
959
960        // Get the prompt
961        self.harness.get_prompt(name, arguments).await
962    }
963
964    /// Test error handling workflow
965    ///
966    /// # Panics
967    /// Panics if the error handling test fails
968    pub async fn test_error_handling_workflow(&self) {
969        // Test tool error handling
970        let result = self
971            .harness
972            .call_tool_with_fallback("nonexistent_tool", None)
973            .await;
974        assert!(result.is_error, "Nonexistent tool should fail");
975
976        // Test resource error handling
977        let result = self
978            .harness
979            .read_resource_with_fallback("things://nonexistent")
980            .await;
981        // The fallback method returns error content, so we check that it contains an error message
982        assert!(
983            !result.contents.is_empty(),
984            "Nonexistent resource should return error content"
985        );
986        let Content::Text { text } = &result.contents[0];
987        assert!(
988            text.contains("not found"),
989            "Error message should indicate resource not found"
990        );
991
992        // Test prompt error handling
993        let result = self
994            .harness
995            .get_prompt_with_fallback("nonexistent_prompt", None)
996            .await;
997        assert!(result.is_error, "Nonexistent prompt should fail");
998
999        // Test specific error types - simplified for now
1000        // if let Some(error) = result.error {
1001        //     assert!(matches!(error, McpError::PromptNotFound { .. }));
1002        // }
1003    }
1004
1005    /// Test performance workflow
1006    pub async fn test_performance_workflow(&self) {
1007        let perf_test = McpPerformanceTest::new();
1008
1009        // Test tool performance
1010        self.harness.call_tool("get_inbox", None).await;
1011        perf_test.assert_under_ms(1000);
1012
1013        // Test resource performance
1014        self.harness.read_resource("things://inbox").await;
1015        perf_test.assert_under_ms(1000);
1016
1017        // Test prompt performance
1018        self.harness
1019            .get_prompt(
1020                "task_review",
1021                Some(serde_json::json!({"task_title": "Test"})),
1022            )
1023            .await;
1024        perf_test.assert_under_ms(1000);
1025    }
1026}
1027
1028impl Default for McpIntegrationTest {
1029    fn default() -> Self {
1030        panic!("McpIntegrationTest::default() cannot be used in async context. Use McpIntegrationTest::new().await instead.")
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use serde_json::json;
1038
1039    #[tokio::test]
1040    async fn test_mcp_test_harness_creation() {
1041        let harness = McpTestHarness::new();
1042        assert!(!harness.server().list_tools().unwrap().tools.is_empty());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_mcp_tool_call() {
1047        let harness = McpTestHarness::new();
1048        let result = harness.call_tool("get_inbox", None).await;
1049        assert!(!result.is_error);
1050    }
1051
1052    #[tokio::test]
1053    async fn test_mcp_resource_read() {
1054        let harness = McpTestHarness::new();
1055        let result = harness.read_resource("things://inbox").await;
1056        assert!(!result.contents.is_empty());
1057    }
1058
1059    #[tokio::test]
1060    async fn test_mcp_prompt_get() {
1061        let harness = McpTestHarness::new();
1062        let result = harness
1063            .get_prompt("task_review", Some(json!({"task_title": "Test"})))
1064            .await;
1065        assert!(!result.is_error);
1066    }
1067
1068    #[tokio::test]
1069    async fn test_mcp_tool_json_result() {
1070        let harness = McpTestHarness::new();
1071        let json_result = harness.assert_tool_returns_json("get_inbox", None).await;
1072        assert!(json_result.is_array());
1073    }
1074
1075    #[tokio::test]
1076    async fn test_mcp_mock_database() {
1077        let mut db = MockDatabase::new();
1078        db.add_task(MockTask {
1079            uuid: "test-task".to_string(),
1080            title: "Test Task".to_string(),
1081            status: "incomplete".to_string(),
1082            project: None,
1083            area: None,
1084        });
1085
1086        let task = db.get_task("test-task");
1087        assert!(task.is_some());
1088        assert_eq!(task.unwrap().title, "Test Task");
1089
1090        let completed_tasks = db.get_tasks_by_status("completed");
1091        assert_eq!(completed_tasks.len(), 0);
1092    }
1093
1094    #[tokio::test]
1095    async fn test_mcp_test_utils() {
1096        let request =
1097            McpTestUtils::create_tool_request("test_tool", Some(json!({"param": "value"})));
1098        assert_eq!(request.name, "test_tool");
1099        assert!(request.arguments.is_some());
1100
1101        let request = McpTestUtils::create_resource_request("things://test");
1102        assert_eq!(request.uri, "things://test");
1103
1104        let request =
1105            McpTestUtils::create_prompt_request("test_prompt", Some(json!({"param": "value"})));
1106        assert_eq!(request.name, "test_prompt");
1107        assert!(request.arguments.is_some());
1108    }
1109
1110    #[tokio::test]
1111    async fn test_mcp_performance_test() {
1112        let perf_test = McpPerformanceTest::new();
1113        std::thread::sleep(std::time::Duration::from_millis(10));
1114        let elapsed = perf_test.elapsed();
1115        assert!(elapsed.as_millis() >= 10);
1116
1117        let perf_test = McpPerformanceTest::new();
1118        perf_test.assert_under_ms(1000); // Should pass
1119    }
1120
1121    #[tokio::test]
1122    async fn test_mcp_integration_test() {
1123        let integration_test = McpIntegrationTest::new();
1124
1125        // Test tool workflow
1126        let result = integration_test.test_tool_workflow("get_inbox", None).await;
1127        assert!(!result.is_error);
1128
1129        // Test resource workflow
1130        let result = integration_test
1131            .test_resource_workflow("things://inbox")
1132            .await;
1133        assert!(!result.contents.is_empty());
1134
1135        // Test prompt workflow
1136        let result = integration_test
1137            .test_prompt_workflow("task_review", Some(json!({"task_title": "Test"})))
1138            .await;
1139        assert!(!result.is_error);
1140
1141        // Test error handling workflow
1142        integration_test.test_error_handling_workflow().await;
1143
1144        // Test performance workflow
1145        integration_test.test_performance_workflow().await;
1146    }
1147}