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, true);
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 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 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 1_700_000_000.0 };
423
424 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) .bind(0) .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) .bind(1) .bind(now)
444 .bind(now)
445 .execute(&pool)
446 .await
447 .unwrap();
448
449 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) .bind(0) .bind("Complete redesign of company website")
458 .bind(now_timestamp)
459 .bind(now_timestamp)
460 .bind("550e8400-e29b-41d4-a716-446655440001") .bind(0) .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) .bind(0) .bind("Learn the Rust programming language")
473 .bind(now_timestamp)
474 .bind(now_timestamp)
475 .bind("550e8400-e29b-41d4-a716-446655440002")
476 .bind(0) .bind("[\"personal\", \"learning\"]")
478 .execute(&pool).await.unwrap();
479
480 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) .bind::<Option<i64>>(None) .bind(now_timestamp)
492 .bind(now_timestamp)
493 .bind::<Option<String>>(None) .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .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) .bind::<Option<i64>>(None) .bind(now_timestamp)
511 .bind(now_timestamp)
512 .bind("550e8400-e29b-41d4-a716-446655440010")
513 .bind("550e8400-e29b-41d4-a716-446655440001") .bind("") .bind(0) .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) .bind::<Option<i64>>(None) .bind(now_timestamp)
530 .bind(now_timestamp)
531 .bind("550e8400-e29b-41d4-a716-446655440011")
532 .bind("550e8400-e29b-41d4-a716-446655440002") .bind("") .bind(0) .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
549pub 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
645pub struct McpTestUtils;
647
648impl McpTestUtils {
649 #[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 #[must_use]
660 pub fn create_resource_request(uri: &str) -> ReadResourceRequest {
661 ReadResourceRequest {
662 uri: uri.to_string(),
663 }
664 }
665
666 #[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 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 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 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 #[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 #[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 #[must_use]
767 pub fn create_test_data() -> MockDatabase {
768 Self::create_test_data_with_scenarios()
769 }
770
771 #[must_use]
773 pub fn create_test_data_with_scenarios() -> MockDatabase {
774 let mut db = MockDatabase::new();
775
776 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 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 db.add_area(MockArea {
806 uuid: "area-3".to_string(),
807 title: "Health".to_string(),
808 visible: true,
809 });
810
811 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
848pub 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 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
889pub 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 pub async fn test_tool_workflow(
919 &self,
920 tool_name: &str,
921 arguments: Option<Value>,
922 ) -> CallToolResult {
923 let tools = self.harness.server().list_tools().unwrap();
925 assert!(!tools.tools.is_empty(), "Should have tools available");
926
927 self.harness.call_tool(tool_name, arguments).await
929 }
930
931 pub async fn test_resource_workflow(&self, uri: &str) -> ReadResourceResult {
936 let resources = self.harness.server().list_resources().unwrap();
938 assert!(
939 !resources.resources.is_empty(),
940 "Should have resources available"
941 );
942
943 self.harness.read_resource(uri).await
945 }
946
947 pub async fn test_prompt_workflow(
952 &self,
953 name: &str,
954 arguments: Option<Value>,
955 ) -> GetPromptResult {
956 let prompts = self.harness.server().list_prompts().unwrap();
958 assert!(!prompts.prompts.is_empty(), "Should have prompts available");
959
960 self.harness.get_prompt(name, arguments).await
962 }
963
964 pub async fn test_error_handling_workflow(&self) {
969 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 let result = self
978 .harness
979 .read_resource_with_fallback("things://nonexistent")
980 .await;
981 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 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 }
1004
1005 pub async fn test_performance_workflow(&self) {
1007 let perf_test = McpPerformanceTest::new();
1008
1009 self.harness.call_tool("get_inbox", None).await;
1011 perf_test.assert_under_ms(1000);
1012
1013 self.harness.read_resource("things://inbox").await;
1015 perf_test.assert_under_ms(1000);
1016
1017 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); }
1120
1121 #[tokio::test]
1122 async fn test_mcp_integration_test() {
1123 let integration_test = McpIntegrationTest::new();
1124
1125 let result = integration_test.test_tool_workflow("get_inbox", None).await;
1127 assert!(!result.is_error);
1128
1129 let result = integration_test
1131 .test_resource_workflow("things://inbox")
1132 .await;
1133 assert!(!result.contents.is_empty());
1134
1135 let result = integration_test
1137 .test_prompt_workflow("task_review", Some(json!({"task_title": "Test"})))
1138 .await;
1139 assert!(!result.is_error);
1140
1141 integration_test.test_error_handling_workflow().await;
1143
1144 integration_test.test_performance_workflow().await;
1146 }
1147}