mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
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:
@@ -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
|
||||
+81
-28
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user