diff options
| author | Thijs Schreijer <thijs@thijsschreijer.nl> | 2026-08-31 22:27:42 +0200 |
|---|---|---|
| committer | Thijs Schreijer <thijs@thijsschreijer.nl> | 2026-09-01 07:59:54 +0200 |
| commit | cd6b41a13af403d32e08ff416e60982b5cb718dc (patch) | |
| tree | 3865d91753fd636a866d4711f50ab27613e58634 /test/ssetest.lua | |
| parent | c5f169ec303a70707bc48fbece9b52cc90a48de0 (diff) | |
| download | luasocket-feat/sse.tar.gz luasocket-feat/sse.tar.bz2 luasocket-feat/sse.zip | |
Add SSE sink (socket.sse module)feat/sse
Implements SSE by using the replacement body sink. Replaces
the sink with an SSE parser if the response is a text stream.
Example application included
Diffstat (limited to '')
| -rw-r--r-- | test/ssetest.lua | 370 |
1 files changed, 370 insertions, 0 deletions
diff --git a/test/ssetest.lua b/test/ssetest.lua new file mode 100644 index 0000000..03206ba --- /dev/null +++ b/test/ssetest.lua | |||
| @@ -0,0 +1,370 @@ | |||
| 1 | local socket = require("socket") | ||
| 2 | local sse = require("socket.sse") | ||
| 3 | |||
| 4 | dofile("testsupport.lua") | ||
| 5 | |||
| 6 | -- collects everything a parser sink dispatches to it (Messages and, when | ||
| 7 | -- enabled, Comments) into an ordered list; the "collect" name signals it | ||
| 8 | -- passes through unchanged rather than transforming | ||
| 9 | local function collect() | ||
| 10 | local list = {} | ||
| 11 | local snk = function(item, err) | ||
| 12 | if err then return nil, err end | ||
| 13 | table.insert(list, item) | ||
| 14 | return 1 | ||
| 15 | end | ||
| 16 | return snk, list | ||
| 17 | end | ||
| 18 | |||
| 19 | -- feeds a parser sink the given raw chunks, then signals end of stream | ||
| 20 | local function feed(parser, ...) | ||
| 21 | for _, chunk in ipairs({...}) do | ||
| 22 | local ok, err = parser(chunk) | ||
| 23 | if not ok then return nil, err end | ||
| 24 | end | ||
| 25 | return parser(nil) | ||
| 26 | end | ||
| 27 | |||
| 28 | -------------------------------- | ||
| 29 | io.write("testing event/data/id parsing on a single Message, retry line consumed but not attached: ") | ||
| 30 | do | ||
| 31 | local snk, list = collect() | ||
| 32 | local parser = sse.parser(snk) | ||
| 33 | local ok = feed(parser, | ||
| 34 | "event: greeting\r\n" .. | ||
| 35 | "data: hello\r\n" .. | ||
| 36 | "id: 1\r\n" .. | ||
| 37 | "retry: 3000\r\n" .. | ||
| 38 | "\r\n") | ||
| 39 | assert(ok, "parser returned error") | ||
| 40 | assert(#list == 1, "expected exactly one message") | ||
| 41 | assert(list[1].event == "greeting", "wrong event") | ||
| 42 | assert(list[1].data == "hello", "wrong data") | ||
| 43 | assert(list[1].id == "1", "wrong id") | ||
| 44 | assert(list[1].retry == nil, "Message must not carry a retry field; retry only lives on the context table") | ||
| 45 | print("ok") | ||
| 46 | end | ||
| 47 | |||
| 48 | -------------------------------- | ||
| 49 | io.write("testing multiple data: lines are joined with \\n: ") | ||
| 50 | do | ||
| 51 | local snk, list = collect() | ||
| 52 | local parser = sse.parser(snk) | ||
| 53 | local ok = feed(parser, "data: line one\ndata: line two\ndata: line three\n\n") | ||
| 54 | assert(ok, "parser returned error") | ||
| 55 | assert(#list == 1, "expected exactly one message") | ||
| 56 | assert(list[1].data == "line one\nline two\nline three", "data not joined correctly: " .. list[1].data) | ||
| 57 | print("ok") | ||
| 58 | end | ||
| 59 | |||
| 60 | -------------------------------- | ||
| 61 | io.write("testing event defaults to 'message' when omitted: ") | ||
| 62 | do | ||
| 63 | local snk, list = collect() | ||
| 64 | local parser = sse.parser(snk) | ||
| 65 | local ok = feed(parser, "data: no event here\n\n") | ||
| 66 | assert(ok, "parser returned error") | ||
| 67 | assert(#list == 1, "expected exactly one message") | ||
| 68 | assert(list[1].event == "message", "expected default event type, got " .. tostring(list[1].event)) | ||
| 69 | print("ok") | ||
| 70 | end | ||
| 71 | |||
| 72 | -------------------------------- | ||
| 73 | io.write("testing id persists forward onto later Messages: ") | ||
| 74 | do | ||
| 75 | local snk, list = collect() | ||
| 76 | local parser = sse.parser(snk) | ||
| 77 | local ok = feed(parser, | ||
| 78 | "id: abc\ndata: first\n\n" .. | ||
| 79 | "data: second\n\n" .. | ||
| 80 | "id: def\ndata: third\n\n" .. | ||
| 81 | "data: fourth\n\n") | ||
| 82 | assert(ok, "parser returned error") | ||
| 83 | assert(#list == 4, "expected four messages, got " .. #list) | ||
| 84 | assert(list[1].id == "abc", "message 1 id") | ||
| 85 | assert(list[2].id == "abc", "message 2 id should persist from message 1") | ||
| 86 | assert(list[3].id == "def", "message 3 id") | ||
| 87 | assert(list[4].id == "def", "message 4 id should persist from message 3") | ||
| 88 | print("ok") | ||
| 89 | end | ||
| 90 | |||
| 91 | -------------------------------- | ||
| 92 | io.write("testing comment lines are silently absorbed when comments is off: ") | ||
| 93 | do | ||
| 94 | local snk, list = collect() | ||
| 95 | local parser = sse.parser(snk) | ||
| 96 | local ok = feed(parser, ": this is a comment\ndata: real message\n\n") | ||
| 97 | assert(ok, "parser returned error") | ||
| 98 | assert(#list == 1, "expected only the message, comment should be absorbed") | ||
| 99 | assert(list[1].data == "real message", "wrong data") | ||
| 100 | print("ok") | ||
| 101 | end | ||
| 102 | |||
| 103 | -------------------------------- | ||
| 104 | io.write("testing comment lines are dispatched distinctly when comments is on: ") | ||
| 105 | do | ||
| 106 | local snk, list = collect() | ||
| 107 | local parser = sse.parser(snk, { comments = true }) | ||
| 108 | local ok = feed(parser, ": keep-alive\ndata: real message\n\n") | ||
| 109 | assert(ok, "parser returned error") | ||
| 110 | assert(#list == 2, "expected comment and message, got " .. #list) | ||
| 111 | assert(list[1].comment == "keep-alive", "wrong comment text: " .. tostring(list[1].comment)) | ||
| 112 | assert(list[1].event == nil, "comment must not look like a message") | ||
| 113 | assert(list[2].data == "real message", "wrong data") | ||
| 114 | print("ok") | ||
| 115 | end | ||
| 116 | |||
| 117 | -------------------------------- | ||
| 118 | io.write("testing context table gets last_event_id/retry written and retains them after parsing ends: ") | ||
| 119 | do | ||
| 120 | local snk = collect() | ||
| 121 | local context = {} | ||
| 122 | local parser = sse.parser(snk, { context = context }) | ||
| 123 | local ok = feed(parser, "id: xyz\nretry: 5000\ndata: hi\n\n") | ||
| 124 | assert(ok, "parser returned error") | ||
| 125 | assert(context.last_event_id == "xyz", "context.last_event_id not written") | ||
| 126 | assert(context.retry == 5000, "context.retry not written") | ||
| 127 | print("ok") | ||
| 128 | end | ||
| 129 | |||
| 130 | -------------------------------- | ||
| 131 | io.write("testing context.retry persists across later events that don't repeat it, and no Message ever carries a retry field: ") | ||
| 132 | do | ||
| 133 | local snk, list = collect() | ||
| 134 | local context = {} | ||
| 135 | local parser = sse.parser(snk, { context = context }) | ||
| 136 | local ok = feed(parser, | ||
| 137 | "retry: 2000\ndata: first\n\n" .. | ||
| 138 | "data: second\n\n") | ||
| 139 | assert(ok, "parser returned error") | ||
| 140 | assert(#list == 2, "expected two messages, got " .. #list) | ||
| 141 | assert(list[1].retry == nil, "message 1 must not carry a retry field") | ||
| 142 | assert(list[2].retry == nil, "message 2 must not carry a retry field") | ||
| 143 | assert(context.retry == 2000, "context.retry should still reflect the most recent retry hint after a later event that didn't repeat it") | ||
| 144 | print("ok") | ||
| 145 | end | ||
| 146 | |||
| 147 | -------------------------------- | ||
| 148 | io.write("testing an oversized line produces a distinct error and aborts: ") | ||
| 149 | do | ||
| 150 | local snk, list = collect() | ||
| 151 | local parser = sse.parser(snk) | ||
| 152 | local huge = string.rep("x", sse.MAXLINESIZE + 1) | ||
| 153 | local ok, err = feed(parser, "data: " .. huge .. "\n\n") | ||
| 154 | assert(not ok, "expected parser to fail on oversized line") | ||
| 155 | assert(err == "oversized", "expected 'oversized' error, got " .. tostring(err)) | ||
| 156 | assert(#list == 0, "no message should have been dispatched") | ||
| 157 | print("ok") | ||
| 158 | end | ||
| 159 | |||
| 160 | -------------------------------- | ||
| 161 | io.write("testing an oversized event (many lines under MAXEVENTSIZE total) produces a distinct error: ") | ||
| 162 | do | ||
| 163 | local snk, list = collect() | ||
| 164 | local parser = sse.parser(snk) | ||
| 165 | local line = "data: " .. string.rep("y", 100) .. "\n" | ||
| 166 | local lines = string.rep(line, math.ceil(sse.MAXEVENTSIZE / #line) + 1) | ||
| 167 | local ok, err = feed(parser, lines) | ||
| 168 | assert(not ok, "expected parser to fail on oversized event") | ||
| 169 | assert(err == "oversized", "expected 'oversized' error, got " .. tostring(err)) | ||
| 170 | assert(#list == 0, "no message should have been dispatched") | ||
| 171 | print("ok") | ||
| 172 | end | ||
| 173 | |||
| 174 | -------------------------------- | ||
| 175 | io.write("testing a field split across two raw-byte chunks is parsed correctly: ") | ||
| 176 | do | ||
| 177 | local snk, list = collect() | ||
| 178 | local parser = sse.parser(snk) | ||
| 179 | -- split mid field-name, mid value, and mid line-terminator | ||
| 180 | local ok = feed(parser, "eve", "nt: greet", "ing\ndata: hel", "lo\r", "\n\r\n") | ||
| 181 | assert(ok, "parser returned error") | ||
| 182 | assert(#list == 1, "expected exactly one message, got " .. #list) | ||
| 183 | assert(list[1].event == "greeting", "wrong event: " .. tostring(list[1].event)) | ||
| 184 | assert(list[1].data == "hello", "wrong data: " .. tostring(list[1].data)) | ||
| 185 | print("ok") | ||
| 186 | end | ||
| 187 | |||
| 188 | -------------------------------- | ||
| 189 | io.write("testing a Message-sink error aborts the parser sink chain: ") | ||
| 190 | do | ||
| 191 | local calls = 0 | ||
| 192 | local snk = function(message) | ||
| 193 | calls = calls + 1 | ||
| 194 | if calls == 1 then return 1 end | ||
| 195 | return nil, "sink refused message" | ||
| 196 | end | ||
| 197 | local parser = sse.parser(snk) | ||
| 198 | local ok, err = feed(parser, "data: first\n\ndata: second\n\n") | ||
| 199 | assert(not ok, "expected parser to propagate message sink error") | ||
| 200 | assert(err == "sink refused message", "unexpected error: " .. tostring(err)) | ||
| 201 | assert(calls == 2, "expected the sink to be invoked twice") | ||
| 202 | print("ok") | ||
| 203 | end | ||
| 204 | |||
| 205 | -------------------------------- | ||
| 206 | io.write("testing a Message with no data: line is not dispatched: ") | ||
| 207 | do | ||
| 208 | local snk, list = collect() | ||
| 209 | local parser = sse.parser(snk) | ||
| 210 | local ok = feed(parser, "event: ping\nid: 1\n\ndata: real\n\n") | ||
| 211 | assert(ok, "parser returned error") | ||
| 212 | assert(#list == 1, "the dataless block should not have produced a message") | ||
| 213 | assert(list[1].data == "real", "wrong data") | ||
| 214 | print("ok") | ||
| 215 | end | ||
| 216 | |||
| 217 | -------------------------------- | ||
| 218 | io.write("testing callbacksink lets a plain callback act as a message sink: ") | ||
| 219 | do | ||
| 220 | local received = {} | ||
| 221 | local callback = function(message) table.insert(received, message); return true end | ||
| 222 | local snk = sse.callbacksink(callback) | ||
| 223 | local parser = sse.parser(snk) | ||
| 224 | local ok = feed(parser, "data: first\n\ndata: second\n\n") | ||
| 225 | assert(ok, "parser returned error") | ||
| 226 | assert(#received == 2, "expected two messages, got " .. #received) | ||
| 227 | assert(received[1].data == "first", "wrong data") | ||
| 228 | assert(received[2].data == "second", "wrong data") | ||
| 229 | print("ok") | ||
| 230 | end | ||
| 231 | |||
| 232 | -------------------------------- | ||
| 233 | io.write("testing callbacksink propagates an error returned by the callback: ") | ||
| 234 | do | ||
| 235 | local calls = 0 | ||
| 236 | local callback = function(message) | ||
| 237 | calls = calls + 1 | ||
| 238 | if message.data == "bad" then return nil, "callback refused" end | ||
| 239 | return true | ||
| 240 | end | ||
| 241 | local snk = sse.callbacksink(callback) | ||
| 242 | local parser = sse.parser(snk) | ||
| 243 | local ok, err = feed(parser, "data: good\n\ndata: bad\n\ndata: unreachable\n\n") | ||
| 244 | assert(not ok, "expected callback error to abort the chain") | ||
| 245 | assert(err == "callback refused", "unexpected error: " .. tostring(err)) | ||
| 246 | assert(calls == 2, "expected the callback to stop being invoked after it errors, got " .. calls) | ||
| 247 | print("ok") | ||
| 248 | end | ||
| 249 | |||
| 250 | -------------------------------- | ||
| 251 | io.write("testing callbacksink does not swallow a sink that fails with a falsy ok and no error message: ") | ||
| 252 | do | ||
| 253 | local calls = 0 | ||
| 254 | local rawsink = function(message) | ||
| 255 | calls = calls + 1 | ||
| 256 | if message.data == "bad" then return false end | ||
| 257 | return 1 | ||
| 258 | end | ||
| 259 | local snk = sse.callbacksink(rawsink) | ||
| 260 | local parser = sse.parser(snk) | ||
| 261 | local ok, err = feed(parser, "data: good\n\ndata: bad\n\ndata: unreachable\n\n") | ||
| 262 | assert(not ok, "expected the falsy ok to abort the chain even without an error message") | ||
| 263 | assert(err == nil, "no error message was given, so none should be invented") | ||
| 264 | assert(calls == 2, "expected the sink to stop being invoked once it fails, got " .. calls) | ||
| 265 | print("ok") | ||
| 266 | end | ||
| 267 | |||
| 268 | -------------------------------- | ||
| 269 | io.write("testing callbacksink treats a callback that returns nothing as an error, not a default success: ") | ||
| 270 | do | ||
| 271 | local snk = sse.callbacksink(function(message) end) | ||
| 272 | local parser = sse.parser(snk) | ||
| 273 | local ok = feed(parser, "data: hi\n\n") | ||
| 274 | assert(not ok, "a callback must explicitly return truthy to signal success, matching a raw sink's contract") | ||
| 275 | print("ok") | ||
| 276 | end | ||
| 277 | |||
| 278 | -------------------------------- | ||
| 279 | io.write("testing responseheaders forwards its config (context/comments) to the parser: ") | ||
| 280 | do | ||
| 281 | local received = {} | ||
| 282 | local context = {} | ||
| 283 | local factory = sse.responseheaders( | ||
| 284 | function(message) table.insert(received, message); return true end, | ||
| 285 | { comments = true, context = context }) | ||
| 286 | local ok, sink = factory(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK") | ||
| 287 | assert(ok, "expected success") | ||
| 288 | local fed = feed(sink, ": keep-alive\nid: xyz\ndata: hello\n\n") | ||
| 289 | assert(fed, "sink chain returned an error") | ||
| 290 | assert(#received == 2, "expected the comment and the message, got " .. #received) | ||
| 291 | assert(received[1].comment == "keep-alive", "comments config was not forwarded to the parser") | ||
| 292 | assert(context.last_event_id == "xyz", "context table was not forwarded to the parser") | ||
| 293 | print("ok") | ||
| 294 | end | ||
| 295 | |||
| 296 | -------------------------------- | ||
| 297 | io.write("testing responseheaders installs the sink chain on a matching Content-Type: ") | ||
| 298 | do | ||
| 299 | local received = {} | ||
| 300 | local factory = sse.responseheaders(function(message) table.insert(received, message); return true end) | ||
| 301 | local reqt = {} | ||
| 302 | reqt.headers_callback = factory | ||
| 303 | local ok, sink = reqt.headers_callback(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK") | ||
| 304 | assert(ok, "expected success on a matching content-type") | ||
| 305 | assert(type(sink) == "function", "expected a sink function on a matching content-type") | ||
| 306 | reqt.sink = sink | ||
| 307 | local fed = feed(reqt.sink, "data: hello\n\n") | ||
| 308 | assert(fed, "sink chain returned an error") | ||
| 309 | assert(#received == 1, "expected one message dispatched through the installed sink") | ||
| 310 | assert(received[1].data == "hello", "wrong data") | ||
| 311 | print("ok") | ||
| 312 | end | ||
| 313 | |||
| 314 | -------------------------------- | ||
| 315 | io.write("testing responseheaders also accepts a raw ltn12-style message sink directly: ") | ||
| 316 | do | ||
| 317 | local received = {} | ||
| 318 | -- a raw sink following full ltn12 protocol: truthy on success, (nil, err) on failure | ||
| 319 | local rawsink = function(message) | ||
| 320 | table.insert(received, message) | ||
| 321 | return 1 | ||
| 322 | end | ||
| 323 | local factory = sse.responseheaders(rawsink) | ||
| 324 | local ok, sink = factory(200, { ["content-type"] = "text/event-stream" }, "HTTP/1.1 200 OK") | ||
| 325 | assert(ok, "expected success") | ||
| 326 | local fed = feed(sink, "data: raw\n\n") | ||
| 327 | assert(fed, "sink chain returned an error") | ||
| 328 | assert(#received == 1, "expected one message") | ||
| 329 | assert(received[1].data == "raw", "wrong data") | ||
| 330 | print("ok") | ||
| 331 | end | ||
| 332 | |||
| 333 | -------------------------------- | ||
| 334 | io.write("testing responseheaders matches a Content-Type with trailing parameters: ") | ||
| 335 | do | ||
| 336 | local factory = sse.responseheaders(function() end) | ||
| 337 | local ok, sink = factory(200, { ["content-type"] = "text/event-stream; charset=utf-8" }, "HTTP/1.1 200 OK") | ||
| 338 | assert(ok, "expected success") | ||
| 339 | assert(type(sink) == "function", "expected a sink for a parameterized but matching content-type") | ||
| 340 | print("ok") | ||
| 341 | end | ||
| 342 | |||
| 343 | -------------------------------- | ||
| 344 | io.write("testing responseheaders declines cleanly on a non-matching Content-Type: ") | ||
| 345 | do | ||
| 346 | local calledback = false | ||
| 347 | local factory = sse.responseheaders(function() calledback = true end) | ||
| 348 | local reqt = { sink = "original sink placeholder" } | ||
| 349 | local ok, sink = factory(200, { ["content-type"] = "text/html" }, "HTTP/1.1 200 OK") | ||
| 350 | assert(ok, "a non-matching content-type must still succeed (just decline the sink swap)") | ||
| 351 | assert(sink == nil, "a non-matching content-type must not offer a sink swap") | ||
| 352 | -- mirror what http.lua does with the factory's return values, to prove | ||
| 353 | -- the caller's existing sink survives untouched | ||
| 354 | if sink then reqt.sink = sink end | ||
| 355 | assert(reqt.sink == "original sink placeholder", "existing sink must be left untouched") | ||
| 356 | assert(not calledback, "message callback must not fire for a non-matching content-type") | ||
| 357 | print("ok") | ||
| 358 | end | ||
| 359 | |||
| 360 | -------------------------------- | ||
| 361 | io.write("testing responseheaders declines cleanly when Content-Type is missing: ") | ||
| 362 | do | ||
| 363 | local factory = sse.responseheaders(function() end) | ||
| 364 | local ok, sink = factory(200, {}, "HTTP/1.1 200 OK") | ||
| 365 | assert(ok, "missing content-type must still succeed (just decline the sink swap)") | ||
| 366 | assert(sink == nil, "missing content-type must not offer a sink swap") | ||
| 367 | print("ok") | ||
| 368 | end | ||
| 369 | |||
| 370 | print("the library passed all tests") | ||
