use std::net::{IpAddr, Ipv4Addr}; use std::sync::{Arc, Barrier}; use bds_core::db::Database; use bds_core::engine::{mcp, project}; use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus}; use serde_json::json; struct Fixture { _root: tempfile::TempDir, database_path: std::path::PathBuf, data_dir: std::path::PathBuf, project_id: String, } impl Fixture { fn new() -> Self { let root = tempfile::tempdir().unwrap(); let database_path = root.path().join("app/bds.db"); let data_dir = root.path().join("project"); std::fs::create_dir_all(database_path.parent().unwrap()).unwrap(); std::fs::create_dir_all(&data_dir).unwrap(); let db = Database::open(&database_path).unwrap(); db.migrate().unwrap(); bds_core::engine::search::prepare_search_index(db.conn()).unwrap(); let project = project::create_project(db.conn(), "MCP Test", data_dir.to_str()).unwrap(); project::set_active_project(db.conn(), &project.id).unwrap(); Self { _root: root, database_path, data_dir, project_id: project.id, } } fn context(&self) -> mcp::McpContext { mcp::McpContext::new(self.database_path.clone()) } fn database(&self) -> Database { Database::open(&self.database_path).unwrap() } fn post(&self, title: &str, content: &str) -> bds_core::model::Post { let db = self.database(); bds_core::engine::post::create_post( db.conn(), &self.data_dir, &self.project_id, title, Some(content), vec!["rust".into()], vec!["article".into()], Some("Alice"), Some("en"), None, ) .unwrap() } fn media(&self) -> bds_core::model::Media { let source = self._root.path().join("source.png"); std::fs::write( &source, include_bytes!( "../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png" ), ) .unwrap(); let db = self.database(); bds_core::engine::media::import_media( db.conn(), &self.data_dir, &self.project_id, &source, "source.png", Some("Close"), Some("Close icon"), None, None, Some("en"), vec!["ui".into()], ) .unwrap() } } fn resource_json(content: mcp::ResourceContent) -> serde_json::Value { serde_json::from_str(content.text.as_deref().unwrap()).unwrap() } #[test] fn protocol_lists_every_resource_and_tool_without_mutating_state() { let fixture = Fixture::new(); let context = fixture.context(); let post = fixture.post("Read only", "Body"); let db = fixture.database(); let before_posts = bds_core::db::queries::post::count_posts_by_project(db.conn(), &fixture.project_id) .unwrap(); let before_proposals = mcp::list_proposals(db.conn(), &fixture.project_id) .unwrap() .len(); let resources = context.list_resources(); let templates = context.list_resource_templates(); let tools = context.list_tools(); assert_eq!(resources.len(), 6); assert_eq!(templates.len(), 4); for name in [ "check_term", "search_posts", "count_posts", "read_post_by_slug", "get_post_translations", "get_media_translations", "upsert_media_translation", "draft_post", "propose_script", "propose_template", "propose_media_metadata", "propose_post_metadata", ] { assert!(tools.iter().any(|tool| tool["name"] == name)); } context.read_resource("bds://stats").unwrap(); context.read_resource("bds://project").unwrap(); context .call_tool("read_post_by_slug", json!({"slug": post.slug})) .unwrap(); assert_eq!( bds_core::db::queries::post::count_posts_by_project(db.conn(), &fixture.project_id) .unwrap(), before_posts ); assert_eq!( mcp::list_proposals(db.conn(), &fixture.project_id) .unwrap() .len(), before_proposals ); } #[test] fn write_tools_create_inert_proposals_and_desktop_approval_executes_once() { let fixture = Fixture::new(); let context = fixture.context(); let proposed = context .call_tool( "draft_post", json!({"title":"Proposed post","content":"Body","tags":["rust"]}), ) .unwrap(); let proposal_id = proposed["proposalId"].as_str().unwrap(); let db = Database::open(&fixture.database_path).unwrap(); assert!( bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) .unwrap() .is_empty() ); let proposal = mcp::get_proposal(db.conn(), proposal_id).unwrap(); assert_eq!(proposal.status, mcp::ProposalStatus::Pending); let events = bds_core::engine::domain_events::subscribe(); let accepted = mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).unwrap(); assert_eq!(accepted.status, mcp::ProposalStatus::Accepted); let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id).unwrap(); assert_eq!(posts.len(), 1); assert_eq!(posts[0].title, "Proposed post"); assert_eq!(posts[0].status, bds_core::model::PostStatus::Published); assert!(fixture.data_dir.join(&posts[0].file_path).is_file()); assert!(events.drain().into_iter().any(|event| matches!( event, DomainEvent::EntityChanged { project_id, entity: DomainEntity::Post, .. } if project_id == fixture.project_id ))); assert!(mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).is_err()); assert_eq!( bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id) .unwrap() .len(), 1 ); } #[test] fn every_resource_supports_pagination_links_images_and_not_found() { let fixture = Fixture::new(); let first = fixture.post("First post", "First body"); for index in 0..50 { fixture.post(&format!("Post {index}"), "Body"); } let media = fixture.media(); let db = fixture.database(); bds_core::engine::post_media::link_media_to_post( db.conn(), &fixture.data_dir, &fixture.project_id, &first.id, &media.id, 0, ) .unwrap(); bds_core::engine::tag::sync_tags_from_posts(db.conn(), &fixture.project_id).unwrap(); bds_core::engine::meta::write_categories_json(&fixture.data_dir, &["article".into()]).unwrap(); let context = fixture.context(); let project = resource_json(context.read_resource("bds://project").unwrap()); assert_eq!(project["id"], fixture.project_id); assert_eq!(project["name"], "MCP Test"); let posts = resource_json(context.read_resource("bds://posts").unwrap()); assert_eq!(posts["items"].as_array().unwrap().len(), 50); assert_eq!(posts["total"], 51); let cursor = posts["nextCursor"].as_str().unwrap(); let next = resource_json( context .read_resource(&format!("bds://posts?cursor={cursor}")) .unwrap(), ); assert_eq!(next["items"].as_array().unwrap().len(), 1); assert!( context .read_resource("bds://posts?cursor=not-base64") .is_err() ); assert_eq!( resource_json(context.read_resource("bds://media").unwrap())["total"], 1 ); assert_eq!( resource_json(context.read_resource("bds://tags").unwrap())["items"][0]["name"], "rust" ); assert_eq!( resource_json(context.read_resource("bds://categories").unwrap())["items"][0]["name"], "article" ); let stats = resource_json(context.read_resource("bds://stats").unwrap()); assert_eq!(stats["post_count"], 51); assert_eq!(stats["media_count"], 1); let linked = resource_json( context .read_resource(&format!("bds://posts/{}/media", first.id)) .unwrap(), ); assert_eq!(linked["items"][0]["id"], media.id); let image = context .read_resource(&format!("bds://media/{}/image", media.id)) .unwrap(); assert_eq!(image.mime_type, "image/png"); assert!(!image.blob.unwrap().is_empty()); assert!(context.read_resource("bds://posts/missing/media").is_err()); assert!(context.read_resource("bds://unknown").is_err()); } #[test] fn every_read_tool_uses_shared_queries_filters_translations_and_grouping() { let fixture = Fixture::new(); let post = fixture.post("Rust search", "Semantic body"); let media = fixture.media(); let db = fixture.database(); bds_core::engine::post::upsert_translation( db.conn(), &fixture.data_dir, &post.id, "de", "Rust Suche", Some("Kurz"), Some("Deutscher Inhalt"), ) .unwrap(); bds_core::engine::media::upsert_media_translation( db.conn(), &fixture.data_dir, &media.id, "de", Some("Schließen"), Some("Schließen-Symbol"), None, ) .unwrap(); let context = fixture.context(); let term = context .call_tool("check_term", json!({"term":"rust"})) .unwrap(); assert_eq!(term["is_tag"], true); assert_eq!(term["tag_post_count"], 1); let search = context .call_tool( "search_posts", json!({"query":"semantic","category":"article","tags":["rust"],"language":"en","offset":0,"limit":10}), ) .unwrap(); assert_eq!(search["total"], 1); assert_eq!(search["posts"][0]["id"], post.id); let missing = context .call_tool("search_posts", json!({"missingTranslationLanguage":"fr"})) .unwrap(); assert_eq!(missing["total"], 1); let count = context .call_tool("count_posts", json!({"groupBy":["status","tag"]})) .unwrap(); assert_eq!(count["totalPosts"], 1); assert_eq!(count["groups"][0]["count"], 1); let translated = context .call_tool( "read_post_by_slug", json!({"slug":post.slug,"language":"de"}), ) .unwrap(); assert_eq!(translated["post"]["title"], "Rust Suche"); assert_eq!(translated["post"]["content"], "Deutscher Inhalt"); assert_eq!( context .call_tool("get_post_translations", json!({"postId":post.id})) .unwrap()["translations"] .as_array() .unwrap() .len(), 1 ); assert_eq!( context .call_tool("get_media_translations", json!({"mediaId":media.id})) .unwrap()["translations"] .as_array() .unwrap() .len(), 1 ); assert!( context .call_tool("search_posts", json!({"month":2})) .is_err() ); assert!(context.call_tool("missing", json!({})).is_err()); } #[test] fn every_write_tool_is_inert_then_approves_or_rejects_through_shared_engines() { let fixture = Fixture::new(); let post = fixture.post("Existing", "Body"); let media = fixture.media(); let context = fixture.context(); let db = fixture.database(); let requests = [ ( "upsert_media_translation", json!({"mediaId":media.id,"language":"fr","title":"Fermer"}), ), ( "propose_script", json!({"title":"Task","kind":"utility","content":"function main()\n return true\nend"}), ), ( "propose_template", json!({"title":"Card","kind":"partial","content":"
{{ post.title }}
"}), ), ( "propose_media_metadata", json!({"mediaId":media.id,"title":"Updated media","tags":["updated"]}), ), ( "propose_post_metadata", json!({"postId":post.id,"title":"Updated post","categories":["news"]}), ), ]; let mut proposal_ids = Vec::new(); for (tool, params) in requests { let response = context.call_tool(tool, params).unwrap(); proposal_ids.push(response["proposalId"].as_str().unwrap().to_string()); } assert_eq!( bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id) .unwrap() .len(), 0 ); assert_eq!( bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id) .unwrap() .len(), 0 ); assert_eq!( bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id) .unwrap() .title, "Existing" ); assert_eq!( bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id) .unwrap() .title .as_deref(), Some("Close") ); let events = bds_core::engine::domain_events::subscribe(); for proposal_id in proposal_ids.iter().take(4) { mcp::accept_proposal(db.conn(), &fixture.data_dir, proposal_id).unwrap(); } mcp::reject_proposal(db.conn(), &fixture.data_dir, &proposal_ids[4]).unwrap(); assert_eq!( bds_core::db::queries::media_translation::list_media_translations_by_media( db.conn(), &media.id ) .unwrap() .len(), 1 ); assert_eq!( bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project_id) .unwrap()[0] .status, bds_core::model::ScriptStatus::Published ); assert_eq!( bds_core::db::queries::template::list_templates_by_project(db.conn(), &fixture.project_id) .unwrap()[0] .status, bds_core::model::TemplateStatus::Published ); assert_eq!( bds_core::db::queries::media::get_media_by_id(db.conn(), &media.id) .unwrap() .title .as_deref(), Some("Updated media") ); assert_eq!( bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id) .unwrap() .title, "Existing" ); assert_eq!( mcp::get_proposal(db.conn(), &proposal_ids[4]) .unwrap() .status, ProposalStatus::Rejected ); let changed_entities = events .drain() .into_iter() .filter_map(|event| match event { DomainEvent::EntityChanged { project_id, entity, .. } if project_id == fixture.project_id => Some(entity), _ => None, }) .collect::