diff --git a/components/esp_http_client/test_apps/main/test_http_client_streaming.c b/components/esp_http_client/test_apps/main/test_http_client_streaming.c new file mode 100644 index 00000000000..eb61f244e98 --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_streaming.c @@ -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 +#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 diff --git a/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.c b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.c index dba22d83f00..1ab0ec80dc8 100644 --- a/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.c +++ b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.c @@ -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; } /** diff --git a/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.h b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.h index f91ca9acdfe..1a202b7a08b 100644 --- a/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.h +++ b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.h @@ -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, \ } /** diff --git a/components/esp_http_client/test_apps/pytest_stage0_qemu.py b/components/esp_http_client/test_apps/pytest_stage0_qemu.py index c8d6a015809..de11fc9d5c0 100644 --- a/components/esp_http_client/test_apps/pytest_stage0_qemu.py +++ b/components/esp_http_client/test_apps/pytest_stage0_qemu.py @@ -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 + )