1use 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};
11pub struct McpTestHarness {
15 server: ThingsMcpServer,
16 temp_file: NamedTempFile,
17}
18
19impl McpTestHarness {
20 #[must_use]
25 pub fn new() -> Self {
26 Self::new_with_config(crate::mcp::MiddlewareConfig::default())
27 }
28
29 #[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 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 #[must_use]
60 pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
61 Self::new_with_config(middleware_config)
62 }
63
64 #[must_use]
66 pub fn server(&self) -> &ThingsMcpServer {
67 &self.server
68 }
69
70 #[must_use]
72 pub fn db_path(&self) -> &Path {
73 self.temp_file.path()
74 }
75
76 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 1_700_000_000.0 };
424
425 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) .bind(0) .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) .bind(1) .bind(now)
445 .bind(now)
446 .execute(&pool)
447 .await
448 .unwrap();
449
450 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) .bind(0) .bind("Complete redesign of company website")
459 .bind(now_timestamp)
460 .bind(now_timestamp)
461 .bind("550e8400-e29b-41d4-a716-446655440001") .bind(0) .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) .bind(0) .bind("Learn the Rust programming language")
474 .bind(now_timestamp)
475 .bind(now_timestamp)
476 .bind("550e8400-e29b-41d4-a716-446655440002")
477 .bind(0) .bind("[\"personal\", \"learning\"]")
479 .execute(&pool).await.unwrap();
480
481 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) .bind::<Option<i64>>(None) .bind(now_timestamp)
493 .bind(now_timestamp)
494 .bind::<Option<String>>(None) .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .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) .bind::<Option<i64>>(None) .bind(now_timestamp)
512 .bind(now_timestamp)
513 .bind("550e8400-e29b-41d4-a716-446655440010")
514 .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .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) .bind::<Option<i64>>(None) .bind(now_timestamp)
531 .bind(now_timestamp)
532 .bind("550e8400-e29b-41d4-a716-446655440011")
533 .bind("550e8400-e29b-41d4-a716-446655440002") .bind("") .bind(0) .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
550pub 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
646pub struct McpTestUtils;
648
649impl McpTestUtils {
650 #[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 #[must_use]
661 pub fn create_resource_request(uri: &str) -> ReadResourceRequest {
662 ReadResourceRequest {
663 uri: uri.to_string(),
664 }
665 }
666
667 #[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 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 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 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 #[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 #[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 #[must_use]
768 pub fn create_test_data() -> MockDatabase {
769 Self::create_test_data_with_scenarios()
770 }
771
772 #[must_use]
774 pub fn create_test_data_with_scenarios() -> MockDatabase {
775 let mut db = MockDatabase::new();
776
777 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 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 db.add_area(MockArea {
807 uuid: "area-3".to_string(),
808 title: "Health".to_string(),
809 visible: true,
810 });
811
812 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
849pub 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 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
890pub 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 pub async fn test_tool_workflow(
920 &self,
921 tool_name: &str,
922 arguments: Option<Value>,
923 ) -> CallToolResult {
924 let tools = self.harness.server().list_tools().unwrap();
926 assert!(!tools.tools.is_empty(), "Should have tools available");
927
928 self.harness.call_tool(tool_name, arguments).await
930 }
931
932 pub async fn test_resource_workflow(&self, uri: &str) -> ReadResourceResult {
937 let resources = self.harness.server().list_resources().unwrap();
939 assert!(
940 !resources.resources.is_empty(),
941 "Should have resources available"
942 );
943
944 self.harness.read_resource(uri).await
946 }
947
948 pub async fn test_prompt_workflow(
953 &self,
954 name: &str,
955 arguments: Option<Value>,
956 ) -> GetPromptResult {
957 let prompts = self.harness.server().list_prompts().unwrap();
959 assert!(!prompts.prompts.is_empty(), "Should have prompts available");
960
961 self.harness.get_prompt(name, arguments).await
963 }
964
965 pub async fn test_error_handling_workflow(&self) {
970 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 let result = self
979 .harness
980 .read_resource_with_fallback("things://nonexistent")
981 .await;
982 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 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 }
1005
1006 pub async fn test_performance_workflow(&self) {
1008 let perf_test = McpPerformanceTest::new();
1009
1010 self.harness.call_tool("get_inbox", None).await;
1012 perf_test.assert_under_ms(1000);
1013
1014 self.harness.read_resource("things://inbox").await;
1016 perf_test.assert_under_ms(1000);
1017
1018 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); }
1121
1122 #[tokio::test]
1123 async fn test_mcp_integration_test() {
1124 let integration_test = McpIntegrationTest::new();
1125
1126 let result = integration_test.test_tool_workflow("get_inbox", None).await;
1128 assert!(!result.is_error);
1129
1130 let result = integration_test
1132 .test_resource_workflow("things://inbox")
1133 .await;
1134 assert!(!result.contents.is_empty());
1135
1136 let result = integration_test
1138 .test_prompt_workflow("task_review", Some(json!({"task_title": "Test"})))
1139 .await;
1140 assert!(!result.is_error);
1141
1142 integration_test.test_error_handling_workflow().await;
1144
1145 integration_test.test_performance_workflow().await;
1147 }
1148}