yams/tests/unit/daemon/daemon_protocol_test.cpp
Trevon 52aa700144
fix(graph): --scope-cwd clusters, --name lookup, --limit (#280 follow-ups) (#284)
* test(graph): cover --topology-clusters --scope-cwd outside src/include

Follow-up to #280. --topology-clusters --scope-cwd still built its
scope from <cwd>/src and <cwd>/include, so a corpus laid out any other
way had no scoped members:
- The scope path set must hold every stored path under the cwd, also
  when the cwd is reached through a symlink, and not a sibling that
  shares the prefix.
- GraphTopologySupport must build the set from its own metadata repo.

The existing cluster-scope case built its path set by hand from the
old src/include contract. It now builds the set from /repo, so
/repo/tests/ is in scope and a document outside /repo is not.

Signed-off-by: trvon <me@trevon.dev>

* fix(graph): scope --topology-clusters --scope-cwd to the whole cwd

Follow-up to #280. Topology views built their --scope-cwd path set from
<cwd>/src and <cwd>/include only, a leftover from the code-symbol graph
removed in v0.20. Corpora laid out any other way reported no scoped
members.

Build the set from the same roots as the --list-type ranges: the cwd,
lexical and symlink-resolved. Keep only paths really under a root, since
the prefix query matches with LIKE. The topology view no longer prunes
build/, packages and IDE paths, so it matches --list-type.

GraphTopologySupport now builds the set from its own metadata repo
instead of the CLI app context. Remove buildGraphCodeScopePathSet and
buildGraphCurrentScopePathSet, which have no other callers, and use one
"Scoped to paths under the current directory" note for both views.

Signed-off-by: trvon <me@trevon.dev>

* test(cli): cover graph --name on a document with a path:file: node

Follow-up to #280. yams graph --name <path> tried path:file:<path>
node keys before the document lookup. v0.20 ingestion writes
path:logical: and path:<snapshot>: keys, so the probe misses on a
fresh corpus; after `yams doctor repair --graph` (or NL entity
extraction) the key exists, and --name printed a raw traversal of the
file node instead of the document's related documents.

Smoke cases through the in-process CLI:
- --name on a file with a path:file: node returns its hash and its
  semantic neighbours (red).
- A relative --name resolves under the cwd even though a newer copy
  with the same relative path exists in another checkout.
- A bare file name still resolves by name.

The portability allowlist entry for this file moves with its getenv
line.

Signed-off-by: trvon <me@trevon.dev>

* fix(cli): resolve graph --name through the document lookup only

Follow-up to #280. yams graph --name first traversed path:file:<path>
node keys. v0.20 ingestion writes path:logical:<path> and
path:<snapshot>:<path>, which only chain to blob versions; semantic
neighbours hang off doc:<hash>. On a fresh corpus the probe always
missed. After `yams doctor repair --graph` or NL entity extraction it
hit and printed a raw file-node traversal instead of the related
documents.

Drop the probe and go straight to the document lookup, which starts at
doc:<hash> since #280. Resolving to path:logical: instead would show
the version chain, not related documents; --node-key still traverses
any node.

Send --name as the path ingestion stores: relative to the client cwd,
lexically normalized, symlinks resolved. The daemon resolved relative
names against its own cwd and fell back to a suffix match, which picks
the newest copy across checkouts. Fall back to the name as given when
it does not resolve, so bare file names and suffixes still work.

build_graph_file_node_candidates had no other callers; its tests are
replaced by tests for buildGraphDocumentNameCandidates.

Signed-off-by: trvon <me@trevon.dev>

* test(graph): cover --limit on the document graph lookup

Follow-up to #280. yams graph <hash> and --name ignored --limit: the
daemon capped the graph query at 100 nodes and the same-directory
fallback at 20 documents.

Smoke cases through the in-process CLI:
- --limit 1 returns one related document by name and by hash, and the
  first of the uncapped list.
- --limit 1 caps the same-directory fallback for a document without
  graph edges.
- Without --limit the related lists are unchanged.

Signed-off-by: trvon <me@trevon.dev>

* fix(graph): pass --limit through the document graph lookup

Follow-up to #280. yams graph <hash> and --name sent --depth but not
--limit; DocumentService fixed the graph query at 100 nodes and the
same-directory fallback at 20 documents.

Add GetRequest.graph_limit (proto field 27) and
RetrieveDocumentRequest::graphLimit, and pass them from the CLI through
the dispatcher. A limit caps the related list, graph neighbours, path
history and the same-directory fallback alike. The graph query never
asks for fewer than 100 nodes, since path and blob nodes count toward
that limit.

The CLI sends a limit only when --limit is given; 0 keeps the old caps,
so default output is unchanged. The binary GetRequest layout appends
the field and still reads payloads without it.

Unit tests cover the proto round trip, the dispatcher mapping and the
DocumentService caps.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-09 00:01:24 -06:00

1825 lines
72 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Trevon Helm
// Consolidated protocol tests (6 → 1): message framing, serialization, type mappings, roundtrip
// Covers: MessageFramer, ProtoSerializer, response_of.hpp, getMessageType, getRequestName
#include <array>
#include <atomic>
#include <csignal>
#include <filesystem>
#include <random>
#include <thread>
#include <type_traits>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <yams/common/utf8_utils.h>
#include <yams/daemon/ipc/ipc_protocol.h>
#include <yams/daemon/ipc/message_framing.h>
#include <yams/daemon/ipc/proto_serializer.h>
#include <yams/daemon/ipc/response_of.hpp>
#ifdef _WIN32
#include <winsock2.h>
#else
#include <arpa/inet.h>
#endif
using namespace yams;
using namespace yams::daemon;
namespace {
struct RecordingRequestSerializer {
RecordingRequestSerializer& operator<<(const std::string& value) {
fieldOrder.push_back('s');
strings.push_back(value);
return *this;
}
template <typename T>
requires std::is_integral_v<T>
RecordingRequestSerializer& operator<<(T value) {
fieldOrder.push_back(std::is_same_v<T, bool> ? 'b' : 'u');
if constexpr (std::is_same_v<T, bool>) {
bools.push_back(value);
} else {
integers.push_back(static_cast<std::uint64_t>(value));
}
return *this;
}
std::string fieldOrder;
std::vector<std::string> strings;
std::vector<std::uint64_t> integers;
std::vector<bool> bools;
};
struct LegacyGraphExploreDeserializer {
Result<std::string> readString() {
if (!queryRead) {
queryRead = true;
return std::string{"legacy-query"};
}
return ErrorCode::InvalidData;
}
template <typename T> Result<T> read() {
if constexpr (std::is_same_v<T, std::uint64_t>) {
if (integerIndex >= integers.size()) {
return ErrorCode::InvalidData;
}
return integers[integerIndex++];
} else if constexpr (std::is_same_v<T, bool>) {
if (boolIndex >= bools.size()) {
return ErrorCode::InvalidData;
}
return bools[boolIndex++];
} else {
return ErrorCode::InvalidData;
}
}
std::array<std::uint64_t, 5> integers{4, 12, 8000, 2000, 80};
std::array<bool, 4> bools{true, false, true, false};
std::size_t integerIndex{0};
std::size_t boolIndex{0};
bool queryRead{false};
};
#ifndef _WIN32
struct SigpipeIgnoreGuard {
SigpipeIgnoreGuard() : previous_(std::signal(SIGPIPE, SIG_IGN)) {}
~SigpipeIgnoreGuard() { std::signal(SIGPIPE, previous_); }
using Handler = void (*)(int);
Handler previous_{};
};
const SigpipeIgnoreGuard kSigpipeIgnoreGuard;
#endif
// Helper to create messages with Request/Response payloads
Message makeMessageWith(Request r, uint64_t id = 42) {
Message m;
m.requestId = id;
m.payload = std::move(r);
return m;
}
Message makeMessageWith(Response r, uint64_t id = 43) {
Message m;
m.requestId = id;
m.payload = std::move(r);
return m;
}
std::string invalidUtf8(std::string_view prefix, std::string_view suffix = {}) {
std::string value(prefix);
value.push_back(static_cast<char>(0xFF));
value.append(suffix);
return value;
}
void checkSanitizedField(std::string_view fieldName, const std::string& actual,
const std::string& original) {
INFO(fieldName);
CHECK(actual == yams::common::sanitizeUtf8(original));
}
uint32_t crc32Reference(const std::vector<uint8_t>& data) {
uint32_t crc = 0xFFFFFFFF;
for (auto byte : data) {
crc ^= byte;
for (int j = 0; j < 8; ++j) {
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u);
}
}
return ~crc;
}
std::vector<uint8_t> makeRawFrame(const std::vector<uint8_t>& payload, uint32_t flags = 0) {
constexpr std::size_t kHeaderSize = sizeof(MessageFramer::FrameHeader);
MessageFramer::FrameHeader header;
header.payload_size = static_cast<uint32_t>(payload.size());
header.checksum = crc32Reference(payload);
header.flags = flags;
header.to_network();
std::vector<uint8_t> frame(kHeaderSize + payload.size());
std::memcpy(frame.data(), &header, sizeof(header));
if (!payload.empty()) {
std::memcpy(frame.data() + kHeaderSize, payload.data(), payload.size());
}
return frame;
}
} // namespace
// =============================================================================
// CRC32 Reference Vector Tests
// =============================================================================
TEST_CASE("CRC32: known reference vectors via frame header", "[daemon][protocol][crc32]") {
// MessageFramer::calculate_crc32 is private, so we verify CRC32 values
// by building raw frames and extracting the checksum from the header.
// This tests the same code path that will be optimized.
auto extract_checksum = [](const std::vector<uint8_t>& payload) -> uint32_t {
// Build a frame header manually using MessageFramer::frame_message,
// then extract the CRC32 from the header's checksum field.
// We construct a raw header to compute the CRC, replicating the algorithm.
MessageFramer framer;
// Use the template frame() which computes CRC32 on the payload
// We need a FramedMessage type. Instead, build a raw frame manually:
MessageFramer::FrameHeader header;
header.payload_size = static_cast<uint32_t>(payload.size());
// The checksum field is what we want. We can't call calculate_crc32 directly,
// but we can replicate the ISO CRC32 reference implementation here for validation.
// This is the reference: NOT the code under test.
uint32_t crc = 0xFFFFFFFF;
for (auto byte : payload) {
crc = crc ^ byte;
for (int j = 0; j < 8; ++j) {
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u);
}
}
return ~crc;
};
SECTION("Standard check value: \"123456789\" -> 0xCBF43926") {
std::string input = "123456789";
std::vector<uint8_t> data(input.begin(), input.end());
REQUIRE(extract_checksum(data) == 0xCBF43926);
}
SECTION("Empty input -> 0x00000000") {
std::vector<uint8_t> empty;
REQUIRE(extract_checksum(empty) == 0x00000000);
}
SECTION("Single zero byte -> 0xD202EF8D") {
std::vector<uint8_t> data = {0x00};
REQUIRE(extract_checksum(data) == 0xD202EF8D);
}
// Now verify that frames built by MessageFramer embed the correct CRC32
// by parsing them back and confirming no checksum mismatch
SECTION("Frame roundtrip preserves CRC32 for known payloads") {
MessageFramer framer;
// Build a message with known content
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 1;
msg.payload = PingRequest{};
auto framedResult = framer.frame_message(msg);
REQUIRE(framedResult);
// Parse it back — this validates CRC32 internally
auto parsedResult = framer.parse_frame(framedResult.value());
REQUIRE(parsedResult);
// Flip one bit in payload, should fail CRC
auto corrupted = framedResult.value();
if (corrupted.size() > MessageFramer::HEADER_SIZE + 1) {
corrupted[MessageFramer::HEADER_SIZE + 1] ^= 0x01;
auto badResult = framer.parse_frame(corrupted);
REQUIRE_FALSE(badResult);
}
}
SECTION("CRC32 determinism: same payload always same frame") {
MessageFramer framer;
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 42;
msg.payload = StatusRequest{true};
auto frame1 = framer.frame_message(msg);
auto frame2 = framer.frame_message(msg);
REQUIRE(frame1);
REQUIRE(frame2);
REQUIRE(frame1.value() == frame2.value());
}
}
// =============================================================================
// Message Framing Tests
// =============================================================================
TEST_CASE("MessageFramer: Basic frame creation and parsing", "[daemon][protocol][framing]") {
auto framer = std::make_unique<MessageFramer>();
SECTION("Round-trip with PingRequest") {
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 12345;
msg.timestamp = std::chrono::steady_clock::now();
msg.payload = PingRequest{};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
REQUIRE(framedResult.value().size() > sizeof(MessageFramer::FrameHeader));
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
REQUIRE(parsedResult.value().version == msg.version);
REQUIRE(parsedResult.value().requestId == msg.requestId);
REQUIRE(std::holds_alternative<Request>(parsedResult.value().payload));
}
SECTION("Empty payload handling") {
Message msg{PROTOCOL_VERSION, 111, std::chrono::steady_clock::now(), PingRequest{}};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
REQUIRE(parsedResult.value().requestId == 111);
}
}
TEST_CASE("MessageFramer: CRC32 validation", "[daemon][protocol][framing][validation]") {
auto framer = std::make_unique<MessageFramer>();
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 54321;
msg.payload = StatusRequest{true};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto framedData = framedResult.value();
// Corrupt a byte in the payload (after header)
if (framedData.size() > sizeof(MessageFramer::FrameHeader) + 10) {
framedData[sizeof(MessageFramer::FrameHeader) + 5] ^= 0xFF;
}
// Parsing should fail due to CRC mismatch
auto parsedResult = framer->parse_frame(framedData);
REQUIRE_FALSE(parsedResult);
REQUIRE(parsedResult.error().code == ErrorCode::InvalidData);
}
TEST_CASE("ErrorResponse retry info round-trips", "[daemon][protocol][error]") {
ErrorResponse original;
original.code = ErrorCode::ResourceExhausted;
original.message = "server busy";
original.retry = ErrorResponse::RetryInfo{250, "overload"};
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 77;
msg.payload = Response{original};
MessageFramer framer;
auto framed = framer.frame_message(msg);
REQUIRE(framed);
auto parsed = framer.parse_frame(framed.value());
REQUIRE(parsed);
REQUIRE(std::holds_alternative<Response>(parsed.value().payload));
const auto& response = std::get<Response>(parsed.value().payload);
REQUIRE(std::holds_alternative<ErrorResponse>(response));
const auto& error = std::get<ErrorResponse>(response);
REQUIRE(error.code == ErrorCode::ResourceExhausted);
REQUIRE(error.message == "server busy");
REQUIRE(error.retry.has_value());
CHECK(error.retry->retryAfterMs == 250);
CHECK(error.retry->reason == "overload");
}
TEST_CASE("MessageFramer: Large message handling", "[daemon][protocol][framing][size]") {
auto framer = std::make_unique<MessageFramer>();
SECTION("Large query string (10KB)") {
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 99999;
SearchRequest req;
req.query = std::string(10000, 'x');
req.limit = 100;
msg.payload = req;
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
REQUIRE(framedResult.value().size() > 10000);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* parsedReq =
std::get_if<SearchRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(parsedReq != nullptr);
REQUIRE(parsedReq->query.size() == 10000);
REQUIRE(parsedReq->query == std::string(10000, 'x'));
}
SECTION("Message size limits") {
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 222;
SearchRequest req;
req.query = std::string(MAX_MESSAGE_SIZE / 2, 'y'); // Under limit
msg.payload = req;
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
// Oversized message should fail
req.query = std::string(MAX_MESSAGE_SIZE + 1000, 'z');
msg.payload = req;
framedResult = framer->frame_message(msg);
REQUIRE_FALSE(framedResult);
REQUIRE(framedResult.error().code == ErrorCode::InvalidData);
}
}
TEST_CASE("MessageFramer: SearchRequest sanitizes invalid UTF-8 for protobuf",
"[daemon][protocol][framing][utf8]") {
auto framer = std::make_unique<MessageFramer>();
SearchRequest req;
req.query = std::string("task=performance owner=opencode");
req.query.push_back(static_cast<char>(0xFF));
req.query.append(" tail");
req.searchType = "keyword";
req.hashQuery = std::string("\xFEhash", 5);
req.pathPattern = std::string("\xFF*.md", 5);
req.scopePathPrefix = std::string("\xFF/repo", 6);
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 444;
msg.payload = req;
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* parsedReq = std::get_if<SearchRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(parsedReq != nullptr);
REQUIRE(parsedReq->query == yams::common::sanitizeUtf8(req.query));
REQUIRE(parsedReq->hashQuery == yams::common::sanitizeUtf8(req.hashQuery));
REQUIRE(parsedReq->pathPattern == yams::common::sanitizeUtf8(req.pathPattern));
REQUIRE(parsedReq->scopePathPrefix == yams::common::sanitizeUtf8(req.scopePathPrefix));
REQUIRE(parsedReq->searchType == "keyword");
}
TEST_CASE("MessageFramer: Header validation", "[daemon][protocol][framing][validation]") {
auto framer = std::make_unique<MessageFramer>();
SECTION("Invalid magic number") {
std::vector<uint8_t> badFrame(sizeof(MessageFramer::FrameHeader) + 100);
auto* header = reinterpret_cast<MessageFramer::FrameHeader*>(badFrame.data());
header->magic = htonl(0xDEADBEEF); // Wrong magic
header->version = htonl(MessageFramer::VERSION);
header->payload_size = htonl(100);
header->checksum = htonl(0);
auto result = framer->parse_frame(badFrame);
REQUIRE_FALSE(result);
REQUIRE(result.error().code == ErrorCode::InvalidData);
}
SECTION("Version mismatch") {
std::vector<uint8_t> badFrame(sizeof(MessageFramer::FrameHeader) + 100);
auto* header = reinterpret_cast<MessageFramer::FrameHeader*>(badFrame.data());
header->magic = htonl(MessageFramer::MAGIC);
header->version = htonl(99); // Wrong version
header->payload_size = htonl(100);
header->checksum = htonl(0);
auto result = framer->parse_frame(badFrame);
REQUIRE_FALSE(result);
REQUIRE(result.error().code == ErrorCode::InvalidData);
}
SECTION("Partial frame data") {
Message msg{PROTOCOL_VERSION, 333, std::chrono::steady_clock::now(), ShutdownRequest{true}};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto& fullFrame = framedResult.value();
if (fullFrame.size() > 10) {
std::vector<uint8_t> partialFrame(fullFrame.begin(),
fullFrame.begin() + fullFrame.size() / 2);
auto result = framer->parse_frame(partialFrame);
REQUIRE_FALSE(result);
}
}
}
TEST_CASE("MessageFramer: valid frame rejects malformed protobuf envelope",
"[daemon][protocol][framing][validation]") {
MessageFramer framer;
// 0x80 is a truncated protobuf varint: framing and CRC can be valid while
// Envelope parsing still fails.
const std::vector<uint8_t> malformed_payload = {0x80};
auto frame = makeRawFrame(malformed_payload);
auto result = framer.parse_frame(frame);
REQUIRE_FALSE(result);
REQUIRE(result.error().code == ErrorCode::SerializationError);
CHECK(result.error().message.find("Failed to parse protobuf Envelope") != std::string::npos);
}
TEST_CASE("MessageFramer: Concurrency and thread safety",
"[daemon][protocol][framing][concurrency]") {
auto framer = std::make_unique<MessageFramer>();
const int numThreads = 10;
const int messagesPerThread = 100;
std::atomic<int> successCount{0};
std::atomic<int> failCount{0};
std::vector<std::thread> threads;
for (int t = 0; t < numThreads; ++t) {
threads.emplace_back([&framer, t, &successCount, &failCount]() {
for (int i = 0; i < messagesPerThread; ++i) {
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = t * 1000 + i;
msg.payload = StatusRequest{i % 2 == 0};
auto framedResult = framer->frame_message(msg);
if (framedResult) {
auto parsedResult = framer->parse_frame(framedResult.value());
if (parsedResult && parsedResult.value().requestId == msg.requestId) {
successCount++;
} else {
failCount++;
}
} else {
failCount++;
}
}
});
}
for (auto& t : threads) {
t.join();
}
REQUIRE(successCount == numThreads * messagesPerThread);
REQUIRE(failCount == 0);
}
TEST_CASE("MessageFramer: All message types", "[daemon][protocol][framing][types]") {
auto framer = std::make_unique<MessageFramer>();
struct TestCase {
std::string name;
std::variant<Request, Response> payload;
};
auto testCase = GENERATE(table<std::string, std::variant<Request, Response>>({
{"PingRequest", std::variant<Request, Response>{PingRequest{}}},
{"StatusRequest", std::variant<Request, Response>{StatusRequest{true}}},
{"ShutdownRequest", std::variant<Request, Response>{ShutdownRequest{false}}},
{"SearchRequest", std::variant<Request, Response>{SearchRequest{
.query = "test", .limit = 10, .searchType = "keyword"}}},
{"GenerateEmbeddingRequest",
std::variant<Request, Response>{GenerateEmbeddingRequest{"text", "model"}}},
{"LoadModelRequest", std::variant<Request, Response>{LoadModelRequest{"test-model"}}},
{"PongResponse", std::variant<Request, Response>{PongResponse{
std::chrono::steady_clock::now(), std::chrono::milliseconds{0}}}},
}));
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 444;
msg.payload = std::get<1>(testCase);
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
REQUIRE(parsedResult.value().requestId == 444);
}
// =============================================================================
// Protocol Serialization Tests (DownloadRequest/Response)
// =============================================================================
TEST_CASE("DownloadProtocol: Request serialization", "[daemon][protocol][download]") {
auto framer = std::make_unique<MessageFramer>();
SECTION("Full DownloadRequest roundtrip") {
Message msg;
msg.version = PROTOCOL_VERSION;
msg.requestId = 100;
msg.timestamp = std::chrono::steady_clock::now();
DownloadRequest req;
req.url = "https://example.com/file.txt";
req.outputPath = "/tmp/output.txt";
req.checksum = "sha256:abcdef0123456789";
req.tags = {"tag1", "tag2", "tag3"};
req.metadata = {{"key1", "value1"}, {"key2", "value2"}};
req.quiet = true;
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto& parsedMsg = parsedResult.value();
REQUIRE(std::holds_alternative<Request>(parsedMsg.payload));
auto* downloadReq = std::get_if<DownloadRequest>(&std::get<Request>(parsedMsg.payload));
REQUIRE(downloadReq != nullptr);
REQUIRE(downloadReq->url == req.url);
REQUIRE(downloadReq->outputPath == req.outputPath);
REQUIRE(downloadReq->checksum == req.checksum);
REQUIRE(downloadReq->tags == req.tags);
REQUIRE(downloadReq->metadata == req.metadata);
REQUIRE(downloadReq->quiet == req.quiet);
}
SECTION("Special characters and Unicode") {
Message msg{PROTOCOL_VERSION, 104, std::chrono::steady_clock::now(), Request{}};
DownloadRequest req;
req.url = "https://example.com/file with spaces & special=chars?param=value#anchor";
req.outputPath = "/path/with spaces/and-特殊文字/file.txt";
req.checksum = "sha512:ABCDEF123456";
req.tags = {"tag with spaces", "tag/with/slashes", "tag&special"};
req.metadata = {{"key with spaces", "value\nwith\nnewlines"}, {"特殊", "文字"}};
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* downloadReq =
std::get_if<DownloadRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(downloadReq != nullptr);
REQUIRE(downloadReq->url == req.url);
REQUIRE(downloadReq->outputPath == req.outputPath);
REQUIRE(downloadReq->checksum == req.checksum);
REQUIRE(downloadReq->tags == req.tags);
REQUIRE(downloadReq->metadata == req.metadata);
}
SECTION("Large metadata and tags") {
Message msg{PROTOCOL_VERSION, 105, std::chrono::steady_clock::now(), Request{}};
DownloadRequest req;
req.url = "https://example.com/large";
for (int i = 0; i < 100; ++i) {
req.metadata["key" + std::to_string(i)] = "value" + std::to_string(i);
}
for (int i = 0; i < 50; ++i) {
req.tags.push_back("tag" + std::to_string(i));
}
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* downloadReq =
std::get_if<DownloadRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(downloadReq != nullptr);
REQUIRE(downloadReq->metadata.size() == 100);
REQUIRE(downloadReq->tags.size() == 50);
}
SECTION("DownloadStatusRequest roundtrip") {
Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}};
DownloadStatusRequest req;
req.jobId = "download-17";
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* statusReq =
std::get_if<DownloadStatusRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(statusReq != nullptr);
REQUIRE(statusReq->jobId == req.jobId);
}
SECTION("CancelDownloadJobRequest roundtrip") {
Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}};
CancelDownloadJobRequest req;
req.jobId = "download-18";
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* cancelReq =
std::get_if<CancelDownloadJobRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(cancelReq != nullptr);
REQUIRE(cancelReq->jobId == req.jobId);
}
SECTION("ListDownloadJobsRequest roundtrip") {
Message msg{PROTOCOL_VERSION, 107, std::chrono::steady_clock::now(), Request{}};
ListDownloadJobsRequest req;
req.limit = 7;
msg.payload = Request{req};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* listReq =
std::get_if<ListDownloadJobsRequest>(&std::get<Request>(parsedResult.value().payload));
REQUIRE(listReq != nullptr);
REQUIRE(listReq->limit == req.limit);
}
}
TEST_CASE("DownloadProtocol: Response serialization", "[daemon][protocol][download]") {
auto framer = std::make_unique<MessageFramer>();
SECTION("Success response") {
Message msg{PROTOCOL_VERSION, 101, std::chrono::steady_clock::now(), Response{}};
DownloadResponse res;
res.hash = "sha256:abcdef1234567890";
res.localPath = "/tmp/downloaded_file.txt";
res.url = "https://example.com/file.txt";
res.jobId = "download-42";
res.state = "completed";
res.createdAtMs = 1111;
res.updatedAtMs = 2222;
res.size = 12345;
res.success = true;
res.error = "";
msg.payload = Response{res};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* downloadRes =
std::get_if<DownloadResponse>(&std::get<Response>(parsedResult.value().payload));
REQUIRE(downloadRes != nullptr);
REQUIRE(downloadRes->hash == res.hash);
REQUIRE(downloadRes->localPath == res.localPath);
REQUIRE(downloadRes->url == res.url);
REQUIRE(downloadRes->jobId == res.jobId);
REQUIRE(downloadRes->state == res.state);
REQUIRE(downloadRes->createdAtMs == res.createdAtMs);
REQUIRE(downloadRes->updatedAtMs == res.updatedAtMs);
REQUIRE(downloadRes->size == res.size);
REQUIRE(downloadRes->success == true);
}
SECTION("Error response") {
Message msg{PROTOCOL_VERSION, 102, std::chrono::steady_clock::now(), Response{}};
DownloadResponse res;
res.hash = "";
res.localPath = "";
res.url = "https://example.com/nonexistent.txt";
res.jobId = "download-43";
res.state = "failed";
res.createdAtMs = 3333;
res.updatedAtMs = 4444;
res.size = 0;
res.success = false;
res.error = "404 Not Found";
msg.payload = Response{res};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* downloadRes =
std::get_if<DownloadResponse>(&std::get<Response>(parsedResult.value().payload));
REQUIRE(downloadRes != nullptr);
REQUIRE_FALSE(downloadRes->success);
REQUIRE(downloadRes->error == "404 Not Found");
REQUIRE(downloadRes->size == 0);
}
SECTION("ListDownloadJobsResponse roundtrip") {
Message msg{PROTOCOL_VERSION, 103, std::chrono::steady_clock::now(), Response{}};
ListDownloadJobsResponse res;
DownloadResponse first;
first.url = "https://example.com/a";
first.jobId = "download-1";
first.state = "completed";
first.createdAtMs = 100;
first.updatedAtMs = 200;
first.success = true;
DownloadResponse second;
second.url = "https://example.com/b";
second.jobId = "download-2";
second.state = "failed";
second.createdAtMs = 300;
second.updatedAtMs = 400;
second.success = false;
second.error = "boom";
res.jobs = {first, second};
msg.payload = Response{res};
auto framedResult = framer->frame_message(msg);
REQUIRE(framedResult);
auto parsedResult = framer->parse_frame(framedResult.value());
REQUIRE(parsedResult);
auto* listRes = std::get_if<ListDownloadJobsResponse>(
&std::get<Response>(parsedResult.value().payload));
REQUIRE(listRes != nullptr);
REQUIRE(listRes->jobs.size() == 2);
REQUIRE(listRes->jobs.at(0).jobId == "download-1");
REQUIRE(listRes->jobs.at(1).error == "boom");
}
}
// =============================================================================
// ProtoSerializer Roundtrip Tests
// =============================================================================
TEST_CASE("GraphExploreRequest appends optional binary fields",
"[daemon][protocol][serialization][compatibility]") {
GraphExploreRequest request;
request.query = "query";
request.scopePathPrefix = "/workspace/yams";
RecordingRequestSerializer serializer;
request.serialize(serializer);
CHECK((serializer.fieldOrder == "suuuuubbbbs"));
REQUIRE((serializer.strings.size() == 2));
CHECK((serializer.strings.front() == "query"));
CHECK((serializer.strings.back() == "/workspace/yams"));
LegacyGraphExploreDeserializer legacy;
auto decoded = GraphExploreRequest::deserialize(legacy);
REQUIRE(decoded.has_value());
CHECK((decoded.value().query == "legacy-query"));
CHECK(decoded.value().scopePathPrefix.empty());
CHECK((decoded.value().maxFiles == 4));
CHECK((decoded.value().maxSymbols == 12));
CHECK((decoded.value().maxTotalChars == 8000));
CHECK((decoded.value().maxCharsPerFile == 2000));
CHECK((decoded.value().maxSnippetLines == 80));
CHECK(decoded.value().includeLineNumbers);
CHECK_FALSE(decoded.value().includeRelationships);
CHECK(decoded.value().includeCode);
CHECK_FALSE(decoded.value().includeTests);
}
TEST_CASE("GraphImpactRequest encodes repository scope", "[daemon][protocol][serialization]") {
GraphImpactRequest request;
request.symbol = "RequestHandler";
request.scopePathPrefix = "/workspace/yams";
RecordingRequestSerializer serializer;
request.serialize(serializer);
CHECK((serializer.fieldOrder == "suus"));
REQUIRE((serializer.strings.size() == 2));
CHECK((serializer.strings.front() == "RequestHandler"));
CHECK((serializer.strings.back() == "/workspace/yams"));
}
TEST_CASE("ProtoSerializer: Request roundtrip", "[daemon][protocol][serialization]") {
SECTION("CatRequest") {
CatRequest cr;
cr.hash = "";
cr.name = "inline.txt";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{cr}, 1));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<CatRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->name == "inline.txt");
}
SECTION("ListSessionsRequest") {
auto enc =
ProtoSerializer::encode_payload(makeMessageWith(Request{ListSessionsRequest{}}, 2));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto req = std::get<Request>(dec.value().payload);
REQUIRE(std::holds_alternative<ListSessionsRequest>(req));
}
SECTION("SearchRequest carries session") {
SearchRequest sr;
sr.query = "hello";
sr.useSession = true;
sr.sessionName = "feature-auth";
sr.instanceId = "inst-abc-123";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{sr}, 10));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<SearchRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->useSession);
REQUIRE(got->sessionName == "feature-auth");
REQUIRE(got->instanceId == "inst-abc-123");
}
SECTION("GrepRequest carries session") {
GrepRequest gr;
gr.pattern = "TODO";
gr.useSession = true;
gr.sessionName = "feature-auth";
gr.instanceId = "inst-grep-456";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{gr}, 11));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<GrepRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->useSession);
REQUIRE(got->sessionName == "feature-auth");
REQUIRE(got->instanceId == "inst-grep-456");
}
SECTION("AddDocumentRequest carries session") {
AddDocumentRequest ar;
ar.path = "/tmp/a.txt";
ar.sessionId = "feature-auth";
ar.instanceId = "inst-add-789";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{ar}, 12));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<AddDocumentRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->sessionId == "feature-auth");
REQUIRE(got->instanceId == "inst-add-789");
}
SECTION("ListRequest carries session") {
ListRequest lr;
lr.limit = 5;
lr.sessionId = "feature-auth";
lr.instanceId = "inst-list-012";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{lr}, 13));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<ListRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->sessionId == "feature-auth");
REQUIRE(got->instanceId == "inst-list-012");
}
SECTION("BatchEmbeddingRequest sanitizes invalid UTF-8") {
BatchEmbeddingRequest req;
req.texts = {"hello", std::string("\xFF\xFE", 2) + "broken"};
req.modelName = "all-MiniLM-L6-v2";
req.normalize = true;
req.batchSize = 8;
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 14));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<BatchEmbeddingRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->texts.size() == req.texts.size());
CHECK(got->texts[0] == yams::common::sanitizeUtf8(req.texts[0]));
CHECK(got->texts[1] == yams::common::sanitizeUtf8(req.texts[1]));
}
SECTION("GetRequest sanitizes invalid UTF-8 text fields") {
GetRequest req;
req.hash = invalidUtf8("sha256:");
req.name = invalidUtf8("notes", ".md");
req.fileType = invalidUtf8("text");
req.mimeType = invalidUtf8("text/plain");
req.extension = invalidUtf8(".md");
req.createdAfter = invalidUtf8("2025-01-01");
req.modifiedBefore = invalidUtf8("2025-12-31");
req.indexedAfter = invalidUtf8("yesterday");
req.outputPath = invalidUtf8("/tmp/out", ".txt");
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 15));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<GetRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("GetRequest.hash", got->hash, req.hash);
checkSanitizedField("GetRequest.name", got->name, req.name);
checkSanitizedField("GetRequest.fileType", got->fileType, req.fileType);
checkSanitizedField("GetRequest.mimeType", got->mimeType, req.mimeType);
checkSanitizedField("GetRequest.extension", got->extension, req.extension);
checkSanitizedField("GetRequest.createdAfter", got->createdAfter, req.createdAfter);
checkSanitizedField("GetRequest.modifiedBefore", got->modifiedBefore, req.modifiedBefore);
checkSanitizedField("GetRequest.indexedAfter", got->indexedAfter, req.indexedAfter);
checkSanitizedField("GetRequest.outputPath", got->outputPath, req.outputPath);
}
SECTION("GetRequest preserves content selection flags") {
GetRequest req;
req.hash = "sha256:abc123";
req.metadataOnly = false;
req.includeContent = false;
req.maxBytes = 4096;
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<GetRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
CHECK(got->hash == req.hash);
CHECK(got->metadataOnly == req.metadataOnly);
CHECK(got->includeContent == req.includeContent);
CHECK(got->maxBytes == req.maxBytes);
}
SECTION("UpdateDocumentResponse preserves detailed update fields") {
UpdateDocumentResponse resp;
resp.hash = "sha256:update123";
resp.contentUpdated = false;
resp.metadataUpdated = true;
resp.tagsUpdated = true;
resp.updatesApplied = 2;
resp.tagsAdded = 1;
resp.tagsRemoved = 1;
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 16));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<UpdateDocumentResponse>(&std::get<Response>(dec.value().payload));
REQUIRE(got != nullptr);
CHECK(got->hash == resp.hash);
CHECK(got->contentUpdated == resp.contentUpdated);
CHECK(got->metadataUpdated == resp.metadataUpdated);
CHECK(got->tagsUpdated == resp.tagsUpdated);
CHECK(got->updatesApplied == resp.updatesApplied);
CHECK(got->tagsAdded == resp.tagsAdded);
CHECK(got->tagsRemoved == resp.tagsRemoved);
}
SECTION("ListRequest sanitizes invalid UTF-8 filter and session fields") {
ListRequest req;
req.format = invalidUtf8("table");
req.sortBy = invalidUtf8("date");
req.fileType = invalidUtf8("text");
req.mimeType = invalidUtf8("text/plain");
req.extensions = invalidUtf8("md,txt");
req.createdAfter = invalidUtf8("2025-01-01");
req.changeWindow = invalidUtf8("24h");
req.tags = {invalidUtf8("task=perf"), "owner=opencode"};
req.filterTags = invalidUtf8("phase=checkpoint");
req.namePattern = invalidUtf8("*.md");
req.metadataFilters = {{invalidUtf8("task"), invalidUtf8("performance")},
{"owner", invalidUtf8("opencode")}};
req.sessionId = invalidUtf8("feature-auth");
req.instanceId = invalidUtf8("inst-list");
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<ListRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("ListRequest.format", got->format, req.format);
checkSanitizedField("ListRequest.sortBy", got->sortBy, req.sortBy);
checkSanitizedField("ListRequest.fileType", got->fileType, req.fileType);
checkSanitizedField("ListRequest.mimeType", got->mimeType, req.mimeType);
checkSanitizedField("ListRequest.extensions", got->extensions, req.extensions);
checkSanitizedField("ListRequest.createdAfter", got->createdAfter, req.createdAfter);
checkSanitizedField("ListRequest.changeWindow", got->changeWindow, req.changeWindow);
REQUIRE(got->tags.size() == req.tags.size());
checkSanitizedField("ListRequest.tags[0]", got->tags[0], req.tags[0]);
checkSanitizedField("ListRequest.filterTags", got->filterTags, req.filterTags);
checkSanitizedField("ListRequest.namePattern", got->namePattern, req.namePattern);
checkSanitizedField(
"ListRequest.metadataFilters.task",
got->metadataFilters.at(yams::common::sanitizeUtf8(req.metadataFilters.begin()->first)),
req.metadataFilters.begin()->second);
checkSanitizedField("ListRequest.sessionId", got->sessionId, req.sessionId);
checkSanitizedField("ListRequest.instanceId", got->instanceId, req.instanceId);
}
SECTION("AddDocumentRequest sanitizes invalid UTF-8 metadata and routing fields") {
AddDocumentRequest req;
req.path = invalidUtf8("/tmp/doc", ".md");
req.name = invalidUtf8("doc", ".md");
req.tags = {invalidUtf8("tag-a"), "tag-b"};
req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
req.includePatterns = {invalidUtf8("*.md")};
req.excludePatterns = {invalidUtf8("*.bin")};
req.collection = invalidUtf8("notes");
req.snapshotId = invalidUtf8("snap-123");
req.snapshotLabel = invalidUtf8("checkpoint");
req.sessionId = invalidUtf8("session-a");
req.instanceId = invalidUtf8("instance-a");
req.mimeType = invalidUtf8("text/plain");
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 17));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<AddDocumentRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("AddDocumentRequest.path", got->path, req.path);
checkSanitizedField("AddDocumentRequest.name", got->name, req.name);
checkSanitizedField("AddDocumentRequest.tags[0]", got->tags.at(0), req.tags.at(0));
checkSanitizedField("AddDocumentRequest.collection", got->collection, req.collection);
checkSanitizedField("AddDocumentRequest.snapshotId", got->snapshotId, req.snapshotId);
checkSanitizedField("AddDocumentRequest.snapshotLabel", got->snapshotLabel,
req.snapshotLabel);
checkSanitizedField("AddDocumentRequest.sessionId", got->sessionId, req.sessionId);
checkSanitizedField("AddDocumentRequest.instanceId", got->instanceId, req.instanceId);
checkSanitizedField("AddDocumentRequest.mimeType", got->mimeType, req.mimeType);
}
SECTION("GrepRequest sanitizes invalid UTF-8 filter and session fields") {
GrepRequest req;
req.pattern = invalidUtf8("task=performance");
req.path = invalidUtf8("/tmp");
req.paths = {invalidUtf8("/repo/src")};
req.includePatterns = {invalidUtf8("*.cpp")};
req.filterTags = {invalidUtf8("owner=opencode")};
req.colorMode = invalidUtf8("auto");
req.useSession = true;
req.sessionName = invalidUtf8("session-grep");
req.instanceId = invalidUtf8("instance-grep");
req.scopePathPrefix = invalidUtf8("/repo");
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 18));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<GrepRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("GrepRequest.pattern", got->pattern, req.pattern);
checkSanitizedField("GrepRequest.path", got->path, req.path);
checkSanitizedField("GrepRequest.paths[0]", got->paths.at(0), req.paths.at(0));
checkSanitizedField("GrepRequest.includePatterns[0]", got->includePatterns.at(0),
req.includePatterns.at(0));
checkSanitizedField("GrepRequest.filterTags[0]", got->filterTags.at(0),
req.filterTags.at(0));
checkSanitizedField("GrepRequest.colorMode", got->colorMode, req.colorMode);
checkSanitizedField("GrepRequest.sessionName", got->sessionName, req.sessionName);
checkSanitizedField("GrepRequest.instanceId", got->instanceId, req.instanceId);
checkSanitizedField("GrepRequest.scopePathPrefix", got->scopePathPrefix,
req.scopePathPrefix);
}
SECTION("DownloadRequest sanitizes invalid UTF-8 text fields") {
DownloadRequest req;
req.url = invalidUtf8("https://example.com/file");
req.outputPath = invalidUtf8("/tmp/download", ".txt");
req.checksum = invalidUtf8("sha256:", "deadbeef");
req.tags = {invalidUtf8("tag-download")};
req.metadata = {{invalidUtf8("source"), invalidUtf8("cli")}};
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 19));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<DownloadRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("DownloadRequest.url", got->url, req.url);
checkSanitizedField("DownloadRequest.outputPath", got->outputPath, req.outputPath);
checkSanitizedField("DownloadRequest.checksum", got->checksum, req.checksum);
checkSanitizedField("DownloadRequest.tags[0]", got->tags.at(0), req.tags.at(0));
}
SECTION("UpdateDocumentRequest sanitizes invalid UTF-8 text fields") {
UpdateDocumentRequest req;
req.hash = invalidUtf8("sha256:");
req.name = invalidUtf8("doc", ".md");
req.newContent = invalidUtf8("updated content");
req.addTags = {invalidUtf8("tag-add")};
req.removeTags = {invalidUtf8("tag-remove")};
req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 23));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
auto* got = std::get_if<UpdateDocumentRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
checkSanitizedField("UpdateDocumentRequest.hash", got->hash, req.hash);
checkSanitizedField("UpdateDocumentRequest.name", got->name, req.name);
checkSanitizedField("UpdateDocumentRequest.newContent", got->newContent, req.newContent);
checkSanitizedField("UpdateDocumentRequest.addTags[0]", got->addTags.at(0),
req.addTags.at(0));
checkSanitizedField("UpdateDocumentRequest.removeTags[0]", got->removeTags.at(0),
req.removeTags.at(0));
}
SECTION("GraphExploreRequest roundtrips budgets and flags") {
GraphExploreRequest req;
req.query = "processTask";
req.maxFiles = 3;
req.maxSymbols = 9;
req.maxTotalChars = 12345;
req.maxCharsPerFile = 4096;
req.maxSnippetLines = 42;
req.includeLineNumbers = true;
req.includeRelationships = false;
req.includeCode = false;
req.includeTests = true;
req.scopePathPrefix = "/workspace/yams";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 24));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<GraphExploreRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
CHECK(got->query == req.query);
CHECK(got->maxFiles == 3);
CHECK(got->maxSymbols == 9);
CHECK(got->maxTotalChars == 12345);
CHECK(got->maxCharsPerFile == 4096);
CHECK(got->maxSnippetLines == 42);
CHECK(got->includeLineNumbers);
CHECK_FALSE(got->includeRelationships);
CHECK_FALSE(got->includeCode);
CHECK(got->includeTests);
CHECK(got->scopePathPrefix == "/workspace/yams");
}
SECTION("GraphImpactRequest roundtrips repository scope") {
GraphImpactRequest req;
req.symbol = "RequestHandler";
req.depth = 3;
req.maxSymbols = 17;
req.scopePathPrefix = "/workspace/yams";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 25));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<GraphImpactRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
CHECK(got->symbol == req.symbol);
CHECK(got->depth == 3);
CHECK(got->maxSymbols == 17);
CHECK(got->scopePathPrefix == "/workspace/yams");
}
SECTION("GraphQueryRequest roundtrips list scope") {
GraphQueryRequest req;
req.listByType = true;
req.nodeType = "function";
req.scopePathPrefix = "/workspace/yams";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 26));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
auto* got = std::get_if<GraphQueryRequest>(&std::get<Request>(dec.value().payload));
REQUIRE(got != nullptr);
CHECK(got->listByType);
CHECK(got->nodeType == "function");
CHECK(got->scopePathPrefix == "/workspace/yams");
}
}
TEST_CASE("ProtoSerializer: Response roundtrip", "[daemon][protocol][serialization]") {
DownloadResponse dr;
dr.hash = "deadbeef";
dr.localPath = "/tmp/file";
dr.url = "https://example.com";
dr.jobId = "download-9";
dr.state = "completed";
dr.createdAtMs = 123;
dr.updatedAtMs = 456;
dr.size = 1234;
dr.success = true;
dr.error = "";
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{dr}, 3));
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
REQUIRE(std::holds_alternative<Response>(dec.value().payload));
auto* got = std::get_if<DownloadResponse>(&std::get<Response>(dec.value().payload));
REQUIRE(got != nullptr);
REQUIRE(got->hash == "deadbeef");
REQUIRE(got->jobId == "download-9");
REQUIRE(got->state == "completed");
REQUIRE(got->createdAtMs == 123);
REQUIRE(got->updatedAtMs == 456);
REQUIRE(got->size == 1234);
REQUIRE(got->success == true);
SECTION("GraphQueryResponse includes edges") {
GraphQueryResponse gr;
gr.originNode.nodeId = 1;
gr.originNode.nodeKey = "path:file:/root/a.cpp";
gr.originNode.label = "a.cpp";
gr.originNode.type = "file";
GraphNode cn;
cn.nodeId = 2;
cn.nodeKey = "symbol:foo";
cn.label = "foo";
cn.type = "function";
cn.distance = 1;
gr.connectedNodes.push_back(cn);
GraphEdge edge;
edge.edgeId = 42;
edge.srcNodeId = 1;
edge.dstNodeId = 2;
edge.relation = "defines";
edge.weight = 0.9f;
edge.properties = "{\"source\":\"test\"}";
gr.edges.push_back(edge);
gr.totalNodesFound = 1;
gr.totalEdgesTraversed = 1;
gr.kgAvailable = true;
auto encGraph = ProtoSerializer::encode_payload(
makeMessageWith(Response{std::in_place_type<GraphQueryResponse>, gr}, 4));
REQUIRE(encGraph);
auto decGraph = ProtoSerializer::decode_payload(encGraph.value());
REQUIRE(decGraph);
REQUIRE(std::holds_alternative<Response>(decGraph.value().payload));
auto* gotGraph =
std::get_if<GraphQueryResponse>(&std::get<Response>(decGraph.value().payload));
REQUIRE(gotGraph != nullptr);
REQUIRE(gotGraph->edges.size() == 1);
CHECK(gotGraph->edges[0].edgeId == 42);
CHECK(gotGraph->edges[0].relation == "defines");
CHECK(gotGraph->edges[0].properties == "{\"source\":\"test\"}");
}
SECTION("GraphExploreResponse roundtrips snippets and relationships") {
GraphExploreResponse response;
response.query = "processTask";
response.totalSymbolsConsidered = 2;
response.totalFilesConsidered = 1;
response.emittedChars = 64;
response.snippetRenderMicros = 123;
response.snippetsRendered = 1;
response.kgAvailable = true;
response.truncated = true;
response.warnings = {"budget reached"};
GraphExploreSymbol symbol;
symbol.nodeKey = "function:demo::processTask@/repo/src/process.cpp";
symbol.label = "processTask";
symbol.qualifiedName = "demo::processTask";
symbol.kind = "function";
symbol.filePath = "/repo/src/process.cpp";
symbol.startLine = 10;
symbol.endLine = 12;
symbol.score = 98.5;
symbol.exactMatch = true;
response.entrySymbols.push_back(symbol);
GraphExploreSnippet snippet;
snippet.filePath = symbol.filePath;
snippet.language = "cpp";
snippet.mode = "full";
snippet.startLine = 10;
snippet.endLine = 12;
snippet.heading = "process.cpp";
snippet.content = "10\tint processTask() {}\n";
snippet.symbols.push_back(symbol);
snippet.truncated = false;
response.files.push_back(snippet);
GraphExploreRelation relation;
relation.relation = "calls";
relation.sourceNodeKey = symbol.nodeKey;
relation.sourceLabel = "processTask";
relation.targetNodeKey = "function:demo::helper@/repo/src/process.cpp";
relation.targetLabel = "helper";
relation.weight = 0.75F;
relation.confidence = 0.75;
response.relationships.push_back(relation);
auto encGraph = ProtoSerializer::encode_payload(
makeMessageWith(Response{std::in_place_type<GraphExploreResponse>, response}, 5));
REQUIRE(encGraph);
auto decGraph = ProtoSerializer::decode_payload(encGraph.value());
REQUIRE(decGraph);
REQUIRE(std::holds_alternative<Response>(decGraph.value().payload));
auto* gotGraph =
std::get_if<GraphExploreResponse>(&std::get<Response>(decGraph.value().payload));
REQUIRE(gotGraph != nullptr);
CHECK(gotGraph->query == "processTask");
REQUIRE(gotGraph->entrySymbols.size() == 1);
CHECK(gotGraph->entrySymbols[0].startLine == 10);
REQUIRE(gotGraph->files.size() == 1);
CHECK(gotGraph->files[0].content.find("processTask") != std::string::npos);
REQUIRE(gotGraph->relationships.size() == 1);
CHECK(gotGraph->relationships[0].relation == "calls");
CHECK(gotGraph->warnings.at(0) == "budget reached");
CHECK(gotGraph->snippetRenderMicros == 123);
CHECK(gotGraph->snippetsRendered == 1);
CHECK(gotGraph->truncated);
}
SECTION("GraphSymbolLookupResponse roundtrips snippet render metrics") {
GraphSymbolLookupResponse response;
response.symbol = "processTask";
response.snippetRenderMicros = 456;
response.snippetsRendered = 2;
auto encoded = ProtoSerializer::encode_payload(
makeMessageWith(Response{std::in_place_type<GraphSymbolLookupResponse>, response}, 6));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
REQUIRE(std::holds_alternative<Response>(decoded.value().payload));
auto* got =
std::get_if<GraphSymbolLookupResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(got != nullptr);
CHECK(got->snippetRenderMicros == 456);
CHECK(got->snippetsRendered == 2);
}
SECTION("GraphTraceResponse roundtrips snippet render metrics") {
GraphTraceResponse response;
response.from = "processTask";
response.to = "helper";
response.snippetRenderMicros = 789;
response.snippetsRendered = 3;
auto encoded = ProtoSerializer::encode_payload(
makeMessageWith(Response{std::in_place_type<GraphTraceResponse>, response}, 7));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
REQUIRE(std::holds_alternative<Response>(decoded.value().payload));
auto* got = std::get_if<GraphTraceResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(got != nullptr);
CHECK(got->snippetRenderMicros == 789);
CHECK(got->snippetsRendered == 3);
}
SECTION("ErrorResponse sanitizes invalid UTF-8 message") {
ErrorResponse err;
err.code = ErrorCode::InvalidData;
err.message = invalidUtf8("bad-search-query");
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{err}, 20));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* decodedError =
std::get_if<ErrorResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(decodedError != nullptr);
checkSanitizedField("ErrorResponse.message", decodedError->message, err.message);
}
SECTION("RepairEvent sanitizes invalid UTF-8 progress text") {
RepairEvent ev;
ev.phase = invalidUtf8("repairing");
ev.operation = invalidUtf8("fts5");
ev.message = invalidUtf8("rebuilding-index");
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{ev}, 21));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* decodedEvent = std::get_if<RepairEvent>(&std::get<Response>(decoded.value().payload));
REQUIRE(decodedEvent != nullptr);
checkSanitizedField("RepairEvent.phase", decodedEvent->phase, ev.phase);
checkSanitizedField("RepairEvent.operation", decodedEvent->operation, ev.operation);
checkSanitizedField("RepairEvent.message", decodedEvent->message, ev.message);
}
SECTION("RepairResponse sanitizes invalid UTF-8 errors and operation messages") {
RepairResponse resp;
resp.success = false;
resp.errors = {invalidUtf8("fts5 failed"), invalidUtf8("mime failed")};
RepairOperationResult op;
op.operation = invalidUtf8("embeddings");
op.message = invalidUtf8("bad utf8 in batch");
resp.operationResults.push_back(op);
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 22));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* decodedResponse =
std::get_if<RepairResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(decodedResponse != nullptr);
REQUIRE(decodedResponse->errors.size() == resp.errors.size());
checkSanitizedField("RepairResponse.errors[0]", decodedResponse->errors.at(0),
resp.errors.at(0));
REQUIRE(decodedResponse->operationResults.size() == 1);
checkSanitizedField("RepairResponse.operationResults[0].operation",
decodedResponse->operationResults.at(0).operation,
resp.operationResults.at(0).operation);
checkSanitizedField("RepairResponse.operationResults[0].message",
decodedResponse->operationResults.at(0).message,
resp.operationResults.at(0).message);
}
SECTION("GetResponse sanitizes invalid UTF-8 descriptor fields without content") {
GetResponse resp;
resp.hash = invalidUtf8("sha256:");
resp.name = invalidUtf8("doc", ".md");
resp.path = invalidUtf8("/tmp/doc", ".md");
resp.hasContent = false;
resp.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 24));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* decodedResponse =
std::get_if<GetResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(decodedResponse != nullptr);
checkSanitizedField("GetResponse.hash", decodedResponse->hash, resp.hash);
checkSanitizedField("GetResponse.name", decodedResponse->name, resp.name);
checkSanitizedField("GetResponse.path", decodedResponse->path, resp.path);
REQUIRE_FALSE(decodedResponse->hasContent);
}
}
TEST_CASE("GetRequest: graph depth and limit survive the wire", "[daemon][protocol][graph]") {
// #280 follow-up: `yams graph --limit` needs a GetRequest field (graph_limit = 27).
GetRequest req;
req.name = "/corpus/simeon/include/simeon/bm25.hpp";
req.byName = true;
req.metadataOnly = true;
req.showGraph = true;
req.graphDepth = 2;
req.graphLimit = 3;
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 26));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* out = std::get_if<GetRequest>(&std::get<Request>(decoded.value().payload));
REQUIRE(out != nullptr);
CHECK(out->showGraph);
CHECK(out->graphDepth == 2);
CHECK(out->graphLimit == 3);
GetRequest unset;
unset.hash = "12be4dc5";
auto unsetEncoded = ProtoSerializer::encode_payload(makeMessageWith(Request{unset}, 27));
REQUIRE(unsetEncoded);
auto unsetDecoded = ProtoSerializer::decode_payload(unsetEncoded.value());
REQUIRE(unsetDecoded);
auto* unsetOut = std::get_if<GetRequest>(&std::get<Request>(unsetDecoded.value().payload));
REQUIRE(unsetOut != nullptr);
CHECK(unsetOut->graphLimit == 0);
}
TEST_CASE("GetResponse: document graph fields survive the wire", "[daemon][protocol][graph]") {
// Issue #280: `yams graph <hash>` / `--name` reported graphEnabled=false with no related
// documents because the proto binding dropped the graph fields and the file name.
GetResponse resp;
resp.hash = "12be4dc5f2d4a975852a09acce9f1ab303ee8138c47c13283f094001840ca86c";
resp.name = "bm25.hpp";
resp.fileName = "bm25.hpp";
resp.path = "/corpus/simeon/include/simeon/bm25.hpp";
resp.hasContent = false;
resp.graphEnabled = true;
resp.related.push_back(RelatedDocumentEntry{.hash = "559cd590",
.path = "/corpus/simeon/include/query_router.hpp",
.name = "query_router.hpp",
.relationship = "semantic_neighbor",
.distance = 1,
.relevanceScore = 0.85});
resp.related.push_back(RelatedDocumentEntry{.hash = "fd6d8e75",
.path = "/corpus/simeon/src/tokenizer.cpp",
.name = "tokenizer.cpp",
.relationship = "semantic_neighbor",
.distance = 2,
.relevanceScore = 0.0});
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 25));
REQUIRE(encoded);
auto decoded = ProtoSerializer::decode_payload(encoded.value());
REQUIRE(decoded);
auto* out = std::get_if<GetResponse>(&std::get<Response>(decoded.value().payload));
REQUIRE(out != nullptr);
CHECK(out->hash == resp.hash);
CHECK(out->fileName == resp.fileName);
CHECK(out->graphEnabled);
REQUIRE(out->related.size() == resp.related.size());
for (std::size_t i = 0; i < resp.related.size(); ++i) {
CHECK(out->related[i].hash == resp.related[i].hash);
CHECK(out->related[i].path == resp.related[i].path);
CHECK(out->related[i].name == resp.related[i].name);
CHECK(out->related[i].relationship == resp.related[i].relationship);
CHECK(out->related[i].distance == resp.related[i].distance);
CHECK(out->related[i].relevanceScore == Catch::Approx(resp.related[i].relevanceScore));
}
}
TEST_CASE("GrepResponse: Serialization edge cases", "[daemon][protocol][grep]") {
SECTION("Paths-only mode (file only, no line)") {
GrepResponse in{};
GrepMatch m{};
std::string candidate = "/tmp/test/README.md";
m.file = candidate;
m.line = "";
in.matches.push_back(m);
Message msg{};
msg.payload = Response{std::in_place_type<GrepResponse>, in};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
const auto& resp = std::get<Response>(dec.value().payload);
REQUIRE(std::holds_alternative<GrepResponse>(resp));
const GrepResponse& gr = std::get<GrepResponse>(resp);
REQUIRE(gr.matches.size() == 1);
REQUIRE(gr.matches[0].file == candidate);
REQUIRE(gr.matches[0].line.empty());
}
SECTION("Line match preserves content") {
GrepResponse in{};
GrepMatch m{};
m.file = "";
m.line = "hello world";
in.matches.push_back(m);
Message msg{};
msg.payload = Response{std::in_place_type<GrepResponse>, in};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
const GrepResponse& gr = std::get<GrepResponse>(std::get<Response>(dec.value().payload));
REQUIRE(gr.matches.size() == 1);
REQUIRE(gr.matches[0].line == "hello world");
REQUIRE(gr.matches[0].file.empty());
}
SECTION("Binary content preservation") {
GrepResponse in{};
GrepMatch m{};
m.file = "binary.bin";
m.line = std::string("\xFF\x00\x1B", 3);
m.contextBefore.push_back(std::string("\x01\x02", 2));
m.contextAfter.push_back(std::string("ok", 2));
in.matches.push_back(m);
Message msg{};
msg.payload = Response{std::in_place_type<GrepResponse>, in};
auto enc = ProtoSerializer::encode_payload(msg);
REQUIRE(enc);
auto dec = ProtoSerializer::decode_payload(enc.value());
REQUIRE(dec);
const GrepResponse& gr = std::get<GrepResponse>(std::get<Response>(dec.value().payload));
REQUIRE(gr.matches.size() == 1);
REQUIRE(gr.matches[0].line == std::string("\xFF\x00\x1B", 3));
REQUIRE(gr.matches[0].contextBefore.size() == 1);
REQUIRE(gr.matches[0].contextAfter.size() == 1);
}
}
// =============================================================================
// Type System Tests (ResponseOf, MessageType)
// =============================================================================
TEST_CASE("ResponseOf: Compile-time request→response mappings", "[daemon][protocol][types]") {
// Search operations
static_assert(std::is_same_v<ResponseOfT<SearchRequest>, SearchResponse>);
static_assert(std::is_same_v<ResponseOfT<AddDocumentRequest>, AddDocumentResponse>);
static_assert(std::is_same_v<ResponseOfT<GetRequest>, GetResponse>);
static_assert(std::is_same_v<ResponseOfT<DeleteRequest>, DeleteResponse>);
// System operations
static_assert(std::is_same_v<ResponseOfT<StatusRequest>, StatusResponse>);
static_assert(std::is_same_v<ResponseOfT<PingRequest>, PongResponse>);
// Model operations
static_assert(std::is_same_v<ResponseOfT<GenerateEmbeddingRequest>, EmbeddingResponse>);
static_assert(std::is_same_v<ResponseOfT<BatchEmbeddingRequest>, BatchEmbeddingResponse>);
static_assert(std::is_same_v<ResponseOfT<LoadModelRequest>, ModelLoadResponse>);
static_assert(std::is_same_v<ResponseOfT<UnloadModelRequest>, SuccessResponse>);
static_assert(std::is_same_v<ResponseOfT<ModelStatusRequest>, ModelStatusResponse>);
// Download and other operations
static_assert(std::is_same_v<ResponseOfT<DownloadRequest>, DownloadResponse>);
static_assert(std::is_same_v<ResponseOfT<GrepRequest>, GrepResponse>);
static_assert(std::is_same_v<ResponseOfT<UpdateDocumentRequest>, UpdateDocumentResponse>);
static_assert(std::is_same_v<ResponseOfT<GetStatsRequest>, GetStatsResponse>);
SUCCEED();
}
TEST_CASE("ResponseOf: Runtime usage with actual instances", "[daemon][protocol][types]") {
SearchRequest searchReq{.query = "test query", .limit = 10, .searchType = "keyword"};
using SearchResType = ResponseOfT<decltype(searchReq)>;
static_assert(std::is_same_v<SearchResType, SearchResponse>);
SearchResType searchRes;
searchRes.totalCount = 5;
REQUIRE(searchRes.totalCount == 5);
GenerateEmbeddingRequest embedReq{"sample text", "model"};
using EmbedResType = ResponseOfT<decltype(embedReq)>;
static_assert(std::is_same_v<EmbedResType, EmbeddingResponse>);
EmbedResType embedRes;
embedRes.dimensions = 384;
REQUIRE(embedRes.dimensions == 384);
}
TEST_CASE("MessageType: Request and response enum mappings", "[daemon][protocol][types]") {
SECTION("Request type mappings") {
REQUIRE(MessageType::CatRequest == getMessageType(Request{CatRequest{}}));
REQUIRE(MessageType::ListSessionsRequest == getMessageType(Request{ListSessionsRequest{}}));
REQUIRE(MessageType::DownloadStatusRequest ==
getMessageType(Request{DownloadStatusRequest{.jobId = "download-1"}}));
REQUIRE(MessageType::CancelDownloadJobRequest ==
getMessageType(Request{CancelDownloadJobRequest{.jobId = "download-2"}}));
REQUIRE(MessageType::ListDownloadJobsRequest ==
getMessageType(Request{ListDownloadJobsRequest{.limit = 5}}));
REQUIRE(MessageType::UseSessionRequest ==
getMessageType(Request{UseSessionRequest{.session_name = "s"}}));
REQUIRE(MessageType::AddPathSelectorRequest ==
getMessageType(Request{AddPathSelectorRequest{.session_name = "s", .path = "/p"}}));
REQUIRE(
MessageType::RemovePathSelectorRequest ==
getMessageType(Request{RemovePathSelectorRequest{.session_name = "s", .path = "/p"}}));
REQUIRE(MessageType::GraphExploreRequest ==
getMessageType(Request{GraphExploreRequest{.query = "processTask"}}));
}
SECTION("Response type mappings") {
REQUIRE(MessageType::CatResponse == getMessageType(Response{CatResponse{}}));
REQUIRE(MessageType::ListResponse == getMessageType(Response{ListResponse{}}));
REQUIRE(MessageType::StatusResponse == getMessageType(Response{StatusResponse{}}));
REQUIRE(MessageType::ListDownloadJobsResponse ==
getMessageType(Response{ListDownloadJobsResponse{}}));
REQUIRE(MessageType::GraphExploreResponse ==
getMessageType(Response{GraphExploreResponse{.query = "processTask"}}));
}
SECTION("Request name extraction") {
REQUIRE(std::string("Cat") == getRequestName(Request{CatRequest{}}));
REQUIRE(std::string("ListSessions") == getRequestName(Request{ListSessionsRequest{}}));
REQUIRE(std::string("DownloadStatus") ==
getRequestName(Request{DownloadStatusRequest{.jobId = "download-1"}}));
REQUIRE(std::string("GraphExplore") ==
getRequestName(Request{GraphExploreRequest{.query = "processTask"}}));
REQUIRE(std::string("CancelDownloadJob") ==
getRequestName(Request{CancelDownloadJobRequest{.jobId = "download-2"}}));
REQUIRE(std::string("ListDownloadJobs") ==
getRequestName(Request{ListDownloadJobsRequest{.limit = 5}}));
REQUIRE(std::string("UseSession") ==
getRequestName(Request{UseSessionRequest{.session_name = "x"}}));
REQUIRE(std::string("AddPathSelector") ==
getRequestName(Request{AddPathSelectorRequest{}}));
REQUIRE(std::string("RemovePathSelector") ==
getRequestName(Request{RemovePathSelectorRequest{}}));
}
}