diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/httpfixture.lua | 47 | ||||
| -rw-r--r-- | test/ssehttptest.lua | 151 | ||||
| -rw-r--r-- | test/ssetest.lua | 370 |
3 files changed, 556 insertions, 12 deletions
diff --git a/test/httpfixture.lua b/test/httpfixture.lua index 3ea0737..14c7548 100644 --- a/test/httpfixture.lua +++ b/test/httpfixture.lua | |||
| @@ -49,18 +49,12 @@ local function quote(s) | |||
| 49 | return "\"" .. s .. "\"" | 49 | return "\"" .. s .. "\"" |
| 50 | end | 50 | end |
| 51 | 51 | ||
| 52 | -- Scripts the server to accept one connection (into the shared `data` | 52 | -- Appends the script parts for one accept-send-close cycle (see |
| 53 | -- global, mirroring test/testclnt.lua's reconnect()), write a sequence of | 53 | -- accept_and_send_sequence) onto `parts`. |
| 54 | -- raw byte chunks to it, then close it. `chunks` is an array of either | 54 | local function append_connection(parts, chunks) |
| 55 | -- plain strings, or {body, delay = seconds} tables -- the delay (via | 55 | parts[#parts + 1] = "if data then data:close() data = nil end" |
| 56 | -- socket.sleep) is applied before sending that chunk, so callers can prove | 56 | parts[#parts + 1] = "data = server:accept()" |
| 57 | -- incremental/partial delivery instead of one atomic send. | 57 | parts[#parts + 1] = "data:setoption(\"tcp-nodelay\", true)" |
| 58 | function M.accept_and_send(remote, chunks) | ||
| 59 | local parts = { | ||
| 60 | "if data then data:close() data = nil end", | ||
| 61 | "data = server:accept()", | ||
| 62 | "data:setoption(\"tcp-nodelay\", true)", | ||
| 63 | } | ||
| 64 | for _, chunk in ipairs(chunks) do | 58 | for _, chunk in ipairs(chunks) do |
| 65 | local body, delay | 59 | local body, delay |
| 66 | if type(chunk) == "table" then | 60 | if type(chunk) == "table" then |
| @@ -74,6 +68,35 @@ function M.accept_and_send(remote, chunks) | |||
| 74 | parts[#parts + 1] = "data:send(" .. quote(body) .. ")" | 68 | parts[#parts + 1] = "data:send(" .. quote(body) .. ")" |
| 75 | end | 69 | end |
| 76 | parts[#parts + 1] = "data:close() data = nil" | 70 | parts[#parts + 1] = "data:close() data = nil" |
| 71 | end | ||
| 72 | |||
| 73 | -- Scripts the server to accept one connection (into the shared `data` | ||
| 74 | -- global, mirroring test/testclnt.lua's reconnect()), write a sequence of | ||
| 75 | -- raw byte chunks to it, then close it. `chunks` is an array of either | ||
| 76 | -- plain strings, or {body, delay = seconds} tables -- the delay (via | ||
| 77 | -- socket.sleep) is applied before sending that chunk, so callers can prove | ||
| 78 | -- incremental/partial delivery instead of one atomic send. | ||
| 79 | function M.accept_and_send(remote, chunks) | ||
| 80 | local parts = {} | ||
| 81 | append_connection(parts, chunks) | ||
| 82 | remote(table.concat(parts, "\n")) | ||
| 83 | end | ||
| 84 | |||
| 85 | -- Like accept_and_send, but scripts several accept-send-close cycles as one | ||
| 86 | -- server-side script sent over a single remote() round trip. Needed for a | ||
| 87 | -- client call that opens more than one connection in sequence within a | ||
| 88 | -- single call of its own (e.g. socket.http.request following a redirect): | ||
| 89 | -- queuing a second accept_and_send for that connection ahead of time would | ||
| 90 | -- deadlock, since its remote() call can't get an ack until the server | ||
| 91 | -- finishes the first accept_and_send's blocking accept() -- which itself | ||
| 92 | -- can't complete until the client makes the very call that's stuck waiting | ||
| 93 | -- on that ack. `connections` is an array of `chunks` arrays, one per | ||
| 94 | -- connection, handled in order. | ||
| 95 | function M.accept_and_send_sequence(remote, connections) | ||
| 96 | local parts = {} | ||
| 97 | for _, chunks in ipairs(connections) do | ||
| 98 | append_connection(parts, chunks) | ||
| 99 | end | ||
| 77 | remote(table.concat(parts, "\n")) | 100 | remote(table.concat(parts, "\n")) |
| 78 | end | 101 | end |
| 79 | 102 | ||
diff --git a/test/ssehttptest.lua b/test/ssehttptest.lua new file mode 100644 index 0000000..b4ba6bc --- /dev/null +++ b/test/ssehttptest.lua | |||
| @@ -0,0 +1,151 @@ | |||
| 1 | -- End-to-end coverage proving socket.http.request, the headers_callback | ||
| 2 | -- hook, and socket.sse cooperate correctly over a real socket -- using the | ||
| 3 | -- httpfixture.lua harness (see httpfixturetest.lua) instead of Apache or a | ||
| 4 | -- third-party host. Also covers the two headers_callback interactions that | ||
| 5 | -- only matter once other request-handling logic (redirects, | ||
| 6 | -- shouldreceivebody) is in the path. | ||
| 7 | local socket = require("socket") | ||
| 8 | local http = require("socket.http") | ||
| 9 | local ltn12 = require("ltn12") | ||
| 10 | local sse = require("socket.sse") | ||
| 11 | local fixture = require("httpfixture") | ||
| 12 | |||
| 13 | dofile("testsupport.lua") | ||
| 14 | |||
| 15 | local url = "http://" .. fixture.host .. ":" .. fixture.port .. "/" | ||
| 16 | |||
| 17 | local control, remote = fixture.connect() | ||
| 18 | |||
| 19 | io.write("testing a live SSE stream dispatches Messages incrementally, not batched until close: ") | ||
| 20 | do | ||
| 21 | local received, elapsed = {}, {} | ||
| 22 | local t0 | ||
| 23 | local factory = sse.responseheaders(function(message) | ||
| 24 | table.insert(received, message) | ||
| 25 | table.insert(elapsed, socket.gettime() - t0) | ||
| 26 | return true | ||
| 27 | end) | ||
| 28 | |||
| 29 | -- Framed as HTTP chunked-transfer-encoding, one SSE Message per HTTP | ||
| 30 | -- chunk: the "until-closed"/"default" body source reads in fixed | ||
| 31 | -- socket.BLOCKSIZE (2048-byte) gulps, which would silently buffer both | ||
| 32 | -- of these small Messages into one read and defeat this test; the | ||
| 33 | -- chunked source instead reads exactly each chunk's declared size, so | ||
| 34 | -- Message 1 surfaces as soon as its chunk arrives, independent of | ||
| 35 | -- Message 2's delayed chunk. | ||
| 36 | local function httpchunk(piece) | ||
| 37 | return string.format("%x\r\n%s\r\n", #piece, piece) | ||
| 38 | end | ||
| 39 | |||
| 40 | -- generous relative to the artificial delay so a loaded CI runner's | ||
| 41 | -- scheduling jitter can't turn correct incremental behavior into a | ||
| 42 | -- flaky failure; the delay is deliberately long enough that "arrived | ||
| 43 | -- before it" and "arrived after it" stay unambiguous even with slack | ||
| 44 | local delay = 1.0 | ||
| 45 | local headersblock = fixture.response("200 OK", { | ||
| 46 | "Content-Type: text/event-stream", | ||
| 47 | "Transfer-Encoding: chunked", | ||
| 48 | "Connection: close", | ||
| 49 | }, "") | ||
| 50 | fixture.accept_and_send(remote, { | ||
| 51 | headersblock, | ||
| 52 | httpchunk("data: first\n\n"), | ||
| 53 | { httpchunk("data: second\n\n"), delay = delay }, | ||
| 54 | "0\r\n\r\n", | ||
| 55 | }) | ||
| 56 | |||
| 57 | t0 = socket.gettime() | ||
| 58 | local ok, code = assert(http.request{ url = url .. "sse", headers_callback = factory }) | ||
| 59 | assert(ok, "request failed") | ||
| 60 | assert(code == 200, "status code mismatch: " .. tostring(code)) | ||
| 61 | assert(#received == 2, "expected two messages, got " .. #received) | ||
| 62 | assert(received[1].data == "first", "wrong data for message 1: " .. tostring(received[1].data)) | ||
| 63 | assert(received[2].data == "second", "wrong data for message 2: " .. tostring(received[2].data)) | ||
| 64 | -- message 1 must have been dispatched well before the server even sent | ||
| 65 | -- (let alone finished delaying) message 2 -- if delivery were batched | ||
| 66 | -- until the connection closed instead, both messages would only appear | ||
| 67 | -- once the full delay had elapsed | ||
| 68 | assert(elapsed[1] < delay / 2, | ||
| 69 | "message 1 dispatched too late (" .. elapsed[1] .. "s) to have arrived before message 2's delayed send") | ||
| 70 | assert(elapsed[2] >= delay / 2, | ||
| 71 | "message 2 dispatched suspiciously early (" .. elapsed[2] .. "s); the server's artificial delay may not have been exercised") | ||
| 72 | end | ||
| 73 | print("ok") | ||
| 74 | |||
| 75 | io.write("testing headers_callback is not invoked for a redirected (3xx) response: ") | ||
| 76 | do | ||
| 77 | local calls = {} | ||
| 78 | local function responseheaders(code) | ||
| 79 | table.insert(calls, code) | ||
| 80 | return true | ||
| 81 | end | ||
| 82 | |||
| 83 | local redirect = fixture.response("302 Found", { | ||
| 84 | "Location: /final", | ||
| 85 | "Content-Length: 0", | ||
| 86 | "Connection: close", | ||
| 87 | }, "") | ||
| 88 | local finalbody = "landed" | ||
| 89 | local final = fixture.response("200 OK", { | ||
| 90 | "Content-Length: " .. #finalbody, | ||
| 91 | "Connection: close", | ||
| 92 | }, finalbody) | ||
| 93 | fixture.accept_and_send_sequence(remote, { { redirect }, { final } }) | ||
| 94 | |||
| 95 | local target = {} | ||
| 96 | local ok, code = assert(http.request{ | ||
| 97 | url = url .. "redirect-me", | ||
| 98 | sink = ltn12.sink.table(target), | ||
| 99 | headers_callback = responseheaders, | ||
| 100 | }) | ||
| 101 | assert(ok, "request failed") | ||
| 102 | assert(table.concat(target) == finalbody, "expected the redirected response's body") | ||
| 103 | assert(code == 200, "expected the final response's status code, got " .. tostring(code)) | ||
| 104 | assert(#calls == 1, "expected headers_callback to be invoked exactly once, got " .. #calls) | ||
| 105 | assert(calls[1] == 200, "headers_callback must only ever see the final response, got " .. tostring(calls[1])) | ||
| 106 | end | ||
| 107 | print("ok") | ||
| 108 | |||
| 109 | -- Shared by the three shouldreceivebody variants below (204, 304, HEAD): | ||
| 110 | -- scripts `response`, issues a request built from `reqtextra`, and asserts | ||
| 111 | -- headers_callback still fires with `expectedcode` while the sink it offers | ||
| 112 | -- is never actually invoked, since shouldreceivebody skips the body. | ||
| 113 | local function assertsinkswapskipped(reqtextra, response, expectedcode) | ||
| 114 | local sinkcalls, invokedwith = 0, nil | ||
| 115 | local function responseheaders(code) | ||
| 116 | invokedwith = code | ||
| 117 | return true, function() sinkcalls = sinkcalls + 1; return 1 end | ||
| 118 | end | ||
| 119 | fixture.accept_and_send(remote, { response }) | ||
| 120 | |||
| 121 | local reqt = { url = url .. "skip-body", headers_callback = responseheaders } | ||
| 122 | for k, v in pairs(reqtextra or {}) do reqt[k] = v end | ||
| 123 | local ok, code = assert(http.request(reqt)) | ||
| 124 | assert(ok, "request failed") | ||
| 125 | assert(code == expectedcode, "status code mismatch: " .. tostring(code)) | ||
| 126 | assert(invokedwith == expectedcode, "expected headers_callback to still be invoked, got " .. tostring(invokedwith)) | ||
| 127 | assert(sinkcalls == 0, "the offered sink must never run when shouldreceivebody skips the body") | ||
| 128 | end | ||
| 129 | |||
| 130 | io.write("testing the headers_callback sink-swap offer is inert when a 204 response skips the body: ") | ||
| 131 | assertsinkswapskipped(nil, fixture.response("204 No Content", { "Connection: close" }, ""), 204) | ||
| 132 | print("ok") | ||
| 133 | |||
| 134 | io.write("testing the headers_callback sink-swap offer is inert when a 304 response skips the body: ") | ||
| 135 | assertsinkswapskipped(nil, fixture.response("304 Not Modified", { "Connection: close" }, ""), 304) | ||
| 136 | print("ok") | ||
| 137 | |||
| 138 | io.write("testing the headers_callback sink-swap offer is inert when a HEAD request skips the body: ") | ||
| 139 | do | ||
| 140 | local body = "should never be read" | ||
| 141 | local response = fixture.response("200 OK", { | ||
| 142 | "Content-Length: " .. #body, | ||
| 143 | "Connection: close", | ||
| 144 | }, body) | ||
| 145 | assertsinkswapskipped({ method = "HEAD" }, response, 200) | ||
| 146 | end | ||
| 147 | print("ok") | ||
| 148 | |||
| 149 | remote("os.exit()") | ||
| 150 | |||
| 151 | print("the library passed all tests") | ||
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") | ||
