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 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 let db = std::thread::spawn(move || {
41 tokio::runtime::Runtime::new()
42 .unwrap()
43 .block_on(async { Self::create_test_database(&db_path_clone).await })
44 })
45 .join()
46 .unwrap();
47
48 let config = ThingsConfig::new(&db_path, false);
49 let server = ThingsMcpServer::with_middleware_config(db, config, middleware_config);
50
51 Self { server, temp_file }
52 }
53
54 #[must_use]
59 pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
60 Self::new_with_config(middleware_config)
61 }
62
63 #[must_use]
65 pub fn server(&self) -> &ThingsMcpServer {
66 &self.server
67 }
68
69 #[must_use]
71 pub fn db_path(&self) -> &Path {
72 self.temp_file.path()
73 }
74
75 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 let timestamp_i64 = chrono::Utc::now().timestamp();
387 let now_timestamp = if timestamp_i64 <= i64::from(i32::MAX) {
388 f64::from(i32::try_from(timestamp_i64).unwrap_or(0))
389 } else {
390 1_700_000_000.0 };
393
394 let now = chrono::Utc::now().timestamp() as f64;
396
397 sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
398 .bind("550e8400-e29b-41d4-a716-446655440001")
399 .bind("Work")
400 .bind(1) .bind(0) .bind(now)
403 .bind(now)
404 .execute(&pool)
405 .await
406 .unwrap();
407
408 sqlx::query("INSERT INTO TMArea (uuid, title, visible, 'index', creationDate, userModificationDate) VALUES (?, ?, ?, ?, ?, ?)")
409 .bind("550e8400-e29b-41d4-a716-446655440002")
410 .bind("Personal")
411 .bind(1) .bind(1) .bind(now)
414 .bind(now)
415 .execute(&pool)
416 .await
417 .unwrap();
418
419 sqlx::query(
421 "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
422 )
423 .bind("550e8400-e29b-41d4-a716-446655440010")
424 .bind("Website Redesign")
425 .bind(1) .bind(0) .bind("Complete redesign of company website")
428 .bind(now_timestamp)
429 .bind(now_timestamp)
430 .bind("550e8400-e29b-41d4-a716-446655440001") .bind(0) .bind("[\"work\", \"web\"]")
433 .execute(&pool).await.unwrap();
434
435 sqlx::query(
436 "INSERT INTO TMTask (uuid, title, type, status, notes, creationDate, userModificationDate, area, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
437 )
438 .bind("550e8400-e29b-41d4-a716-446655440011")
439 .bind("Learn Rust")
440 .bind(1) .bind(0) .bind("Learn the Rust programming language")
443 .bind(now_timestamp)
444 .bind(now_timestamp)
445 .bind("550e8400-e29b-41d4-a716-446655440002")
446 .bind(0) .bind("[\"personal\", \"learning\"]")
448 .execute(&pool).await.unwrap();
449
450 sqlx::query(
452 "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
453 )
454 .bind("550e8400-e29b-41d4-a716-446655440099")
455 .bind("Inbox Task")
456 .bind(0)
457 .bind(0)
458 .bind("A task in the inbox")
459 .bind::<Option<i64>>(None) .bind::<Option<i64>>(None) .bind(now_timestamp)
462 .bind(now_timestamp)
463 .bind::<Option<String>>(None) .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .bind("[\"inbox\"]")
468 .execute(&pool).await.unwrap();
469
470 sqlx::query(
471 "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
472 )
473 .bind("550e8400-e29b-41d4-a716-446655440100")
474 .bind("Research competitors")
475 .bind(0)
476 .bind(0)
477 .bind("Look at competitor websites for inspiration")
478 .bind::<Option<i64>>(None) .bind::<Option<i64>>(None) .bind(now_timestamp)
481 .bind(now_timestamp)
482 .bind("550e8400-e29b-41d4-a716-446655440010")
483 .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .bind("[\"research\"]")
487 .execute(&pool).await.unwrap();
488
489 sqlx::query(
490 "INSERT INTO TMTask (uuid, title, type, status, notes, startDate, deadline, creationDate, userModificationDate, project, area, heading, trashed, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
491 )
492 .bind("550e8400-e29b-41d4-a716-446655440101")
493 .bind("Read Rust book")
494 .bind(0)
495 .bind(0)
496 .bind("Read The Rust Programming Language book")
497 .bind::<Option<i64>>(None) .bind::<Option<i64>>(None) .bind(now_timestamp)
500 .bind(now_timestamp)
501 .bind("550e8400-e29b-41d4-a716-446655440011")
502 .bind("550e8400-e29b-41d4-a716-446655440002") .bind("") .bind(0) .bind("[\"reading\"]")
506 .execute(&pool).await.unwrap();
507
508 pool.close().await;
509 ThingsDatabase::new(db_path.as_ref()).await.unwrap()
510 }
511}
512
513impl Default for McpTestHarness {
514 fn default() -> Self {
515 panic!("McpTestHarness::default() cannot be used in async context. Use McpTestHarness::new().await instead.")
516 }
517}
518
519pub struct MockDatabase {
521 pub tasks: Vec<MockTask>,
522 pub projects: Vec<MockProject>,
523 pub areas: Vec<MockArea>,
524}
525
526#[derive(Debug, Clone)]
527pub struct MockTask {
528 pub uuid: String,
529 pub title: String,
530 pub status: String,
531 pub project: Option<String>,
532 pub area: Option<String>,
533}
534
535#[derive(Debug, Clone)]
536pub struct MockProject {
537 pub uuid: String,
538 pub title: String,
539 pub area: Option<String>,
540 pub status: String,
541}
542
543#[derive(Debug, Clone)]
544pub struct MockArea {
545 pub uuid: String,
546 pub title: String,
547 pub visible: bool,
548}
549
550impl MockDatabase {
551 #[must_use]
552 pub fn new() -> Self {
553 Self {
554 tasks: Vec::new(),
555 projects: Vec::new(),
556 areas: Vec::new(),
557 }
558 }
559
560 pub fn add_task(&mut self, task: MockTask) {
561 self.tasks.push(task);
562 }
563
564 pub fn add_project(&mut self, project: MockProject) {
565 self.projects.push(project);
566 }
567
568 pub fn add_area(&mut self, area: MockArea) {
569 self.areas.push(area);
570 }
571
572 #[must_use]
573 pub fn get_task(&self, uuid: &str) -> Option<&MockTask> {
574 self.tasks.iter().find(|t| t.uuid == uuid)
575 }
576
577 #[must_use]
578 pub fn get_project(&self, uuid: &str) -> Option<&MockProject> {
579 self.projects.iter().find(|p| p.uuid == uuid)
580 }
581
582 #[must_use]
583 pub fn get_area(&self, uuid: &str) -> Option<&MockArea> {
584 self.areas.iter().find(|a| a.uuid == uuid)
585 }
586
587 #[must_use]
588 pub fn get_tasks_by_status(&self, status: &str) -> Vec<&MockTask> {
589 self.tasks.iter().filter(|t| t.status == status).collect()
590 }
591
592 #[must_use]
593 pub fn get_tasks_by_project(&self, project: &str) -> Vec<&MockTask> {
594 self.tasks
595 .iter()
596 .filter(|t| t.project.as_ref() == Some(&project.to_string()))
597 .collect()
598 }
599
600 #[must_use]
601 pub fn get_tasks_by_area(&self, area: &str) -> Vec<&MockTask> {
602 self.tasks
603 .iter()
604 .filter(|t| t.area.as_ref() == Some(&area.to_string()))
605 .collect()
606 }
607}
608
609impl Default for MockDatabase {
610 fn default() -> Self {
611 Self::new()
612 }
613}
614
615pub struct McpTestUtils;
617
618impl McpTestUtils {
619 #[must_use]
621 pub fn create_tool_request(name: &str, arguments: Option<Value>) -> CallToolRequest {
622 CallToolRequest {
623 name: name.to_string(),
624 arguments,
625 }
626 }
627
628 #[must_use]
630 pub fn create_resource_request(uri: &str) -> ReadResourceRequest {
631 ReadResourceRequest {
632 uri: uri.to_string(),
633 }
634 }
635
636 #[must_use]
638 pub fn create_prompt_request(name: &str, arguments: Option<Value>) -> GetPromptRequest {
639 GetPromptRequest {
640 name: name.to_string(),
641 arguments,
642 }
643 }
644
645 pub fn assert_tool_result_contains(result: &CallToolResult, expected_content: &str) {
650 assert!(!result.is_error, "Tool call should succeed");
651 assert!(
652 !result.content.is_empty(),
653 "Tool call should return content"
654 );
655
656 match &result.content[0] {
657 Content::Text { text } => {
658 assert!(
659 text.contains(expected_content),
660 "Tool result should contain: {expected_content}"
661 );
662 }
663 }
664 }
665
666 pub fn assert_resource_result_contains(result: &ReadResourceResult, expected_content: &str) {
671 assert!(!result.contents.is_empty(), "Resource read should succeed");
672
673 match &result.contents[0] {
674 Content::Text { text } => {
675 assert!(
676 text.contains(expected_content),
677 "Resource result should contain: {expected_content}"
678 );
679 }
680 }
681 }
682
683 pub fn assert_prompt_result_contains(result: &GetPromptResult, expected_content: &str) {
688 assert!(!result.is_error, "Prompt should succeed");
689 assert!(!result.content.is_empty(), "Prompt should return content");
690
691 match &result.content[0] {
692 Content::Text { text } => {
693 assert!(
694 text.contains(expected_content),
695 "Prompt result should contain: {expected_content}"
696 );
697 }
698 }
699 }
700
701 #[must_use]
706 pub fn assert_tool_result_is_json(result: &CallToolResult) -> Value {
707 assert!(!result.is_error, "Tool call should succeed");
708 assert!(
709 !result.content.is_empty(),
710 "Tool call should return content"
711 );
712
713 match &result.content[0] {
714 Content::Text { text } => {
715 serde_json::from_str(text).expect("Tool result should be valid JSON")
716 }
717 }
718 }
719
720 #[must_use]
725 pub fn assert_resource_result_is_json(result: &ReadResourceResult) -> Value {
726 assert!(!result.contents.is_empty(), "Resource read should succeed");
727
728 match &result.contents[0] {
729 Content::Text { text } => {
730 serde_json::from_str(text).expect("Resource result should be valid JSON")
731 }
732 }
733 }
734
735 #[must_use]
737 pub fn create_test_data() -> MockDatabase {
738 Self::create_test_data_with_scenarios()
739 }
740
741 #[must_use]
743 pub fn create_test_data_with_scenarios() -> MockDatabase {
744 let mut db = MockDatabase::new();
745
746 db.add_area(MockArea {
748 uuid: "area-1".to_string(),
749 title: "Work".to_string(),
750 visible: true,
751 });
752
753 db.add_area(MockArea {
754 uuid: "area-2".to_string(),
755 title: "Personal".to_string(),
756 visible: true,
757 });
758
759 db.add_project(MockProject {
761 uuid: "project-1".to_string(),
762 title: "Website Redesign".to_string(),
763 area: Some("area-1".to_string()),
764 status: "incomplete".to_string(),
765 });
766
767 db.add_project(MockProject {
768 uuid: "project-2".to_string(),
769 title: "Another Project".to_string(),
770 area: Some("area-2".to_string()),
771 status: "incomplete".to_string(),
772 });
773
774 db.add_area(MockArea {
776 uuid: "area-3".to_string(),
777 title: "Health".to_string(),
778 visible: true,
779 });
780
781 db.add_task(MockTask {
783 uuid: "task-1".to_string(),
784 title: "Research competitors".to_string(),
785 status: "incomplete".to_string(),
786 project: Some("project-1".to_string()),
787 area: None,
788 });
789
790 db.add_task(MockTask {
791 uuid: "task-urgent".to_string(),
792 title: "Urgent Task".to_string(),
793 status: "incomplete".to_string(),
794 project: Some("project-1".to_string()),
795 area: None,
796 });
797
798 db.add_task(MockTask {
799 uuid: "task-completed".to_string(),
800 title: "Completed Task".to_string(),
801 status: "completed".to_string(),
802 project: Some("project-2".to_string()),
803 area: None,
804 });
805
806 db.add_task(MockTask {
807 uuid: "task-2".to_string(),
808 title: "Read Rust book".to_string(),
809 status: "completed".to_string(),
810 project: Some("project-2".to_string()),
811 area: None,
812 });
813
814 db
815 }
816}
817
818pub struct McpPerformanceTest {
820 start_time: std::time::Instant,
821}
822
823impl McpPerformanceTest {
824 #[must_use]
825 pub fn new() -> Self {
826 Self {
827 start_time: std::time::Instant::now(),
828 }
829 }
830
831 #[must_use]
832 pub fn elapsed(&self) -> std::time::Duration {
833 self.start_time.elapsed()
834 }
835
836 pub fn assert_under_threshold(&self, threshold: std::time::Duration) {
841 let elapsed = self.elapsed();
842 assert!(
843 elapsed < threshold,
844 "Operation took {elapsed:?}, which exceeds threshold of {threshold:?}"
845 );
846 }
847
848 pub fn assert_under_ms(&self, threshold_ms: u64) {
849 self.assert_under_threshold(std::time::Duration::from_millis(threshold_ms));
850 }
851}
852
853impl Default for McpPerformanceTest {
854 fn default() -> Self {
855 Self::new()
856 }
857}
858
859pub struct McpIntegrationTest {
861 harness: McpTestHarness,
862}
863
864impl McpIntegrationTest {
865 #[must_use]
866 pub fn new() -> Self {
867 Self {
868 harness: McpTestHarness::new(),
869 }
870 }
871
872 #[must_use]
873 pub fn with_middleware_config(middleware_config: crate::mcp::MiddlewareConfig) -> Self {
874 Self {
875 harness: McpTestHarness::with_middleware_config(middleware_config),
876 }
877 }
878
879 #[must_use]
880 pub fn harness(&self) -> &McpTestHarness {
881 &self.harness
882 }
883
884 pub async fn test_tool_workflow(
889 &self,
890 tool_name: &str,
891 arguments: Option<Value>,
892 ) -> CallToolResult {
893 let tools = self.harness.server().list_tools().unwrap();
895 assert!(!tools.tools.is_empty(), "Should have tools available");
896
897 self.harness.call_tool(tool_name, arguments).await
899 }
900
901 pub async fn test_resource_workflow(&self, uri: &str) -> ReadResourceResult {
906 let resources = self.harness.server().list_resources().unwrap();
908 assert!(
909 !resources.resources.is_empty(),
910 "Should have resources available"
911 );
912
913 self.harness.read_resource(uri).await
915 }
916
917 pub async fn test_prompt_workflow(
922 &self,
923 name: &str,
924 arguments: Option<Value>,
925 ) -> GetPromptResult {
926 let prompts = self.harness.server().list_prompts().unwrap();
928 assert!(!prompts.prompts.is_empty(), "Should have prompts available");
929
930 self.harness.get_prompt(name, arguments).await
932 }
933
934 pub async fn test_error_handling_workflow(&self) {
939 let result = self
941 .harness
942 .call_tool_with_fallback("nonexistent_tool", None)
943 .await;
944 assert!(result.is_error, "Nonexistent tool should fail");
945
946 let result = self
948 .harness
949 .read_resource_with_fallback("things://nonexistent")
950 .await;
951 assert!(
953 !result.contents.is_empty(),
954 "Nonexistent resource should return error content"
955 );
956 let Content::Text { text } = &result.contents[0];
957 assert!(
958 text.contains("not found"),
959 "Error message should indicate resource not found"
960 );
961
962 let result = self
964 .harness
965 .get_prompt_with_fallback("nonexistent_prompt", None)
966 .await;
967 assert!(result.is_error, "Nonexistent prompt should fail");
968
969 }
974
975 pub async fn test_performance_workflow(&self) {
977 let perf_test = McpPerformanceTest::new();
978
979 self.harness.call_tool("get_inbox", None).await;
981 perf_test.assert_under_ms(1000);
982
983 self.harness.read_resource("things://inbox").await;
985 perf_test.assert_under_ms(1000);
986
987 self.harness
989 .get_prompt(
990 "task_review",
991 Some(serde_json::json!({"task_title": "Test"})),
992 )
993 .await;
994 perf_test.assert_under_ms(1000);
995 }
996}
997
998impl Default for McpIntegrationTest {
999 fn default() -> Self {
1000 panic!("McpIntegrationTest::default() cannot be used in async context. Use McpIntegrationTest::new().await instead.")
1001 }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use serde_json::json;
1008
1009 #[tokio::test]
1010 async fn test_mcp_test_harness_creation() {
1011 let harness = McpTestHarness::new();
1012 assert!(!harness.server().list_tools().unwrap().tools.is_empty());
1013 }
1014
1015 #[tokio::test]
1016 async fn test_mcp_tool_call() {
1017 let harness = McpTestHarness::new();
1018 let result = harness.call_tool("get_inbox", None).await;
1019 assert!(!result.is_error);
1020 }
1021
1022 #[tokio::test]
1023 async fn test_mcp_resource_read() {
1024 let harness = McpTestHarness::new();
1025 let result = harness.read_resource("things://inbox").await;
1026 assert!(!result.contents.is_empty());
1027 }
1028
1029 #[tokio::test]
1030 async fn test_mcp_prompt_get() {
1031 let harness = McpTestHarness::new();
1032 let result = harness
1033 .get_prompt("task_review", Some(json!({"task_title": "Test"})))
1034 .await;
1035 assert!(!result.is_error);
1036 }
1037
1038 #[tokio::test]
1039 async fn test_mcp_tool_json_result() {
1040 let harness = McpTestHarness::new();
1041 let json_result = harness.assert_tool_returns_json("get_inbox", None).await;
1042 assert!(json_result.is_array());
1043 }
1044
1045 #[tokio::test]
1046 async fn test_mcp_mock_database() {
1047 let mut db = MockDatabase::new();
1048 db.add_task(MockTask {
1049 uuid: "test-task".to_string(),
1050 title: "Test Task".to_string(),
1051 status: "incomplete".to_string(),
1052 project: None,
1053 area: None,
1054 });
1055
1056 let task = db.get_task("test-task");
1057 assert!(task.is_some());
1058 assert_eq!(task.unwrap().title, "Test Task");
1059
1060 let completed_tasks = db.get_tasks_by_status("completed");
1061 assert_eq!(completed_tasks.len(), 0);
1062 }
1063
1064 #[tokio::test]
1065 async fn test_mcp_test_utils() {
1066 let request =
1067 McpTestUtils::create_tool_request("test_tool", Some(json!({"param": "value"})));
1068 assert_eq!(request.name, "test_tool");
1069 assert!(request.arguments.is_some());
1070
1071 let request = McpTestUtils::create_resource_request("things://test");
1072 assert_eq!(request.uri, "things://test");
1073
1074 let request =
1075 McpTestUtils::create_prompt_request("test_prompt", Some(json!({"param": "value"})));
1076 assert_eq!(request.name, "test_prompt");
1077 assert!(request.arguments.is_some());
1078 }
1079
1080 #[tokio::test]
1081 async fn test_mcp_performance_test() {
1082 let perf_test = McpPerformanceTest::new();
1083 std::thread::sleep(std::time::Duration::from_millis(10));
1084 let elapsed = perf_test.elapsed();
1085 assert!(elapsed.as_millis() >= 10);
1086
1087 let perf_test = McpPerformanceTest::new();
1088 perf_test.assert_under_ms(1000); }
1090
1091 #[tokio::test]
1092 async fn test_mcp_integration_test() {
1093 let integration_test = McpIntegrationTest::new();
1094
1095 let result = integration_test.test_tool_workflow("get_inbox", None).await;
1097 assert!(!result.is_error);
1098
1099 let result = integration_test
1101 .test_resource_workflow("things://inbox")
1102 .await;
1103 assert!(!result.contents.is_empty());
1104
1105 let result = integration_test
1107 .test_prompt_workflow("task_review", Some(json!({"task_title": "Test"})))
1108 .await;
1109 assert!(!result.is_error);
1110
1111 integration_test.test_error_handling_workflow().await;
1113
1114 integration_test.test_performance_workflow().await;
1116 }
1117}