Source
tests/purrc/requests_sys_test.cpp
1
// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
// System tests for `requests` — THE FIRST PURE-CHEATAH STDLIB MODULE. Each test runs a real4
// loopback HTTP server (cheatah::socket, C++ thread), then compiles + runs a .purr program5
// that `import requests` and GETs from it over a genuine TCP connection, asserting stdout.6
#include <string>7
#include <thread>9
#include "e2e_harness.hpp"10
#include "socket.hpp"12
namespace sock = cheatah::socket;14
namespace {16
// Accept one connection, read the request head, send @p response verbatim, close.17
// @complexity O(1) @alloc the request buffer @test RequestsSys (helper)18
void serve_once(long long listen_fd, std::string response) {19
const long long client = sock::accept(listen_fd);20
if (client < 0) return;21
std::string request;22
while (request.find("\r\n\r\n") == std::string::npos) {23
const std::string chunk = sock::recv(client, 4096);24
if (chunk.empty()) break;25
request += chunk;26
}27
sock::sendall(client, response);28
sock::close(client);29
}31
} // namespace33
// GET against a live server: status, ok(), body — through pure-cheatah HTTP.34
TEST(RequestsSys, BasicGet) {35
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);36
ASSERT_GE(fd, 0);37
const long long port = sock::local_port(fd);38
std::thread server(serve_once, fd,39
"HTTP/1.1 200 OK\r\nContent-Length: 18\r\n\r\nhello from cheatah");40
const std::string src = "import requests\nimport io\n"41
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +42
"/greeting\")\n"43
"io.print(r.status_code)\nio.print(r.ok())\nio.print(r.body)\n";44
e2e::expect_e2e("requests_basic_get", src, "200\nTrue\nhello from cheatah\n");45
server.join();46
sock::close(fd);47
}49
// A 404 is a COMPLETED exchange: ok() false, error empty, body real.50
TEST(RequestsSys, NotFound) {51
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);52
ASSERT_GE(fd, 0);53
const long long port = sock::local_port(fd);54
std::thread server(serve_once, fd, "HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\n\r\nnope");55
const std::string src = "import requests\nimport io\n"56
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +57
"/missing\")\n"58
"io.print(r.status_code)\nio.print(r.ok())\nio.print(r.error == \"\")\n"59
"io.print(r.body)\n";60
e2e::expect_e2e("requests_not_found", src, "404\nFalse\nTrue\nnope\n");61
server.join();62
sock::close(fd);63
}65
// No Content-Length: the body is framed by connection close.66
TEST(RequestsSys, EofFraming) {67
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);68
ASSERT_GE(fd, 0);69
const long long port = sock::local_port(fd);70
std::thread server(serve_once, fd, "HTTP/1.1 200 OK\r\n\r\nuntil the very end");71
const std::string src = "import requests\nimport io\n"72
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +73
"/\")\nio.print(r.body)\n";74
e2e::expect_e2e("requests_eof", src, "until the very end\n");75
server.join();76
sock::close(fd);77
}79
// Error paths need no server: malformed URL, connection refused.80
TEST(RequestsSys, ErrorPaths) {81
e2e::expect_e2e("requests_errors", R"PURR(import requests82
import io83
let b = requests.get("not a url")84
io.print(b.error == "")85
let c = requests.get("http://127.0.0.1:9/")86
io.print(c.error == "")87
)PURR", "False\nFalse\n");88
}90
// https against a peer that is not a TLS server: refused with a tls error, never silent.91
TEST(RequestsSys, HttpsRefusesBadPeer) {92
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);93
ASSERT_GE(fd, 0);94
const long long port = sock::local_port(fd);95
std::thread peer([fd]() {96
const long long client = sock::accept(fd);97
sock::sendall(client, "plain text, not TLS\r\n");98
sock::close(client);99
});100
const std::string src = "import requests\nimport io\nimport string\n"101
"let o = requests.Options({.timeout_ms = 3000})\n"102
"let r = requests.get(\"https://127.0.0.1:" + std::to_string(port) +103
"/\", o)\nio.print(r.status_code)\nio.print(string.contains(r.error, \"tls\"))\n";104
e2e::expect_e2e("requests_https_bad_peer", src, "0\nTrue\n");105
peer.join();106
sock::close(fd);107
}109
// ---- parity matrix (ported from the C++ reference implementation's test suite) ----111
// Chunked transfer-encoding is decoded (hex sizes, extensions ignored, trailer consumed).112
TEST(RequestsSys, Chunked) {113
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);114
ASSERT_GE(fd, 0);115
const long long port = sock::local_port(fd);116
std::thread server(serve_once, fd,117
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"118
"4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n");119
const std::string src = "import requests\nimport io\n"120
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +121
"/\")\nio.print(r.ok())\nio.print(r.body)\n";122
e2e::expect_e2e("requests_chunked", src, "True\nWikipedia\n");123
server.join();124
sock::close(fd);125
}127
// Response headers are stored lowercased: lookup is case-insensitive either way.128
TEST(RequestsSys, HeaderLookup) {129
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);130
ASSERT_GE(fd, 0);131
const long long port = sock::local_port(fd);132
std::thread server(serve_once, fd,133
"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nX-Custom-Tag: abc123\r\n\r\nx");134
const std::string src = "import requests\nimport io\n"135
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +136
"/\")\n"137
"io.print(r.header(\"x-custom-tag\"))\n"138
"io.print(r.header(\"X-CUSTOM-TAG\"))\n"139
"io.print(r.header(\"absent\") == \"\")\n";140
e2e::expect_e2e("requests_headers", src, "abc123\nabc123\nTrue\n");141
server.join();142
sock::close(fd);143
}145
// Query params are appended and percent-encoded ('&', space, '~' unreserved).146
TEST(RequestsSys, QueryParams) {147
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);148
ASSERT_GE(fd, 0);149
const long long port = sock::local_port(fd);150
std::string captured;151
std::thread server([fd, &captured]() {152
const long long client = sock::accept(fd);153
while (captured.find("\r\n\r\n") == std::string::npos) {154
const std::string chunk = sock::recv(client, 4096);155
if (chunk.empty()) break;156
captured += chunk;157
}158
sock::sendall(client, "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx");159
sock::close(client);160
});161
const std::string full = "import requests\nimport io\n"162
"let o = requests.Options({.timeout_ms = 5000})\n"163
"o.params[\"symbol\"] = \"S&P 500~\"\n"164
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +165
"/q?fixed=1\", o)\nio.print(r.ok())\n";166
e2e::expect_e2e("requests_params", full, "True\n");167
server.join();168
sock::close(fd);169
EXPECT_NE(captured.find("GET /q?fixed=1&symbol=S%26P%20500~ HTTP/1.1"), std::string::npos)170
<< captured;171
}173
// Custom request headers go on the wire.174
TEST(RequestsSys, CustomHeaders) {175
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);176
ASSERT_GE(fd, 0);177
const long long port = sock::local_port(fd);178
std::string captured;179
std::thread server([fd, &captured]() {180
const long long client = sock::accept(fd);181
while (captured.find("\r\n\r\n") == std::string::npos) {182
const std::string chunk = sock::recv(client, 4096);183
if (chunk.empty()) break;184
captured += chunk;185
}186
sock::sendall(client, "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx");187
sock::close(client);188
});189
const std::string full = "import requests\nimport io\n"190
"let o = requests.Options({.timeout_ms = 5000})\n"191
"o.headers[\"X-Api-Key\"] = \"secret\"\n"192
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +193
"/\", o)\nio.print(r.ok())\n";194
e2e::expect_e2e("requests_custom_headers", full, "True\n");195
server.join();196
sock::close(fd);197
EXPECT_NE(captured.find("X-Api-Key: secret\r\n"), std::string::npos) << captured;198
}200
// 302 with a path-absolute Location is followed; the final URL lands in r.url.201
TEST(RequestsSys, Redirect) {202
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);203
ASSERT_GE(fd, 0);204
const long long port = sock::local_port(fd);205
std::thread server([fd]() {206
serve_once(fd, "HTTP/1.1 302 Found\r\nLocation: /moved\r\nContent-Length: 0\r\n\r\n");207
serve_once(fd, "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nfound me");208
});209
const std::string src = "import requests\nimport io\nimport string\n"210
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +211
"/start\")\nio.print(r.ok())\nio.print(r.body)\n"212
"io.print(string.contains(r.url, \"/moved\"))\n";213
e2e::expect_e2e("requests_redirect", src, "True\nfound me\nTrue\n");214
server.join();215
sock::close(fd);216
}218
// A redirect loop stops at max_redirects with a clear error.219
TEST(RequestsSys, RedirectLoop) {220
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);221
ASSERT_GE(fd, 0);222
const long long port = sock::local_port(fd);223
std::thread server([fd]() {224
for (int i = 0; i < 4; ++i)225
serve_once(fd, "HTTP/1.1 302 Found\r\nLocation: /again\r\nContent-Length: 0\r\n\r\n");226
});227
const std::string full = "import requests\nimport io\nimport string\n"228
"let o = requests.Options({.timeout_ms = 5000, .max_redirects = 3})\n"229
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +230
"/start\", o)\nio.print(r.ok())\n"231
"io.print(string.contains(r.error, \"too many redirects\"))\n";232
e2e::expect_e2e("requests_redirect_loop", full, "False\nTrue\n");233
server.join();234
sock::close(fd);235
}237
// A server that never answers trips the socket timeout — bounded, not hung.238
TEST(RequestsSys, Timeout) {239
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);240
ASSERT_GE(fd, 0);241
const long long port = sock::local_port(fd);242
std::thread server([fd]() {243
const long long client = sock::accept(fd);244
std::this_thread::sleep_for(std::chrono::milliseconds(800));245
sock::close(client);246
});247
const std::string full = "import requests\nimport io\n"248
"let o = requests.Options({.timeout_ms = 150})\n"249
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +250
"/slow\", o)\nio.print(r.ok())\nio.print(r.error == \"\")\n";251
const auto start = std::chrono::steady_clock::now();252
e2e::expect_e2e("requests_timeout", full, "False\nFalse\n");253
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(254
std::chrono::steady_clock::now() - start);255
// NOT a latency assertion: `elapsed` spans expect_e2e's whole purrc compile + link +256
// run, which dwarfs the 150 ms request and scales with machine load (this tripped at257
// 5 s during a parallel sanitizer run). It is a backstop against an UNBOUNDED hang —258
// a request that ignored the timeout would block on the server's 800 ms sleep and,259
// if the timeout were broken outright, never return. The timeout's real proof is the260
// expected "False\nFalse" above: the request failed instead of completing.261
EXPECT_LT(elapsed.count(), 120000) << "the request never returned — timeout did not bound it";262
server.join();263
sock::close(fd);264
}266
// End to end: GET a JSON body, then parse it STRAIGHT into a .purr struct via the typed267
// reader (the schema is synthesized by purrc) — requests + parsers composing in pure cheatah.268
TEST(RequestsSys, JsonIntegration) {269
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);270
ASSERT_GE(fd, 0);271
const long long port = sock::local_port(fd);272
std::thread server(serve_once, fd,273
"HTTP/1.1 200 OK\r\nContent-Length: 44\r\n"274
"Content-Type: application/json\r\n\r\n"275
R"({"symbol":"SPX","price":7386.65,"live":true})");276
const std::string src = "import requests\nimport parsers\nimport io\n"277
"struct Quote {\n symbol: str\n price: float\n live: bool\n}\n"278
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +279
"/quote\")\n"280
"let q = Quote(\"\", 0.0, false)\n"281
"if r.ok() and parsers.json.read(r.body, q) {\n"282
" io.print(q.symbol)\n io.print(q.price)\n io.print(q.live)\n}\n";283
e2e::expect_e2e("requests_json", src, "SPX\n7386.65\nTrue\n");284
server.join();285
sock::close(fd);286
}288
// Accept one connection, read the head AND any Content-Length body into @p captured, reply.289
// @complexity O(request size) @alloc the request buffer @test RequestsSys (helper)290
static void serve_capture(long long listen_fd, std::string* captured, std::string response) {291
const long long client = sock::accept(listen_fd);292
if (client < 0) return;293
while (captured->find("\r\n\r\n") == std::string::npos) {294
const std::string chunk = sock::recv(client, 4096);295
if (chunk.empty()) break;296
*captured += chunk;297
}298
const std::size_t he = captured->find("\r\n\r\n");299
const std::size_t clp = captured->find("Content-Length:");300
if (he != std::string::npos && clp != std::string::npos && clp < he) {301
const long long want = std::atoll(captured->c_str() + clp + 15);302
while (want > 0 && static_cast<long long>(captured->size() - (he + 4)) < want) {303
const std::string chunk = sock::recv(client, 4096);304
if (chunk.empty()) break;305
*captured += chunk;306
}307
}308
sock::sendall(client, response);309
sock::close(client);310
}312
// POST a JSON body built with to_json: the wire carries POST + application/json + the body.313
TEST(RequestsSys, PostJson) {314
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);315
ASSERT_GE(fd, 0);316
const long long port = sock::local_port(fd);317
std::string captured;318
std::thread server(serve_capture, fd, &captured,319
std::string("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"));320
const std::string src = "import requests\nimport io\n"321
"let o = requests.Options({.json_body = requests.to_json({\"side\": \"buy\"})})\n"322
"let r = requests.post(\"http://127.0.0.1:" + std::to_string(port) +323
"/order\", o)\nio.print(r.status_code)\nio.print(r.ok())\n";324
e2e::expect_e2e("requests_post_json", src, "200\nTrue\n");325
server.join();326
sock::close(fd);327
EXPECT_EQ(captured.rfind("POST /order ", 0), 0u) << captured;328
EXPECT_NE(captured.find("Content-Type: application/json\r\n"), std::string::npos) << captured;329
EXPECT_NE(captured.find("\r\n\r\n{\"side\":\"buy\"}"), std::string::npos) << captured;330
}332
// HTTP Basic auth puts the base64 Authorization header on the wire.333
TEST(RequestsSys, BasicAuth) {334
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);335
ASSERT_GE(fd, 0);336
const long long port = sock::local_port(fd);337
std::string captured;338
std::thread server(serve_capture, fd, &captured,339
std::string("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"));340
const std::string src = "import requests\nimport io\n"341
"let o = requests.Options({.auth_user = \"user\", .auth_pass = \"pass\"})\n"342
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +343
"/a\", o)\nio.print(r.ok())\n";344
e2e::expect_e2e("requests_basic_auth", src, "True\n");345
server.join();346
sock::close(fd);347
EXPECT_NE(captured.find("Authorization: Basic dXNlcjpwYXNz\r\n"), std::string::npos) << captured;348
}350
// requests.delete() sends the DELETE method (the verb name is a C++ keyword, escaped by codegen).351
TEST(RequestsSys, Delete) {352
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);353
ASSERT_GE(fd, 0);354
const long long port = sock::local_port(fd);355
std::string captured;356
std::thread server(serve_capture, fd, &captured,357
std::string("HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\ndeleted"));358
const std::string src = "import requests\nimport io\n"359
"let r = requests.delete(\"http://127.0.0.1:" + std::to_string(port) +360
"/thing\")\nio.print(r.status_code)\nio.print(r.text())\n";361
e2e::expect_e2e("requests_delete", src, "200\ndeleted\n");362
server.join();363
sock::close(fd);364
EXPECT_EQ(captured.rfind("DELETE /thing ", 0), 0u) << captured;365
}367
// HEAD yields headers only: an empty body even with a declared Content-Length.368
TEST(RequestsSys, Head) {369
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);370
ASSERT_GE(fd, 0);371
const long long port = sock::local_port(fd);372
std::thread server(serve_once, fd, "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n");373
const std::string src = "import requests\nimport io\n"374
"let r = requests.head(\"http://127.0.0.1:" + std::to_string(port) +375
"/h\")\nio.print(r.status_code)\nio.print(len(r.body))\n";376
e2e::expect_e2e("requests_head", src, "200\n0\n");377
server.join();378
sock::close(fd);379
}381
// raise_for_status() raises on a 5xx — caught by a cheatah try/except.382
TEST(RequestsSys, RaiseForStatus) {383
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);384
ASSERT_GE(fd, 0);385
const long long port = sock::local_port(fd);386
std::thread server(serve_once, fd, "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n");387
const std::string src = "import requests\nimport io\n"388
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +389
"/e\")\ntry {\n r.raise_for_status()\n io.print(\"no raise\")\n} except e {\n io.print(\"caught\")\n}\n";390
e2e::expect_e2e("requests_raise_for_status", src, "caught\n");391
server.join();392
sock::close(fd);393
}395
// no_redirect returns the 3xx directly instead of following it.396
TEST(RequestsSys, AllowRedirectsFalse) {397
const long long fd = sock::tcp_listen("127.0.0.1", 0, 4);398
ASSERT_GE(fd, 0);399
const long long port = sock::local_port(fd);400
std::thread server(serve_once, fd,401
"HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n");402
const std::string src = "import requests\nimport io\n"403
"let o = requests.Options({.no_redirect = true})\n"404
"let r = requests.get(\"http://127.0.0.1:" + std::to_string(port) +405
"/start\", o)\nio.print(r.status_code)\nio.print(r.is_redirect())\n";406
e2e::expect_e2e("requests_no_follow", src, "302\nTrue\n");407
server.join();408
sock::close(fd);409
}