test(esp_http_client): streaming API and error path characterization

Add max_write_chunk to the mock to force real short writes and fix its
capture ordering. Pin the open/write/fetch_headers/read sequence, short-write
retry, and the header read timeout (ESP_ERR_HTTP_FETCH_HEADER).
This commit is contained in:
Ashish Sharma
2026-09-05 16:46:50 +08:00
parent 017d40bd69
commit 68176c025e
4 changed files with 233 additions and 29 deletions
@@ -0,0 +1,137 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @file test_http_client_streaming.c
* @brief P0 Critical Tests: open/write/fetch_headers/read streaming path, partial
* transport writes, and read timeout during header fetch
*
* This file characterizes:
* - The manual streaming sequence (open -> write -> fetch_headers -> read) works
* end to end against a mock transport, matching the one-shot perform() path.
* - A transport that only accepts part of a write per call still delivers the
* full request body once the client has finished writing it out.
* - A read timeout while waiting for response headers surfaces as an error from
* esp_http_client_perform() (exact code pinned per characterization rules).
*/
#include <string.h>
#include "esp_http_client.h"
#include "unity.h"
#include "sdkconfig.h"
#include "test_http_client_mock_transport.h"
/*
* Every case in this file drives the client through a mock transport injected
* via esp_http_client_config_t::transport. Without custom transport support
* the clients would fall back to a real transport aimed at test-server.local,
* which does not exist, so the whole file compiles out.
*/
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
static const char *resp_200 =
"HTTP/1.1 200 OK\r\n"
"Content-Length: 5\r\n"
"\r\n"
"hello";
TEST_CASE("open/write/fetch_headers/read sequence works", "[esp_http_client][streaming][p0]")
{
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
mc.response_data = resp_200;
esp_transport_handle_t mock = mock_http_transport_create(&mc);
TEST_ASSERT_NOT_NULL(mock);
esp_http_client_config_t cfg = {
.url = "http://test-server.local/upload",
.method = HTTP_METHOD_POST,
.transport = mock,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
TEST_ASSERT_NOT_NULL(client);
const char *body = "abcde";
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_open(client, 5));
TEST_ASSERT_EQUAL(5, esp_http_client_write(client, body, 5));
TEST_ASSERT_EQUAL(5, esp_http_client_fetch_headers(client));
TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client));
char buf[16] = {0};
int rd = esp_http_client_read(client, buf, sizeof(buf));
TEST_ASSERT_EQUAL(5, rd);
TEST_ASSERT_EQUAL_STRING("hello", buf);
TEST_ASSERT_TRUE(esp_http_client_is_complete_data_received(client));
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_close(client));
esp_http_client_cleanup(client);
mock_http_transport_destroy(mock);
}
TEST_CASE("partial transport writes still send the full request", "[esp_http_client][streaming][p0]")
{
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
mc.mode = MOCK_TRANSPORT_MODE_WRITE_PARTIAL;
mc.response_data = resp_200;
/* MOCK_TRANSPORT_MODE_WRITE_PARTIAL alone (default bytes_before_error = -1)
* never actually splits a write - should_inject_error() short-circuits on
* a negative budget, so without this the mode is inert and the test below
* would pass for the wrong reason (it never exercises a short write).
* max_write_chunk forces every mock_write() call, header or body, to
* accept at most 4 bytes, so the client's write-retry loop genuinely has
* to run multiple times to get the 10-byte body through.
* See mock limitation note in test_http_client_mock_transport.h. */
mc.max_write_chunk = 4;
esp_transport_handle_t mock = mock_http_transport_create(&mc);
TEST_ASSERT_NOT_NULL(mock);
esp_http_client_config_t cfg = {
.url = "http://test-server.local/upload",
.method = HTTP_METHOD_POST,
.transport = mock,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
TEST_ASSERT_NOT_NULL(client);
esp_http_client_set_post_field(client, "0123456789", 10);
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client));
char req[2048];
TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock, req, sizeof(req), NULL));
TEST_ASSERT_NOT_NULL(strstr(req, "0123456789"));
esp_http_client_cleanup(client);
mock_http_transport_destroy(mock);
}
TEST_CASE("read timeout during headers returns fetch-header error", "[esp_http_client][streaming][p0][negative]")
{
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
mc.mode = MOCK_TRANSPORT_MODE_READ_TIMEOUT;
esp_transport_handle_t mock = mock_http_transport_create(&mc);
TEST_ASSERT_NOT_NULL(mock);
esp_http_client_config_t cfg = {
.url = "http://test-server.local/slow",
.timeout_ms = 100,
.transport = mock,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
TEST_ASSERT_NOT_NULL(client);
esp_err_t err = esp_http_client_perform(client);
/* characterization: master behavior, see refactor spec
* mock_read() under MOCK_TRANSPORT_MODE_READ_TIMEOUT returns -1/ETIMEDOUT
* (not the ERR_TCP_TRANSPORT_CONNECTION_TIMEOUT == 0 sentinel), so
* esp_http_client_fetch_headers() takes its generic-failure branch
* (returns ESP_FAIL) rather than its -ESP_ERR_HTTP_EAGAIN branch, and
* esp_http_client_perform() surfaces that as ESP_ERR_HTTP_FETCH_HEADER. */
TEST_ASSERT_EQUAL(ESP_ERR_HTTP_FETCH_HEADER, err);
esp_http_client_cleanup(client);
mock_http_transport_destroy(mock);
}
#endif // CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
@@ -268,6 +268,68 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou
return to_read;
}
/**
* @brief Append accepted write bytes into the request-capture buffer
*
* Shared by every mock_write() path that accepts bytes (full accept,
* max_write_chunk-capped accept, and the WRITE_PARTIAL short-accept branch)
* so req_capture always reflects exactly what the mock reported as written
* to the caller - never more, and never skipped for a partial accept.
*/
static void mock_capture_bytes(mock_http_transport_ctx_t *ctx, const char *buffer, size_t n)
{
size_t space = sizeof(ctx->req_capture) - ctx->req_capture_len;
size_t copy = n < space ? n : space;
memcpy(ctx->req_capture + ctx->req_capture_len, buffer, copy);
ctx->req_capture_len += copy;
}
/**
* @brief Check MOCK_TRANSPORT_MODE_WRITE_FAIL and break the connection if triggered
*
* @return true if the caller should report the write as failed (EPIPE)
*/
static bool mock_write_check_fail(mock_http_transport_ctx_t *ctx, int accept_len)
{
if (ctx->config.mode != MOCK_TRANSPORT_MODE_WRITE_FAIL || !should_inject_error(ctx, accept_len)) {
return false;
}
ESP_LOGI(TAG, "Mock write: FAILED (simulated) - connection broken");
// Write failure (EPIPE) indicates broken connection - this simulates
// real-world behavior where write errors break the connection
ctx->is_connected = false;
errno = EPIPE;
return true;
}
/**
* @brief Check MOCK_TRANSPORT_MODE_WRITE_PARTIAL and, if triggered, capture
* and account for the short accept
*
* @return Number of bytes short-accepted (> 0) if a partial write was
* performed and the caller should return that count directly, or 0
* if WRITE_PARTIAL did not trigger and the caller should proceed
* with its normal (possibly max_write_chunk-capped) full accept.
*/
static int mock_write_try_partial(mock_http_transport_ctx_t *ctx, const char *buffer, int accept_len)
{
if (ctx->config.mode != MOCK_TRANSPORT_MODE_WRITE_PARTIAL || !should_inject_error(ctx, accept_len)) {
return 0;
}
int partial = ctx->config.bytes_before_error - ctx->bytes_processed;
if (partial <= 0 || partial >= accept_len) {
return 0;
}
// Capture exactly the bytes reported as written - this path used to
// return before the capture code below ever ran, so a partially-accepted
// write silently vanished from req_capture.
mock_capture_bytes(ctx, buffer, (size_t)partial);
ctx->bytes_processed += partial;
ctx->stats.total_bytes_written += partial;
ESP_LOGI(TAG, "Mock write: partial write %d bytes (out of %d)", partial, accept_len);
return partial;
}
/**
* @brief Mock write implementation
*/
@@ -300,30 +362,24 @@ static int mock_write(esp_transport_handle_t t, const char *buffer, int len, int
ESP_LOGI(TAG, "Mock write: %d bytes (mode=%d, bytes_processed=%zu)",
len, ctx->config.mode, ctx->bytes_processed);
// Optional per-call cap: simulate a transport whose send buffer only
// accepts up to max_write_chunk bytes per call, independent of `mode`.
// 0 (default) leaves accept_len == len, so every pre-existing test's
// behavior (including the byte-budget math below) is unchanged.
int accept_len = len;
if (ctx->config.max_write_chunk > 0 && accept_len > ctx->config.max_write_chunk) {
accept_len = ctx->config.max_write_chunk;
}
// Handle write failure mode
if (ctx->config.mode == MOCK_TRANSPORT_MODE_WRITE_FAIL) {
if (should_inject_error(ctx, len)) {
ESP_LOGI(TAG, "Mock write: FAILED (simulated) - connection broken");
// Write failure (EPIPE) indicates broken connection
// This simulates real-world behavior where write errors break the connection
ctx->is_connected = false;
errno = EPIPE;
return -1;
}
if (mock_write_check_fail(ctx, accept_len)) {
return -1;
}
// Handle partial write mode
if (ctx->config.mode == MOCK_TRANSPORT_MODE_WRITE_PARTIAL) {
if (should_inject_error(ctx, len)) {
// Write only partial data
int partial = ctx->config.bytes_before_error - ctx->bytes_processed;
if (partial > 0 && partial < len) {
ctx->bytes_processed += partial;
ctx->stats.total_bytes_written += partial;
ESP_LOGI(TAG, "Mock write: partial write %d bytes (out of %d)", partial, len);
return partial;
}
}
int partial = mock_write_try_partial(ctx, buffer, accept_len);
if (partial > 0) {
return partial;
}
// Request boundary: previous response fully consumed and a new request starts.
@@ -339,21 +395,18 @@ static int mock_write(esp_transport_handle_t t, const char *buffer, int len, int
// Capture written bytes (request content) for test assertions, capped to
// avoid overflowing the fixed-size buffer.
size_t space = sizeof(ctx->req_capture) - ctx->req_capture_len;
size_t copy = (size_t)len < space ? (size_t)len : space;
memcpy(ctx->req_capture + ctx->req_capture_len, buffer, copy);
ctx->req_capture_len += copy;
mock_capture_bytes(ctx, buffer, (size_t)accept_len);
// Normal write (just track it, don't actually store)
ctx->bytes_processed += len;
ctx->bytes_processed += accept_len;
if (ctx->config.track_calls) {
ctx->stats.total_bytes_written += len;
ctx->stats.total_bytes_written += accept_len;
}
ESP_LOGI(TAG, "Mock write: completed %d bytes (total_processed=%zu)",
len, ctx->bytes_processed);
return len;
accept_len, ctx->bytes_processed);
return accept_len;
}
/**
@@ -44,6 +44,17 @@ typedef struct {
inside the response body regardless of request size. -1 (default)
disables this and falls back to the shared bytes_before_error/
bytes_processed counter used by the original tests. */
int max_write_chunk; /*!< If > 0, caps every mock_write() call's accepted byte count to at
most this many bytes, independent of `mode`. Simulates a transport
whose per-call send buffer is smaller than the caller's write,
forcing the client's short-write retry loop (esp_http_client_write()
/ esp_http_client_request_send()) to run across multiple calls.
Added because MOCK_TRANSPORT_MODE_WRITE_PARTIAL's existing
bytes_before_error budget only splits a write when the budget lands
inside that specific call, which is brittle (coupled to exact
serialized header length); this field reliably forces a split on
every call regardless of request size. 0 (default) = no cap, and
existing tests are unaffected. */
} mock_http_transport_config_t;
/**
@@ -60,6 +71,7 @@ typedef struct {
.would_block_reads = 0, \
.would_block_writes = 0, \
.read_bytes_before_error = -1, \
.max_write_chunk = 0, \
}
/**
@@ -8,4 +8,6 @@ from pytest_embedded_idf.utils import idf_parametrize
@pytest.mark.qemu
@idf_parametrize('target', ['esp32c3'], indirect=['target'])
def test_http_client_mock(dut: Dut) -> None:
dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle', 'chunked', 'redirect', 'auth'], timeout=120)
dut.run_all_single_board_cases(
group=['basic', 'async', 'lifecycle', 'chunked', 'redirect', 'auth', 'streaming'], timeout=120
)