Test SSEParser directly instead of through a server

Move the SSE parsing tests from a fixture that started a server and an
SSEClient for every case to unit tests of detail::SSEParser, and turn
NonDigitRetryFieldIgnored, which slept for 500ms, into one of them. Add
tests that feed each stream split at every position and byte by byte,
and that cover the max event length and an event left incomplete at the
end of the stream.
This commit is contained in:
yhirose committed 2026-11-12 19:07:54 -05:00
1 parent 07142256ed
commit a39e081386
1 file changed
+207 -236
+207 -236
View File
@@ -26544,264 +26544,264 @@ protected:
}; };
//============================================================================== //==============================================================================
// SSE Parsing Tests // SSE Parser Tests
//============================================================================== //==============================================================================
class SSEParsingTest : public SSEIntegrationTest { struct SSEParseResult {
protected: std::vector<sse::SSEMessage> events;
// Feeds a raw event stream to SSEClient and returns the dispatched messages. std::vector<std::string> ids;
// A trailing sentinel event tells when the whole stream has been parsed. std::vector<int> retries;
std::vector<sse::SSEMessage> parse(const std::string &stream) { bool ok = true;
auto body = stream + "event: end\ndata: end\n\n"; std::string trace; // Every handler call in order
server_->Get("/parse", [body](const Request &, Response &res) {
res.set_content(body, "text/event-stream");
});
return collect("/parse");
}
// Runs SSEClient against path until an "end" event arrives and returns the
// messages dispatched before it
std::vector<sse::SSEMessage> collect(const std::string &path) {
Client client(HOST, get_port());
sse::SSEClient sse(client, path);
std::mutex mutex;
std::condition_variable cv;
std::vector<sse::SSEMessage> messages;
auto done = false;
sse.on_message([&](const sse::SSEMessage &msg) {
std::lock_guard<std::mutex> lock(mutex);
// Ignore a replay from a reconnect that races with stop()
if (!done) { messages.push_back(msg); }
});
sse.on_event("end", [&](const sse::SSEMessage &) {
std::lock_guard<std::mutex> lock(mutex);
done = true;
cv.notify_all();
});
sse.set_reconnect_interval(100);
sse.start_async();
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait_for(lock, std::chrono::seconds(5), [&] { return done; });
}
sse.stop();
EXPECT_TRUE(done);
return messages;
}
}; };
TEST_F(SSEParsingTest, SingleLineData) { static SSEParseResult
auto msgs = parse("data: hello\n\n"); sse_parse(const std::vector<std::string> &chunks,
ASSERT_EQ(msgs.size(), 1u); size_t max_event_length = CPPHTTPLIB_SSE_MAX_EVENT_LENGTH) {
EXPECT_EQ(msgs[0].data, "hello"); SSEParseResult r;
EXPECT_EQ(msgs[0].event, "message"); detail::SSEParser parser(
[&](const sse::SSEMessage &msg) {
r.events.push_back(msg);
r.trace += "event[" + msg.event + "|" + msg.data + "|" + msg.id + "]";
},
[&](const std::string &id) {
r.ids.push_back(id);
r.trace += "id[" + id + "]";
},
[&](int retry_ms) {
r.retries.push_back(retry_ms);
r.trace += "retry[" + std::to_string(retry_ms) + "]";
},
max_event_length);
for (const auto &chunk : chunks) {
if (!parser.parse(chunk.data(), chunk.size())) {
r.ok = false;
break;
}
}
return r;
} }
TEST_F(SSEParsingTest, MultiLineData) { static SSEParseResult sse_parse(const std::string &stream) {
auto msgs = parse("data: line1\ndata: line2\ndata: line3\n\n"); return sse_parse(std::vector<std::string>{stream});
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].data, "line1\nline2\nline3");
} }
TEST_F(SSEParsingTest, CustomEventType) { TEST(SSEParserTest, SingleLineData) {
auto msgs = parse("event: update\ndata: payload\n\n"); auto r = sse_parse("data: hello\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].event, "update"); EXPECT_EQ(r.events[0].data, "hello");
EXPECT_EQ(msgs[0].data, "payload"); EXPECT_EQ(r.events[0].event, "message");
} }
TEST_F(SSEParsingTest, EventIdHandling) { TEST(SSEParserTest, MultiLineData) {
auto msgs = parse("id: 12345\ndata: test\n\n"); auto r = sse_parse("data: line1\ndata: line2\ndata: line3\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].id, "12345"); EXPECT_EQ(r.events[0].data, "line1\nline2\nline3");
} }
TEST_F(SSEParsingTest, EmptyEventId) { TEST(SSEParserTest, CustomEventType) {
auto msgs = parse("id:\ndata: test\n\n"); auto r = sse_parse("event: update\ndata: payload\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].id, ""); EXPECT_EQ(r.events[0].event, "update");
EXPECT_EQ(r.events[0].data, "payload");
} }
TEST_F(SSEParsingTest, CommentsIgnored) { TEST(SSEParserTest, EventIdHandling) {
auto msgs = parse(": this is a comment\ndata: hello\n\n"); auto r = sse_parse("id: 12345\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].id, "12345");
EXPECT_EQ(msgs[0].event, "message"); EXPECT_EQ(r.ids, std::vector<std::string>{"12345"});
} }
TEST_F(SSEParsingTest, ColonInValue) { TEST(SSEParserTest, EmptyEventId) {
auto msgs = parse("data: hello:world:test\n\n"); auto r = sse_parse("id:\ndata: test\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello:world:test"); EXPECT_EQ(r.events[0].id, "");
EXPECT_EQ(r.ids, std::vector<std::string>{""});
} }
TEST_F(SSEParsingTest, FieldNameOnly) { TEST(SSEParserTest, CommentsIgnored) {
auto r = sse_parse(": this is a comment\ndata: hello\n\n");
ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(r.events[0].data, "hello");
EXPECT_EQ(r.events[0].event, "message");
}
TEST(SSEParserTest, ColonInValue) {
auto r = sse_parse("data: hello:world:test\n\n");
ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(r.events[0].data, "hello:world:test");
}
TEST(SSEParserTest, FieldNameOnly) {
// A line without a colon is a field name with an empty value // A line without a colon is a field name with an empty value
auto msgs = parse("data\n\n"); auto r = sse_parse("data\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, ""); EXPECT_EQ(r.events[0].data, "");
} }
TEST_F(SSEParsingTest, TrailingCarriageReturn) { TEST(SSEParserTest, TrailingCarriageReturn) {
auto msgs = parse("event: update\r\ndata: hello\r\n\r\n"); auto r = sse_parse("event: update\r\ndata: hello\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].event, "update"); EXPECT_EQ(r.events[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].data, "hello");
} }
TEST_F(SSEParsingTest, FieldNameOnlyWithCarriageReturn) { TEST(SSEParserTest, FieldNameOnlyWithCarriageReturn) {
auto msgs = parse("data\r\n\r\n"); auto r = sse_parse("data\r\n\r\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, ""); EXPECT_EQ(r.events[0].data, "");
} }
TEST_F(SSEParsingTest, CarriageReturnLineEnding) { TEST(SSEParserTest, CarriageReturnLineEnding) {
auto msgs = parse("event: update\rdata: hello\r\r"); auto r = sse_parse("event: update\rdata: hello\r\r");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].event, "update"); EXPECT_EQ(r.events[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].data, "hello");
} }
TEST_F(SSEParsingTest, LineEndingSplitAcrossChunks) { TEST(SSEParserTest, LineEndingSplitAcrossChunks) {
// Chunk boundaries fall inside every \r\n and right before the first one // Chunk boundaries fall inside every \r\n and right before the first one
std::vector<std::string> chunks = {"event: update", "\r", "\ndata: hello\r", auto r = sse_parse(std::vector<std::string>{"event: update", "\r",
"\n\r", "\nevent: end\ndata: end\n\n"}; "\ndata: hello\r", "\n\r", "\n"});
server_->Get("/split", [chunks](const Request &, Response &res) { ASSERT_EQ(r.events.size(), 1u);
auto i = std::make_shared<size_t>(0); EXPECT_EQ(r.events[0].event, "update");
res.set_chunked_content_provider( EXPECT_EQ(r.events[0].data, "hello");
"text/event-stream", [chunks, i](size_t /*offset*/, DataSink &sink) {
if (*i == chunks.size()) {
sink.done();
} else {
sink.write(chunks[*i].data(), chunks[*i].size());
(*i)++;
}
return true;
});
});
auto msgs = collect("/split");
ASSERT_EQ(msgs.size(), 1u);
EXPECT_EQ(msgs[0].event, "update");
EXPECT_EQ(msgs[0].data, "hello");
} }
TEST_F(SSEParsingTest, LeadingByteOrderMarkIgnored) { TEST(SSEParserTest, LeadingByteOrderMarkIgnored) {
// Only a BOM at the start of the stream is ignored // Only a BOM at the start of the stream is ignored
auto msgs = parse("\xEF\xBB\xBF" auto r = sse_parse("\xEF\xBB\xBF"
"data: first\n\n" "data: first\n\n"
"\xEF\xBB\xBF" "\xEF\xBB\xBF"
"data: second\n\n"); "data: second\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, "first"); EXPECT_EQ(r.events[0].data, "first");
} }
TEST_F(SSEParsingTest, UnknownFieldsIgnored) { TEST(SSEParserTest, UnknownFieldsIgnored) {
auto msgs = parse("unknown: value\ndata: hello\n\n"); auto r = sse_parse("unknown: value\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].data, "hello");
EXPECT_EQ(msgs[0].event, "message"); EXPECT_EQ(r.events[0].event, "message");
} }
TEST_F(SSEParsingTest, SpaceAfterColonOptional) { TEST(SSEParserTest, SpaceAfterColonOptional) {
auto msgs = parse("data: hello\n\ndata:hello\n\n"); auto r = sse_parse("data: hello\n\ndata:hello\n\n");
ASSERT_EQ(msgs.size(), 2u); ASSERT_EQ(r.events.size(), 2u);
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].data, "hello");
EXPECT_EQ(msgs[1].data, "hello"); EXPECT_EQ(r.events[1].data, "hello");
} }
TEST_F(SSEParsingTest, EventWithoutDataResetsEventType) { TEST(SSEParserTest, EventWithoutDataResetsEventType) {
// An event without data is not dispatched, and its type must not leak // An event without data is not dispatched, and its type must not leak
// into the next event // into the next event
auto msgs = parse("event: update\n\ndata: hello\n\n"); auto r = sse_parse("event: update\n\ndata: hello\n\n");
ASSERT_EQ(msgs.size(), 1u); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(msgs[0].event, "message"); EXPECT_EQ(r.events[0].event, "message");
EXPECT_EQ(msgs[0].data, "hello"); EXPECT_EQ(r.events[0].data, "hello");
} }
TEST_F(SSEParsingTest, EmptyEventTypeIsMessage) { TEST(SSEParserTest, EmptyEventTypeIsMessage) {
// Also when the empty field replaces an earlier type // Also when the empty field replaces an earlier type
auto msgs = parse("event:\ndata: a\n\nevent: update\nevent:\ndata: b\n\n"); auto r = sse_parse("event:\ndata: a\n\nevent: update\nevent:\ndata: b\n\n");
ASSERT_EQ(msgs.size(), 2u); ASSERT_EQ(r.events.size(), 2u);
EXPECT_EQ(msgs[0].event, "message"); EXPECT_EQ(r.events[0].event, "message");
EXPECT_EQ(msgs[1].event, "message"); EXPECT_EQ(r.events[1].event, "message");
} }
TEST_F(SSEParsingTest, EventWithoutDataUpdatesLastEventId) { TEST(SSEParserTest, EventWithoutDataReportsId) {
// The first connection sends only an id; the second echoes back the auto r = sse_parse("id: 42\n\n");
// Last-Event-ID it was reconnected with EXPECT_TRUE(r.events.empty());
std::atomic<int> connection_count{0}; EXPECT_EQ(r.ids, std::vector<std::string>{"42"});
server_->Get("/id-only", [&](const Request &req, Response &res) { }
if (connection_count++ == 0) {
res.set_content("id: 42\n\n", "text/event-stream"); TEST(SSEParserTest, EmptyEventIdClearsId) {
} else { auto r = sse_parse("id: 1\ndata: first\n\nid:\ndata: second\n\n");
res.set_content("data: " + req.get_header_value("Last-Event-ID") + ASSERT_EQ(r.events.size(), 2u);
"\n\nevent: end\ndata: end\n\n", EXPECT_EQ(r.events[0].id, "1");
"text/event-stream"); EXPECT_EQ(r.events[1].id, "");
EXPECT_EQ(r.ids, (std::vector<std::string>{"1", ""}));
}
TEST(SSEParserTest, UnsendableEventIdIgnored) {
// Ids that cannot be carried in a Last-Event-ID header
auto r = sse_parse(std::string("id: 42\n\nid: a") + '\0' +
"b\n\nid: \x01\n\nid: 7\n\nid: 7 \n\n");
EXPECT_EQ(r.ids, std::vector<std::string>{"42"});
}
TEST(SSEParserTest, RetryDigitsOnly) {
auto r = sse_parse("retry: 1000\n");
EXPECT_EQ(r.retries, std::vector<int>{1000});
// A value that is not all ASCII digits, or does not fit, is ignored
r = sse_parse("retry: -1\nretry: 1.5\nretry:\nretry: 99999999999\n");
EXPECT_TRUE(r.retries.empty());
}
TEST(SSEParserTest, CompleteEventParsing) {
auto r = sse_parse("event: notification\nid: evt-42\n"
"data: {\"type\":\"alert\"}\nretry: 1000\n\n");
ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(r.events[0].event, "notification");
EXPECT_EQ(r.events[0].id, "evt-42");
EXPECT_EQ(r.events[0].data, "{\"type\":\"alert\"}");
EXPECT_EQ(r.retries, std::vector<int>{1000});
}
TEST(SSEParserTest, IncompleteEventNotReported) {
// An event still waiting for its blank line when the stream ends is
// discarded per the SSE spec
auto r = sse_parse("id: 1\ndata: x\n");
EXPECT_TRUE(r.events.empty());
EXPECT_TRUE(r.ids.empty());
}
TEST(SSEParserTest, SameResultAtEveryChunkBoundary) {
const std::vector<std::string> streams = {
"\xEF\xBB\xBF"
"data: bom\r\n\r\n",
"event: a\r\ndata: 1\r\nid: x\r\n\r\ndata: 2\rretry: 7\r\r",
": comment\ndata\nid:\n\nid: y\n\ndata: tail\r\n\r\n",
};
for (const auto &stream : streams) {
auto expected = sse_parse(stream).trace;
ASSERT_FALSE(expected.empty());
for (size_t i = 0; i <= stream.size(); i++) {
auto r = sse_parse(
std::vector<std::string>{stream.substr(0, i), stream.substr(i)});
EXPECT_EQ(r.trace, expected) << "split at " << i;
} }
});
auto msgs = collect("/id-only"); std::vector<std::string> bytes;
ASSERT_EQ(msgs.size(), 1u); for (auto c : stream) {
EXPECT_EQ(msgs[0].data, "42"); bytes.emplace_back(1, c);
}
TEST_F(SSEParsingTest, EmptyEventIdClearsLastEventId) {
// The first connection sets an id and then clears it with an empty one; the
// second reports whether it was reconnected with a Last-Event-ID
std::atomic<int> connection_count{0};
server_->Get("/id-clear", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content("id: 1\ndata: first\n\nid:\ndata: second\n\n",
"text/event-stream");
} else {
res.set_content(
std::string("data: ") +
(req.has_header("Last-Event-ID") ? "sent" : "not sent") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
} }
}); EXPECT_EQ(sse_parse(bytes).trace, expected) << "byte by byte";
}
auto msgs = collect("/id-clear");
ASSERT_EQ(msgs.size(), 3u);
EXPECT_EQ(msgs[0].id, "1");
EXPECT_EQ(msgs[1].id, "");
EXPECT_EQ(msgs[2].data, "not sent");
} }
TEST_F(SSEParsingTest, UnsendableEventIdKeepsLastEventId) { TEST(SSEParserTest, MaxEventLength) {
// The first connection sets an id and then sends ids that cannot be carried // A line still waiting for its end
// in a header; the second echoes back the Last-Event-ID it was reconnected EXPECT_TRUE(sse_parse(std::vector<std::string>{"data: 12"}, 8).ok);
// with EXPECT_FALSE(sse_parse(std::vector<std::string>{"data: 123"}, 8).ok);
std::atomic<int> connection_count{0};
server_->Get("/id-unsendable", [&](const Request &req, Response &res) {
if (connection_count++ == 0) {
res.set_content(std::string("id: 42\n\nid: a") + '\0' +
"b\n\nid: \x01\n\nid: 7\n\nid: 7 \n\n",
"text/event-stream");
} else {
res.set_content("data: " + req.get_header_value("Last-Event-ID") +
"\n\nevent: end\ndata: end\n\n",
"text/event-stream");
}
});
auto msgs = collect("/id-unsendable"); // Data accumulated over the lines of one event
ASSERT_EQ(msgs.size(), 1u); EXPECT_TRUE(
EXPECT_EQ(msgs[0].data, "42"); sse_parse(std::vector<std::string>{"data: 1234\ndata: 567\n"}, 8).ok);
} EXPECT_FALSE(
sse_parse(std::vector<std::string>{"data: 1234\ndata: 5678\n"}, 8).ok);
TEST_F(SSEParsingTest, CompleteEventParsing) { // 0 means no limit
auto msgs = parse("event: notification\nid: evt-42\n" EXPECT_TRUE(sse_parse(std::vector<std::string>{"data: 123"}, 0).ok);
"data: {\"type\":\"alert\"}\nretry: 1000\n\n");
ASSERT_EQ(msgs.size(), 1u); // Events completed in the same chunk are still reported
EXPECT_EQ(msgs[0].event, "notification"); auto r = sse_parse(std::vector<std::string>{"data: a\n\ndata: 123456789"}, 8);
EXPECT_EQ(msgs[0].id, "evt-42"); EXPECT_FALSE(r.ok);
EXPECT_EQ(msgs[0].data, "{\"type\":\"alert\"}"); ASSERT_EQ(r.events.size(), 1u);
EXPECT_EQ(r.events[0].data, "a");
} }
TEST(SSEMessageTest, Clear) { TEST(SSEMessageTest, Clear) {
@@ -27426,35 +27426,6 @@ TEST_F(SSEIntegrationTest, StreamEndingAtConnectionCloseIsNotAnError) {
EXPECT_EQ(0, error_count.load()); EXPECT_EQ(0, error_count.load());
} }
// Test: A retry field that is not all ASCII digits is ignored
TEST_F(SSEIntegrationTest, NonDigitRetryFieldIgnored) {
std::atomic<int> connection_count{0};
server_->Get("/bad-retry",
[&connection_count](const Request &, Response &res) {
connection_count.fetch_add(1);
res.set_chunked_content_provider(
"text/event-stream", [](size_t offset, DataSink &sink) {
if (offset == 0) {
std::string event = "retry: -1\ndata: hello\n\n";
sink.write(event.data(), event.size());
}
return false;
});
});
Client client("localhost", get_port());
sse::SSEClient sse(client, "/bad-retry");
sse.set_reconnect_interval(10000);
sse.start_async();
std::this_thread::sleep_for(std::chrono::milliseconds(500));
sse.stop();
EXPECT_EQ(connection_count.load(), 1);
}
// Test: A retry field of all ASCII digits sets the reconnection time // Test: A retry field of all ASCII digits sets the reconnection time
TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) { TEST_F(SSEIntegrationTest, DigitRetryFieldApplied) {
std::mutex mutex; std::mutex mutex;