diff --git a/components/esp_http_client/test_apps/main/test_http_client_chunked.c b/components/esp_http_client/test_apps/main/test_http_client_chunked.c new file mode 100644 index 00000000000..d621bdd2fca --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_chunked.c @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_chunked.c + * @brief P0 Critical Tests: chunked transfer-encoding decode and mid-chunk FIN handling + * + * This file characterizes: + * - A well-formed chunked response decodes correctly and is reported via + * esp_http_client_is_chunked_response(), and the decoded body content + * matches the concatenation of the chunk payloads. + * - A connection that drops mid-chunk (FIN before the chunked stream is + * terminated) surfaces as an error from esp_http_client_perform(), not a + * silent ESP_OK. + */ + +#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_chunked = + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n" + "6\r\n world\r\n" + "0\r\n\r\n"; + +/* Accumulates HTTP_EVENT_ON_DATA payloads into a NUL-terminated buffer so the + * decode test can assert on the actual decoded chunked body, not just that + * decoding "succeeded" per the status/return code. */ +static char s_body_buf[32]; +static size_t s_body_len; + +static esp_err_t body_capture_handler(esp_http_client_event_t *evt) +{ + if (evt->event_id == HTTP_EVENT_ON_DATA) { + size_t space = sizeof(s_body_buf) - 1 - s_body_len; + size_t copy = (size_t)evt->data_len < space ? (size_t)evt->data_len : space; + memcpy(s_body_buf + s_body_len, evt->data, copy); + s_body_len += copy; + s_body_buf[s_body_len] = '\0'; + } + return ESP_OK; +} + +TEST_CASE("chunked response decodes and reports is_chunked", "[esp_http_client][chunked][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_chunked; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + + s_body_len = 0; + s_body_buf[0] = '\0'; + esp_http_client_config_t cfg = { + .url = "http://test-server.local/stream", + .event_handler = body_capture_handler, + .transport = mock, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + TEST_ASSERT_NOT_NULL(client); + + TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client)); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + TEST_ASSERT_TRUE(esp_http_client_is_chunked_response(client)); + + /* The two chunks ("hello" + " world") must decode to the concatenated + * payload, stripped of chunk-size lines and CRLF framing. */ + TEST_ASSERT_EQUAL(11, s_body_len); + TEST_ASSERT_EQUAL_STRING("hello world", s_body_buf); + + int chunk_len = 0; + TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_get_chunk_length(client, &chunk_len)); + /* characterization: master behavior, see refactor spec + * after the terminating "0\r\n\r\n" chunk has been consumed, + * get_chunk_length() reports 0 - it reflects the last-seen chunk-size + * line (the zero-length terminator), not "unknown"/-1. */ + TEST_ASSERT_EQUAL(0, chunk_len); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("FIN mid-chunk surfaces an error, not success", "[esp_http_client][chunked][p0][negative]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.mode = MOCK_TRANSPORT_MODE_INCOMPLETE_READ; + mc.response_data = resp_chunked; + /* READ-side-only budget (independent of the 84-byte request write): + * resp_chunked's headers are 47 bytes; "5\r\nhell" is 7 more bytes into + * the first chunk's data ("hello" is 5 bytes, missing the last "o" and + * the trailing CRLF, and the whole second chunk). 47+7=54 truncates + * genuinely inside the first chunk's body, not at a header/chunk + * boundary and not before any response byte is read. */ + mc.read_bytes_before_error = 54; + 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/stream", + .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 + * master returns ESP_ERR_HTTP_INCOMPLETE_DATA for a chunked body + * genuinely truncated mid-chunk (as opposed to ESP_ERR_HTTP_EAGAIN, + * which is what a request-write-vs-read-budget race produces instead - + * see the commit message for how those two differ). On real hardware, + * a raw transport read of 0 for "connection closed" collides with the + * raw "read timeout" sentinel (both 0); which classification wins + * depends on leftover errno state, so master also returns + * ESP_ERR_HTTP_READ_TIMEOUT for this same input on some targets. Both + * are accepted here; the refactor branch's Stage 4 work fixes the + * underlying sentinel collision. */ + TEST_ASSERT_TRUE(err == ESP_ERR_HTTP_INCOMPLETE_DATA || err == ESP_ERR_HTTP_READ_TIMEOUT); + + 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 fe87fc8f8b0..dba22d83f00 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 @@ -21,6 +21,9 @@ typedef struct { bool is_connected; /*!< Connection state */ size_t read_offset; /*!< Current position in response data */ size_t bytes_processed; /*!< Bytes processed (for error injection) */ + size_t read_only_bytes; /*!< Bytes delivered via mock_read() alone (for + read_bytes_before_error injection, independent + of bytes written via mock_write()) */ char *response_buffer; /*!< Internal copy of response data */ int async_polls_left; /*!< Remaining "in progress" returns from mock_connect_async() */ int wb_reads_left; /*!< Remaining EAGAIN injections for mock_read() */ @@ -70,6 +73,19 @@ static bool should_inject_error(mock_http_transport_ctx_t *ctx, size_t bytes_abo return (ctx->bytes_processed + bytes_about_to_process) > (size_t)ctx->config.bytes_before_error; } +/** + * @brief Check if a READ-side-only error should be injected, based on bytes + * delivered via mock_read() alone (ignores mock_write() entirely) + */ +static bool should_inject_read_error(mock_http_transport_ctx_t *ctx, size_t bytes_about_to_process) +{ + if (ctx->config.read_bytes_before_error < 0) { + return false; // Read-side budget not configured + } + + return (ctx->read_only_bytes + bytes_about_to_process) > (size_t)ctx->config.read_bytes_before_error; +} + /** * @brief Mock connect implementation */ @@ -104,6 +120,7 @@ static int mock_connect(esp_transport_handle_t t, const char *host, int port, in ctx->is_connected = true; ctx->read_offset = 0; ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; ESP_LOGI(TAG, "Mock connect succeeded"); return 0; @@ -143,6 +160,7 @@ static int mock_connect_async(esp_transport_handle_t t, const char *host, int po ctx->is_connected = true; ctx->read_offset = 0; ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; ESP_LOGI(TAG, "Mock connect_async succeeded"); return 1; /* ASYNC_TRANS_CONNECT_PASS */ @@ -202,15 +220,28 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou // Determine how much to read size_t to_read = (len < remaining) ? len : remaining; - // Handle incomplete read mode (close connection mid-stream) + // Handle incomplete read mode (close connection mid-stream). + // read_bytes_before_error, when set (>= 0), is a READ-side-only budget: + // it is checked against bytes delivered via mock_read() alone, so + // truncation lands at a byte offset inside the response body + // regardless of how many bytes the request write consumed. When unset + // (-1, the default), falls back to the original shared bytes_processed + // counter (also incremented by mock_write()) used by earlier tests. if (ctx->config.mode == MOCK_TRANSPORT_MODE_INCOMPLETE_READ) { - if (should_inject_error(ctx, to_read)) { + bool use_read_budget = (ctx->config.read_bytes_before_error >= 0); + bool inject = use_read_budget ? should_inject_read_error(ctx, to_read) + : should_inject_error(ctx, to_read); + if (inject) { + size_t budget = use_read_budget ? (size_t)ctx->config.read_bytes_before_error + : (size_t)ctx->config.bytes_before_error; + size_t processed = use_read_budget ? ctx->read_only_bytes : ctx->bytes_processed; // Read partial data then close connection - size_t partial = ctx->config.bytes_before_error - ctx->bytes_processed; + size_t partial = budget - processed; if (partial > 0 && partial < to_read) { memcpy(buffer, ctx->response_buffer + ctx->read_offset, partial); ctx->read_offset += partial; ctx->bytes_processed += partial; + ctx->read_only_bytes += partial; ctx->stats.total_bytes_read += partial; ESP_LOGD(TAG, "Mock read: incomplete data %zu bytes, then EOF", partial); return partial; @@ -226,6 +257,7 @@ static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeou memcpy(buffer, ctx->response_buffer + ctx->read_offset, to_read); ctx->read_offset += to_read; ctx->bytes_processed += to_read; + ctx->read_only_bytes += to_read; if (ctx->config.track_calls) { ctx->stats.total_bytes_read += to_read; @@ -343,6 +375,7 @@ static int mock_close(esp_transport_handle_t t) ctx->is_connected = false; ctx->read_offset = 0; ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; return 0; } @@ -540,6 +573,7 @@ esp_err_t mock_http_transport_set_config(esp_transport_handle_t transport, // The connection state should be managed through connect/close calls ctx->read_offset = 0; ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; // Re-initialize error-injection countdown counters from the new config ctx->async_polls_left = ctx->config.async_connect_polls; @@ -617,6 +651,7 @@ esp_err_t mock_http_transport_set_response(esp_transport_handle_t transport, // Reset read position ctx->read_offset = 0; ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; ESP_LOGD(TAG, "Mock transport response updated (%zu bytes)", ctx->config.response_len); return ESP_OK; 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 9a41be0bee4..f91ca9acdfe 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 @@ -38,6 +38,12 @@ typedef struct { int async_connect_polls; /*!< connect_async returns "in progress" this many times, then succeeds */ int would_block_reads; /*!< First N reads return -1 with errno = EAGAIN */ int would_block_writes; /*!< First N writes return -1 with errno = EAGAIN */ + int read_bytes_before_error; /*!< READ-side-only error budget for MOCK_TRANSPORT_MODE_INCOMPLETE_READ: + counts only bytes actually delivered via mock_read(), independent of + mock_write()'s byte count, so truncation can be pinned to an offset + 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. */ } mock_http_transport_config_t; /** @@ -53,6 +59,7 @@ typedef struct { .async_connect_polls = 0, \ .would_block_reads = 0, \ .would_block_writes = 0, \ + .read_bytes_before_error = -1, \ } /** 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 4f4179f5b19..3730bf1c44b 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,4 @@ 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'], timeout=120) + dut.run_all_single_board_cases(group=['basic', 'async', 'lifecycle', 'chunked'], timeout=120)