// MCP daemon DTO parsing tests // Focus: exercise readStringArray() via real DTO fromJson() implementations. #include #include #include #include #include using nlohmann::json; TEST_CASE("MCP DTO parsing - SearchRequest accepts string/array/null for include_patterns/tags", "[mcp][dto][search][catch2]") { using yams::mcp::MCPSearchRequest; { const auto r = MCPSearchRequest::fromJson(json{{"query", "q"}}); CHECK(r.includePatterns.empty()); CHECK(r.tags.empty()); } { const auto r = MCPSearchRequest::fromJson( json{{"query", "q"}, {"include_patterns", nullptr}, {"tags", nullptr}}); CHECK(r.includePatterns.empty()); CHECK(r.tags.empty()); } { const auto r = MCPSearchRequest::fromJson( json{{"query", "q"}, {"include_patterns", "*.cpp"}, {"tags", "pinned"}}); CHECK(r.includePatterns == std::vector{"*.cpp"}); CHECK(r.tags == std::vector{"pinned"}); } { const auto r = MCPSearchRequest::fromJson( json{{"query", "q"}, {"include_patterns", json::array({"a.*", 1, true, "b.*", nullptr})}, {"tags", json::array({"t1", 2, false, "t2"})}}); CHECK(r.includePatterns == std::vector{"a.*", "b.*"}); CHECK(r.tags == std::vector{"t1", "t2"}); } } TEST_CASE("MCP DTO parsing - SearchRequest tolerant numeric parsing and context override", "[mcp][dto][search][catch2]") { using yams::mcp::MCPSearchRequest; // normalize_query + tolerant parsing for limit/similarity and context override semantics. json j = { {"query", " hi\nthere "}, {"limit", "5"}, {"similarity", "0.5"}, {"context", "3"}, {"before_context", 111}, {"after_context", 222}, {"path", "src"}, }; const auto r = MCPSearchRequest::fromJson(j); CHECK(r.query == "hi there"); CHECK(r.limit == 5u); CHECK(r.similarity == Catch::Approx(0.5)); // When context > 0, it overrides before/after. CHECK(r.context == 3); CHECK(r.beforeContext == 3); CHECK(r.afterContext == 3); // path_pattern falls back to "path". CHECK(r.pathPattern == "src"); } TEST_CASE("MCP DTO parsing - GrepRequest accepts string/array for paths/include_patterns/tags", "[mcp][dto][grep][catch2]") { using yams::mcp::MCPGrepRequest; { const auto r = MCPGrepRequest::fromJson(json{{"pattern", "re"}}); CHECK(r.paths.empty()); CHECK(r.includePatterns.empty()); CHECK(r.tags.empty()); } { const auto r = MCPGrepRequest::fromJson(json{{"pattern", "re"}, {"paths", "src/main.cpp"}, {"include_patterns", "*.h"}, {"tags", "pinned"}}); CHECK(r.paths == std::vector{"src/main.cpp"}); CHECK(r.includePatterns == std::vector{"*.h"}); CHECK(r.tags == std::vector{"pinned"}); } { const auto r = MCPGrepRequest::fromJson( json{{"pattern", "re"}, {"paths", json::array({"a", 1, "b", false})}, {"include_patterns", json::array({"*.cpp", {"x", 1}, "*.hpp"})}, {"tags", json::array({"t1", 0, "t2"})}}); CHECK(r.paths == std::vector{"a", "b"}); CHECK(r.includePatterns == std::vector{"*.cpp", "*.hpp"}); CHECK(r.tags == std::vector{"t1", "t2"}); } } TEST_CASE("MCP DTO parsing - GrepRequest max_count tolerates numeric strings", "[mcp][dto][grep][catch2]") { using yams::mcp::MCPGrepRequest; { const auto r = MCPGrepRequest::fromJson(json{{"pattern", "re"}, {"max_count", "7"}}); REQUIRE(r.maxCount.has_value()); CHECK(r.maxCount.value() == 7); } { // Empty or invalid values should not enable maxCount. const auto r = MCPGrepRequest::fromJson(json{{"pattern", "re"}, {"max_count", ""}}); CHECK_FALSE(r.maxCount.has_value()); } { const auto r = MCPGrepRequest::fromJson(json{{"pattern", "re"}, {"max_count", "nope"}}); CHECK_FALSE(r.maxCount.has_value()); } } TEST_CASE("MCP DTO parsing - DownloadRequest accepts string/array for headers/tags", "[mcp][dto][download][catch2]") { using yams::mcp::MCPDownloadRequest; { const auto r = MCPDownloadRequest::fromJson(json{{"url", "https://e"}}); CHECK(r.headers.empty()); CHECK(r.tags.empty()); } { const auto r = MCPDownloadRequest::fromJson( json{{"url", "https://e"}, {"headers", "Accept: */*"}, {"tags", "docs"}}); CHECK(r.headers == std::vector{"Accept: */*"}); CHECK(r.tags == std::vector{"docs"}); } { const auto r = MCPDownloadRequest::fromJson( json{{"url", "https://e"}, {"headers", json::array({"A: 1", 2, false, "B: 2"})}, {"tags", json::array({"t1", json::object(), nullptr, "t2"})}}); CHECK(r.headers == std::vector{"A: 1", "B: 2"}); CHECK(r.tags == std::vector{"t1", "t2"}); } } TEST_CASE("MCP DTO parsing - DownloadRequest metadata stringifies values", "[mcp][dto][download][catch2]") { using yams::mcp::MCPDownloadRequest; json j = { {"url", "https://e"}, {"metadata", json{{"s", "x"}, {"i", -12}, {"u", 12}, {"b", true}, {"f", 1.5}, {"arr", json::array({1, 2})}, {"obj", json{{"k", 1}}}}}, }; const auto r = MCPDownloadRequest::fromJson(j); REQUIRE(r.metadata.contains("s")); CHECK(r.metadata.at("s") == "x"); CHECK(r.metadata.at("i") == "-12"); CHECK(r.metadata.at("u") == "12"); CHECK(r.metadata.at("b") == "true"); CHECK(r.metadata.at("arr") == json::array({1, 2}).dump()); CHECK(r.metadata.at("obj") == json{{"k", 1}}.dump()); } TEST_CASE("MCP DTO parsing - DownloadResponse preserves indexed flag", "[mcp][dto][download][catch2]") { using yams::mcp::MCPDownloadResponse; { const auto r = MCPDownloadResponse::fromJson( json{{"url", "https://e"}, {"success", true}, {"indexed", true}}); CHECK(r.success); CHECK(r.indexed); } { MCPDownloadResponse resp; resp.url = "https://e"; resp.success = true; resp.indexed = false; const auto j = resp.toJson(); REQUIRE(j.contains("indexed")); CHECK(j["indexed"].is_boolean()); CHECK_FALSE(j["indexed"].get()); } } TEST_CASE("MCP DTO parsing - Download job request and response roundtrip", "[mcp][dto][download][catch2]") { using yams::mcp::MCPDownloadJobRequest; using yams::mcp::MCPDownloadJobResponse; { const auto req = MCPDownloadJobRequest::fromJson(json{{"job_id", "job-123"}}); CHECK(req.jobId == "job-123"); const auto out = req.toJson(); REQUIRE(out.contains("job_id")); CHECK(out["job_id"] == "job-123"); } { const auto resp = MCPDownloadJobResponse::fromJson(json{{"job_id", "job-1"}, {"state", "running"}, {"success", false}, {"url", "https://example.com/a"}, {"hash", "abc"}, {"stored_path", "/tmp/a"}, {"size_bytes", 42}, {"created_at_ms", 100}, {"updated_at_ms", 120}, {"error", ""}}); CHECK(resp.jobId == "job-1"); CHECK(resp.state == "running"); CHECK_FALSE(resp.success); CHECK(resp.sizeBytes == 42); const auto out = resp.toJson(); CHECK(out["job_id"] == "job-1"); CHECK(out["state"] == "running"); CHECK(out["size_bytes"] == 42); } } TEST_CASE("MCP DTO parsing - List download jobs response accepts job arrays", "[mcp][dto][download][catch2]") { using yams::mcp::MCPListDownloadJobsRequest; using yams::mcp::MCPListDownloadJobsResponse; const auto req = MCPListDownloadJobsRequest::fromJson(json::object()); CHECK(req.toJson().empty()); const auto resp = MCPListDownloadJobsResponse::fromJson(json{ {"jobs", json::array({json{{"job_id", "job-1"}, {"state", "running"}}, json{{"job_id", "job-2"}, {"state", "completed"}, {"success", true}}})}}); REQUIRE(resp.jobs.size() == 2); CHECK(resp.jobs[0].jobId == "job-1"); CHECK(resp.jobs[1].state == "completed"); const auto out = resp.toJson(); REQUIRE(out.contains("jobs")); REQUIRE(out["jobs"].is_array()); REQUIRE(out["jobs"].size() == 2); } TEST_CASE("MCP DTO parsing - ListDocumentsRequest accepts string/array for tags", "[mcp][dto][list][catch2]") { using yams::mcp::MCPListDocumentsRequest; { const auto r = MCPListDocumentsRequest::fromJson(json{{"pattern", "*.md"}}); CHECK(r.tags.empty()); CHECK_FALSE(r.matchAllTags); } { const auto r = MCPListDocumentsRequest::fromJson(json{{"pattern", "*.md"}, {"tags", "pinned"}}); CHECK(r.tags == std::vector{"pinned"}); } { const auto r = MCPListDocumentsRequest::fromJson( json{{"pattern", "*.md"}, {"tags", json::array({"t1", 1, "t2", false})}}); CHECK(r.tags == std::vector{"t1", "t2"}); } { const auto r = MCPListDocumentsRequest::fromJson(json{ {"pattern", "*.md"}, {"tags", json::array({"t1", "t2"})}, {"match_all_tags", true}}); CHECK(r.tags == std::vector{"t1", "t2"}); CHECK(r.matchAllTags); const auto j = r.toJson(); REQUIRE(j.contains("match_all_tags")); CHECK(j["match_all_tags"].is_boolean()); CHECK(j["match_all_tags"].get()); } } TEST_CASE("MCP DTO parsing - UpdateMetadataRequest accepts string/array for names/tags/remove_tags", "[mcp][dto][update][catch2]") { using yams::mcp::MCPUpdateMetadataRequest; { const auto r = MCPUpdateMetadataRequest::fromJson(json{{"hash", "h"}}); CHECK(r.names.empty()); CHECK(r.tags.empty()); CHECK(r.removeTags.empty()); } { const auto r = MCPUpdateMetadataRequest::fromJson( json{{"hash", "h"}, {"names", "n1"}, {"tags", "t1"}, {"remove_tags", "t2"}}); CHECK(r.names == std::vector{"n1"}); CHECK(r.tags == std::vector{"t1"}); CHECK(r.removeTags == std::vector{"t2"}); } { const auto r = MCPUpdateMetadataRequest::fromJson( json{{"hash", "h"}, {"names", json::array({"n1", 2, "n2"})}, {"tags", json::array({"t1", false, "t2"})}, {"remove_tags", json::array({nullptr, "rt1", json::object(), "rt2"})}}); CHECK(r.names == std::vector{"n1", "n2"}); CHECK(r.tags == std::vector{"t1", "t2"}); CHECK(r.removeTags == std::vector{"rt1", "rt2"}); } } TEST_CASE("MCP DTO parsing - DeleteByNameRequest accepts string/array for names", "[mcp][dto][delete][catch2]") { using yams::mcp::MCPDeleteByNameRequest; { const auto r = MCPDeleteByNameRequest::fromJson(json{{"names", "a"}}); CHECK(r.names == std::vector{"a"}); } { const auto r = MCPDeleteByNameRequest::fromJson(json{{"names", json::array({"a", 1, "b", false})}}); CHECK(r.names == std::vector{"a", "b"}); } }