diff --git a/components/esp_http_client/test_apps/main/CMakeLists.txt b/components/esp_http_client/test_apps/main/CMakeLists.txt index e063a6242eb..4a8e9bddfda 100644 --- a/components/esp_http_client/test_apps/main/CMakeLists.txt +++ b/components/esp_http_client/test_apps/main/CMakeLists.txt @@ -1,5 +1,8 @@ -idf_component_register(SRC_DIRS "." +set(mock_transport_dir ${CMAKE_CURRENT_SOURCE_DIR}/../mock_transport) + +idf_component_register(SRC_DIRS "." ${mock_transport_dir} PRIV_INCLUDE_DIRS "." "../../lib/include" + ${mock_transport_dir} PRIV_REQUIRES esp_http_client tcp_transport test_utils unity WHOLE_ARCHIVE) diff --git a/components/esp_http_client/test_apps/main/test_http_client.c b/components/esp_http_client/test_apps/main/test_http_client.c index af8b5780100..ece6185d50a 100644 --- a/components/esp_http_client/test_apps/main/test_http_client.c +++ b/components/esp_http_client/test_apps/main/test_http_client.c @@ -14,6 +14,11 @@ #include "test_utils.h" #include "sdkconfig.h" +#include "esp_log.h" + +#include "test_http_client_mock_transport.h" +#include "esp_transport.h" + #define HOST "httpbin.org" #define USERNAME "user" #define PASSWORD "challenge" @@ -439,6 +444,368 @@ TEST_CASE("esp_http_client_request_send fails when an oversized header is mid-li #endif // CONFIG_ESP_HTTP_CLIENT_STRICT_HEADER_BUFFER +#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT +/* ============================================ + * Error Recovery Tests with Mock Transport + * + * Every case below injects a mock transport through + * esp_http_client_config_t::transport. Without custom transport support the + * clients would fall back to a real transport aimed at a host that does not + * exist, so the whole section compiles out. + * ============================================ */ + +/** + * @brief Canned HTTP response for successful requests + * Note: Content-Length must match the actual body length exactly + */ +static const char *mock_http_response_ok = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 15\r\n" // Actual body is 15 bytes: {"status":"ok"} + "\r\n" + "{\"status\":\"ok\"}"; + +static esp_err_t _http_event_handler(esp_http_client_event_t *evt) +{ + switch (evt->event_id) { + case HTTP_EVENT_ON_CONNECTED: + ESP_LOGI("test", "Connected"); + break; + case HTTP_EVENT_DISCONNECTED: + ESP_LOGI("test", "Disconnected"); + break; + case HTTP_EVENT_HEADERS_SENT: + ESP_LOGI("test", "Headers sent"); + break; + case HTTP_EVENT_ON_HEADER: + ESP_LOGI("test", "Header received"); + break; + case HTTP_EVENT_ON_DATA: + ESP_LOGI("test", "Data received"); + break; + case HTTP_EVENT_ON_FINISH: + ESP_LOGI("test", "Request finished"); + break; + case HTTP_EVENT_ERROR: + ESP_LOGI("test", "Error occurred"); + break; + default: + break; + } + return ESP_OK; +} + +/** + * Test: Client reuse after read timeout + * + * Scenario: First request times out while waiting for response, + * second request should succeed with same client + * + * Expected: Client properly recovers and second request works + */ +TEST_CASE("HTTP client can be reused after read timeout", "[esp_http_client][error_recovery]") +{ + // Note: Event loop initialization is optional for these tests + // The ESP_ERR_INVALID_STATE errors are expected if not initialized + // They don't affect the core functionality being tested + + // ========== REQUEST 1: Timeout mode ========== + ESP_LOGI("test", "Request 1: Simulating read timeout"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_READ_TIMEOUT; + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create client with custom transport (disable event posting to avoid errors) + esp_http_client_config_t config = { + .url = "http://mock-server.local/test", + .timeout_ms = 1000, + .is_async = false, + .event_handler = _http_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // This should timeout + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + ESP_LOGI("test", "Request 1 failed as expected: %s", esp_err_to_name(err)); + + // Verify transport was called + mock_http_transport_stats_t stats = {0}; + mock_http_transport_get_stats(mock_transport, &stats); + TEST_ASSERT_GREATER_THAN(0, stats.connect_calls); + + // ========== REQUEST 2: Normal mode with same client ========== + ESP_LOGI("test", "Request 2: Normal operation with reused client"); + + // Reconfigure mock for success + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = mock_http_response_ok; + mock_config.response_len = strlen(mock_http_response_ok); + + mock_http_transport_set_config(mock_transport, &mock_config); + mock_http_transport_reset_stats(mock_transport); + + // This should succeed + err = esp_http_client_perform(client); + if (err != ESP_OK) { + ESP_LOGE("test", "Request 2 failed: %s (0x%x)", esp_err_to_name(err), err); + } + TEST_ASSERT_EQUAL(ESP_OK, err); + + int status_code = esp_http_client_get_status_code(client); + if (status_code != 200) { + ESP_LOGE("test", "Unexpected status code: %d", status_code); + } + TEST_ASSERT_EQUAL(200, status_code); + + ESP_LOGI("test", "Request 2 succeeded - client recovered!"); + + // Verify the second request actually happened + mock_http_transport_get_stats(mock_transport, &stats); + /* Master does not close the connection after a fetch-header failure, so the + * reused client never reconnects; it keeps reading on the same connection. */ + // characterization: master behavior, see refactor spec + TEST_ASSERT_EQUAL(0, stats.connect_calls); + TEST_ASSERT_GREATER_THAN(0, stats.read_calls); + + // Cleanup + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Client reuse after write failure + * + * Scenario: First POST request fails during body write, + * second POST request should succeed with same client + * + * Expected: Client properly recovers and second request works + */ +TEST_CASE("HTTP client can be reused after write failure", "[esp_http_client][error_recovery]") +{ + // ========== REQUEST 1: Write failure during headers ========== + ESP_LOGI("test", "Request 1: Simulating write failure during headers"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_WRITE_FAIL; + // Fail after 100 bytes: this causes failure while writing HTTP headers + // (before POST body starts) + mock_config.bytes_before_error = 100; + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create client with custom transport + esp_http_client_config_t config = { + .url = "http://mock-server.local/post", + .method = HTTP_METHOD_POST, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Set POST data + const char *post_data = "{\"test\":\"data\",\"large\":\"" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "\"}"; + esp_http_client_set_post_field(client, post_data, strlen(post_data)); + + // This should fail during write + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + ESP_LOGI("test", "Request 1 failed as expected: %s", esp_err_to_name(err)); + + // ========== REQUEST 2: Write failure DURING POST body ========== + ESP_LOGI("test", "Request 2: Simulating write failure during POST body"); + + mock_config.mode = MOCK_TRANSPORT_MODE_WRITE_FAIL; + // Fail after 120 bytes: allows headers (~100 bytes) to be written, + // but fails during POST body write (which starts around byte 100-110) + mock_config.bytes_before_error = 170; + mock_http_transport_set_config(mock_transport, &mock_config); + mock_http_transport_reset_stats(mock_transport); + esp_http_client_set_post_field(client, post_data, strlen(post_data)); + + // This should fail during POST body write + err = esp_http_client_perform(client); + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + ESP_LOGI("test", "Request 2 failed as expected: %s", esp_err_to_name(err)); + + // ========== REQUEST 3: Normal mode with same client ========== + ESP_LOGI("test", "Request 3: Normal operation with reused client"); + + // Reconfigure mock for success + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = mock_http_response_ok; + mock_config.response_len = strlen(mock_http_response_ok); + mock_config.bytes_before_error = -1; // No error injection + + mock_http_transport_set_config(mock_transport, &mock_config); + mock_http_transport_reset_stats(mock_transport); + + // Set smaller POST data + const char *post_data2 = "{\"retry\":\"success\"}"; + esp_http_client_set_post_field(client, post_data2, strlen(post_data2)); + + /* Master leaves stale POST-body write state behind after the failed write, + * so the reused client fails immediately without touching the transport. */ + err = esp_http_client_perform(client); + // characterization: master behavior, see refactor spec + TEST_ASSERT_EQUAL(ESP_FAIL, err); + TEST_ASSERT_EQUAL(0, esp_http_client_get_status_code(client)); + + ESP_LOGI("test", "Request 3 did not recover: %s", esp_err_to_name(err)); + + // Cleanup + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Client reuse after incomplete data + * + * Scenario: First request gets incomplete response (connection closed mid-read), + * second request should succeed with same client + * + * Expected: Client properly recovers and second request works + */ +TEST_CASE("HTTP client can be reused after incomplete data", "[esp_http_client][error_recovery]") +{ + // ========== REQUEST 1: Incomplete response ========== + ESP_LOGI("test", "Request 1: Simulating incomplete response"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_INCOMPLETE_READ; + mock_config.response_data = mock_http_response_ok; + mock_config.response_len = strlen(mock_http_response_ok); + mock_config.bytes_before_error = 50; // Close connection after 50 bytes (mid-response) + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create client with custom transport + esp_http_client_config_t config = { + .url = "http://mock-server.local/incomplete", + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // This should fail due to incomplete data + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + ESP_LOGI("test", "Request 1 failed as expected: %s", esp_err_to_name(err)); + + // ========== REQUEST 2: Normal mode with same client ========== + ESP_LOGI("test", "Request 2: Normal operation with reused client"); + + // Reconfigure mock for success (complete response) + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.bytes_before_error = -1; // No error injection + + mock_http_transport_set_config(mock_transport, &mock_config); + mock_http_transport_reset_stats(mock_transport); + + /* Master does not reset the parser/connection state after the aborted read, + * so the reused client fails header fetching without touching the transport. */ + err = esp_http_client_perform(client); + // characterization: master behavior, see refactor spec + TEST_ASSERT_EQUAL(ESP_ERR_HTTP_FETCH_HEADER, err); + TEST_ASSERT_EQUAL(-1, esp_http_client_get_status_code(client)); + + ESP_LOGI("test", "Request 2 did not recover: %s", esp_err_to_name(err)); + + // Cleanup + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Multiple requests with alternating success/failure + * + * Scenario: Multiple requests with errors interspersed with successful requests + * + * Expected: Client can be reused multiple times after various error conditions + */ +TEST_CASE("HTTP client survives multiple error/success cycles", "[esp_http_client][error_recovery]") +{ + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create client with custom transport + esp_http_client_config_t config = { + .url = "http://mock-server.local/cycle", + .event_handler = _http_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Define test sequence: success, timeout, success, incomplete, success + // Note: We use INCOMPLETE_READ instead of WRITE_FAIL because write_fail + // requires POST data and bytes_before_error configuration + mock_transport_mode_t sequence[] = { + MOCK_TRANSPORT_MODE_NORMAL, + MOCK_TRANSPORT_MODE_READ_TIMEOUT, + MOCK_TRANSPORT_MODE_NORMAL, + MOCK_TRANSPORT_MODE_INCOMPLETE_READ, + MOCK_TRANSPORT_MODE_NORMAL, + }; + /* Master recovers from a read timeout (cycle 1 -> 2) but not from an aborted + * read (cycle 3), so the final cycle fails instead of succeeding. */ + // characterization: master behavior, see refactor spec + bool expected_success[] = {true, false, true, false, false}; + + for (int i = 0; i < sizeof(sequence) / sizeof(sequence[0]); i++) { + ESP_LOGI("test", "Cycle %d: mode=%d, expect %s", + i, sequence[i], expected_success[i] ? "SUCCESS" : "FAILURE"); + + // Configure mock + mock_config.mode = sequence[i]; + if (sequence[i] == MOCK_TRANSPORT_MODE_NORMAL) { + mock_config.response_data = mock_http_response_ok; + mock_config.response_len = strlen(mock_http_response_ok); + mock_config.bytes_before_error = -1; // No error injection + } else if (sequence[i] == MOCK_TRANSPORT_MODE_INCOMPLETE_READ) { + mock_config.response_data = mock_http_response_ok; + mock_config.response_len = strlen(mock_http_response_ok); + mock_config.bytes_before_error = 50; // Close after 50 bytes + } + mock_http_transport_set_config(mock_transport, &mock_config); + mock_http_transport_reset_stats(mock_transport); + + // Perform request + esp_err_t err = esp_http_client_perform(client); + + // Verify expectation + if (expected_success[i]) { + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + } else { + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + } + } + + ESP_LOGI("test", "Client survived %d error/success cycles!", + sizeof(sequence) / sizeof(sequence[0])); + + // Cleanup + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} +#endif // CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT + void app_main(void) { unity_run_menu(); diff --git a/components/esp_http_client/test_apps/main/test_http_client_async.c b/components/esp_http_client/test_apps/main/test_http_client_async.c new file mode 100644 index 00000000000..f00625a28bd --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_async.c @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_async.c + * @brief P0 characterization tests: async (is_async) behavior on master + * + * These tests document how esp_http_client_perform() behaves today when + * is_async is set and the transport reports EAGAIN/would-block at various + * stages (connect, read, write). They are characterization tests, not + * behavioral specs: assert values reflect what master actually does. + */ + +#include +#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("async connect yields EAGAIN then completes", "[esp_http_client][async][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_200; + mc.async_connect_polls = 2; /* two CONNECTING polls before success */ + 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/", + .is_async = true, + .transport = mock, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err; + int eagain_count = 0; + while ((err = esp_http_client_perform(client)) == ESP_ERR_HTTP_EAGAIN) { + eagain_count++; + TEST_ASSERT_LESS_THAN(20, eagain_count); /* no livelock */ + } + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + TEST_ASSERT_EQUAL(2, eagain_count); /* characterization: one per CONNECTING poll */ + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("async read would-block yields EAGAIN mid-response", "[esp_http_client][async][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_200; + mc.would_block_reads = 1; /* first read returns -1/EAGAIN */ + 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/", + .is_async = true, + .transport = mock, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err; + int eagain_count = 0; + while ((err = esp_http_client_perform(client)) == ESP_ERR_HTTP_EAGAIN) { + eagain_count++; + TEST_ASSERT_LESS_THAN(20, eagain_count); + } + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + TEST_ASSERT_GREATER_OR_EQUAL(1, eagain_count); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +/* + * NOTE on the name/assertions below: the brief for this test originally + * assumed write would-block gets the same async EAGAIN retry treatment as + * connect/read (see the other two cases in this file). It does not. + * + * esp_http_client_request_send()'s header-write loop (esp_http_client.c, + * the esp_transport_write() call that sends the first-line + headers) + * treats *any* non-positive return from esp_transport_write() as a + * permanent failure: it unconditionally calls esp_http_client_close() and + * returns ESP_ERR_HTTP_WRITE_DATA, with no client->is_async / errno==EAGAIN + * check at all. That check only exists on the "leftover data" continuation + * path and on the POST-body write path (esp_http_client_write()) -- not on + * the very first header write. So a would-block on the first transport + * write is NOT retried in async mode: it aborts the request and tears down + * the connection on the very first esp_http_client_perform() call, with no + * EAGAIN loop at all. (Even if that check were reached, mock_write()'s + * EAGAIN is set before esp_http_client_close()/ESP_LOGE run in the client, + * and errno is not guaranteed to still read EAGAIN by the time a caller + * downstream inspects it -- it was observed to be clobbered here.) + * + * The test name and assertions are updated to match this observed + * master behavior rather than the originally assumed one. + */ +TEST_CASE("async write would-block on header send aborts the request", "[esp_http_client][async][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_200; + mc.would_block_writes = 1; + 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/submit", + .method = HTTP_METHOD_POST, + .is_async = true, + .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, "k=v", 3); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT(err == ESP_ERR_HTTP_EAGAIN || err == ESP_ERR_HTTP_WRITE_DATA); + + mock_http_transport_stats_t stats; + mock_http_transport_get_stats(mock, &stats); + // characterization: master behavior, see refactor spec + TEST_ASSERT_EQUAL(0, stats.write_calls); /* would-blocked write is never counted as completed */ + TEST_ASSERT_EQUAL(1, stats.close_calls); /* connection force-closed on the failed header write */ + + char req[2048]; + size_t req_len = 0; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock, req, sizeof(req), &req_len)); + // characterization: master behavior, see refactor spec + TEST_ASSERT_EQUAL(0, req_len); /* nothing reached the wire: no body, so no duplicate either */ + + 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/main/test_http_client_auth.c b/components/esp_http_client/test_apps/main/test_http_client_auth.c new file mode 100644 index 00000000000..09deb5abb31 --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_auth.c @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_auth.c + * @brief P0 Critical Tests: 401 Basic auth retry within one perform() + * + * This test app's sdkconfig.ci.default sets + * CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y (upstream Kconfig default is + * "n" - Basic auth is unencrypted). That option is required for + * esp_http_client_add_auth() to recognize a "WWW-Authenticate: Basic ..." + * header at all: with it off, the "#if CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH" + * branch that sets auth_type and process_again is compiled out entirely, and + * a 401 falls into add_auth()'s unguarded "not supported" else-branch + * instead - perform() returns ESP_ERR_NOT_SUPPORTED and the 401 is never + * retried. That compiled-out shape is a static #ifdef fact (it cannot + * silently regress) and is documented in the commit history for this file + * rather than pinned as a permanent runtime test here. Stage 0 pins the + * TRUE retry path instead, since the refactor's auth-retry handling and + * counter-split fix build on it. + * + * This file characterizes: + * - A 401 response carrying a WWW-Authenticate: Basic header is answered + * automatically with a retried request that carries an Authorization: + * Basic header, within a single esp_http_client_perform() call, when the + * URL embeds credentials, auth_type is set to HTTP_AUTH_TYPE_BASIC, and + * CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH is enabled. + * - Separately: a caller who preconfigures auth_type = HTTP_AUTH_TYPE_BASIC + * with credentials in the URL gets an Authorization: Basic header on the + * very FIRST request, before any 401 is ever seen - this path + * (esp_http_client_prepare_basic_auth(), called from + * esp_http_client_prepare() whenever auth_type == BASIC and a username is + * set) has no Kconfig guard at all, unlike the WWW-Authenticate-driven + * detection in add_auth(). Preconfigured auth and auto-detected auth are + * gated independently in master. + */ + +#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 + +#if !CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH +#error "These cases require CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y; keep every sdkconfig.ci.* with CUSTOM_TRANSPORT in sync" +#endif + +static const char *resp_401 = + "HTTP/1.1 401 Unauthorized\r\n" + "WWW-Authenticate: Basic realm=\"Test\"\r\n" + "Content-Length: 0\r\n" + "\r\n"; + +static const char *resp_200 = + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"; + +TEST_CASE("401 with credentials retries with Authorization header", "[esp_http_client][auth][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_401; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + mock_http_transport_queue_response(mock, resp_200, 0); + + esp_http_client_config_t cfg = { + .url = "http://user:pass@test-server.local/secure", + .auth_type = HTTP_AUTH_TYPE_BASIC, + .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)); + + /* characterization: master behavior, see refactor spec + * With CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH enabled, + * esp_http_check_response() matches status 401 and calls + * esp_http_client_add_auth() (esp_http_client.c ~L1239-1240), which + * finds a non-NULL auth_header populated from the WWW-Authenticate + * response header, recognizes the "Basic" scheme, sets process_again=1, + * and the client transparently retries within this same perform() call. + * Confirmed as a checked fact (not just inferred from the two asserts + * above): exactly 2 writes reach the transport, one per request. This + * assumes one mock_write() call per request's header block, which held + * for every GET-with-no-body case observed in this suite so far + * (single-write requests); a refactor that splits header writes across + * multiple esp_transport_write() calls would need to update this count + * without necessarily changing behavior, so treat it as a coupled-to- + * buffering assertion, not a load-bearing behavior pin by itself. */ + mock_http_transport_stats_t stats; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_stats(mock, &stats)); + TEST_ASSERT_EQUAL(2, stats.write_calls); + + char req[2048]; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock, req, sizeof(req), NULL)); + /* characterization: master behavior, see refactor spec + * resp_401 has Content-Length: 0 and no "Connection: close", so + * http_should_keep_alive() keeps the connection open across the retry - + * esp_http_client.c never calls esp_http_client_close() between the two + * requests, the same keep-alive shape as Task 7's redirect flow. + * Because the connection never closes, the mock's request-capture buffer + * is never reset via mock_connect(); mock_write()'s FIFO-advance-on- + * boundary logic pops resp_200 into the active buffer on the second + * write, and the capture buffer resets at that same boundary. So this + * assert observes only the SECOND (retried) request. Note this alone + * cannot distinguish "the retry added Authorization" from "it was + * already there" - see the companion test case below, which pins the + * preconfigured-auth path in isolation with no 401 involved at all. */ + TEST_ASSERT_NOT_NULL(strstr(req, "Authorization: Basic ")); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("preconfigured Basic auth is sent before any 401 is seen", "[esp_http_client][auth][p0]") +{ + /* Companion to the retry test above: isolates the preconfigured-auth + * path by never sending a 401 at all - only a single 200 response. */ + 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://user:pass@test-server.local/secure", + .auth_type = HTTP_AUTH_TYPE_BASIC, + .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)); + + char req[2048]; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock, req, sizeof(req), NULL)); + /* characterization: master behavior, see refactor spec + * esp_http_client_prepare() (esp_http_client.c ~L800-803) calls + * esp_http_client_prepare_basic_auth() whenever + * connection_info.auth_type == HTTP_AUTH_TYPE_BASIC and a username is + * set - both true here purely from cfg.auth_type and the URL's embedded + * credentials - before the first request is ever sent, and with no + * CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH guard around that call. So + * Authorization: Basic is present on the only request this case sends, + * even though no 401/WWW-Authenticate exchange happened. */ + TEST_ASSERT_NOT_NULL(strstr(req, "Authorization: Basic ")); + + 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/main/test_http_client_basic.c b/components/esp_http_client/test_apps/main/test_http_client_basic.c new file mode 100644 index 00000000000..ceb0618683d --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_basic.c @@ -0,0 +1,786 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_basic.c + * @brief P0 Critical Tests: Basic HTTP Client Functionality + * + * This file contains P0 (critical) tests covering: + * - HTTP methods (GET, POST, PUT, DELETE, HEAD) + * - HTTP status codes (2xx success, 4xx client errors, 5xx server errors) + * - Basic request/response handling + * - Both positive (success) and negative (error) scenarios + */ + +#include +#include +#include "esp_http_client.h" +#include "esp_log.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 *TAG = "test_basic"; + +/* ============================================ + * Test Response Templates + * ============================================ */ + +// 200 OK - Successful GET response +static const char *response_200_ok = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 32\r\n" + "\r\n" + "{\"message\":\"success\",\"code\":200}"; + +// 201 Created - Successful POST response +static const char *response_201_created = + "HTTP/1.1 201 Created\r\n" + "Content-Type: application/json\r\n" + "Location: /resource/12345\r\n" + "Content-Length: 31\r\n" + "\r\n" + "{\"id\":12345,\"status\":\"created\"}"; + +// 204 No Content - Successful DELETE response (no body) +static const char *response_204_no_content = + "HTTP/1.1 204 No Content\r\n" + "Content-Length: 0\r\n" + "\r\n"; + +// 400 Bad Request - Client error +static const char *response_400_bad_request = + "HTTP/1.1 400 Bad Request\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 34\r\n" + "\r\n" + "{\"error\":\"Invalid request format\"}"; + +// 401 Unauthorized - Authentication required +static const char *response_401_unauthorized = + "HTTP/1.1 401 Unauthorized\r\n" + "WWW-Authenticate: Basic realm=\"Test\"\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 35\r\n" + "\r\n" + "{\"error\":\"Authentication required\"}"; + +// 404 Not Found - Resource not found +static const char *response_404_not_found = + "HTTP/1.1 404 Not Found\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 30\r\n" + "\r\n" + "{\"error\":\"Resource not found\"}"; + +// 500 Internal Server Error +static const char *response_500_server_error = + "HTTP/1.1 500 Internal Server Error\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 33\r\n" + "\r\n" + "{\"error\":\"Internal server error\"}"; + +// 503 Service Unavailable +static const char *response_503_unavailable = + "HTTP/1.1 503 Service Unavailable\r\n" + "Retry-After: 60\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 36\r\n" + "\r\n" + "{\"error\":\"Service temporarily down\"}"; + +/* ============================================ + * Helper Functions + * ============================================ */ + +static esp_err_t basic_event_handler(esp_http_client_event_t *evt) +{ + switch (evt->event_id) { + case HTTP_EVENT_ERROR: + ESP_LOGD(TAG, "HTTP_EVENT_ERROR"); + break; + case HTTP_EVENT_ON_CONNECTED: + ESP_LOGD(TAG, "HTTP_EVENT_ON_CONNECTED"); + break; + case HTTP_EVENT_HEADERS_SENT: + ESP_LOGD(TAG, "HTTP_EVENT_HEADERS_SENT"); + break; + case HTTP_EVENT_ON_HEADER: + ESP_LOGD(TAG, "HTTP_EVENT_ON_HEADER: %s: %s", evt->header_key, evt->header_value); + break; + case HTTP_EVENT_ON_DATA: + ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA: %d bytes", evt->data_len); + break; + case HTTP_EVENT_ON_FINISH: + ESP_LOGD(TAG, "HTTP_EVENT_ON_FINISH"); + break; + case HTTP_EVENT_DISCONNECTED: + ESP_LOGD(TAG, "HTTP_EVENT_DISCONNECTED"); + break; + default: + break; + } + return ESP_OK; +} + +/* ============================================ + * P0 Test: HTTP Methods - Positive Cases + * ============================================ */ + +/** + * Test: GET request succeeds with 200 OK + * + * Positive scenario: Normal GET request returns success + */ +TEST_CASE("GET request succeeds with 200 OK", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing GET request - positive case"); + + // Setup mock transport with 200 OK response + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_200_ok; + mock_config.response_len = strlen(response_200_ok); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create HTTP client + esp_http_client_config_t config = { + .url = "http://test-server.local/api/data", + .method = HTTP_METHOD_GET, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Perform request + esp_err_t err = esp_http_client_perform(client); + + // Verify success + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + TEST_ASSERT_EQUAL(32, esp_http_client_get_content_length(client)); + + // Verify transport was used + mock_http_transport_stats_t stats; + mock_http_transport_get_stats(mock_transport, &stats); + TEST_ASSERT_EQUAL(1, stats.connect_calls); + TEST_ASSERT_GREATER_THAN(0, stats.write_calls); // Sent request + TEST_ASSERT_GREATER_THAN(0, stats.read_calls); // Received response + + ESP_LOGI(TAG, "OK: GET request successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: POST request succeeds with 201 Created + * + * Positive scenario: POST with body returns success + */ +TEST_CASE("POST request succeeds with 201 Created", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing POST request - positive case"); + + // Setup mock transport with 201 Created response + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_201_created; + mock_config.response_len = strlen(response_201_created); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + // Create HTTP client + esp_http_client_config_t config = { + .url = "http://test-server.local/api/resource", + .method = HTTP_METHOD_POST, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Set POST data + const char *post_data = "{\"name\":\"test\",\"value\":123}"; + esp_http_client_set_post_field(client, post_data, strlen(post_data)); + + // Perform request + esp_err_t err = esp_http_client_perform(client); + + // Verify success + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(201, esp_http_client_get_status_code(client)); + + // Verify POST data was sent + mock_http_transport_stats_t stats; + mock_http_transport_get_stats(mock_transport, &stats); + TEST_ASSERT_GREATER_THAN(strlen(post_data), stats.total_bytes_written); + + ESP_LOGI(TAG, "OK: POST request successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: PUT request succeeds with 200 OK + * + * Positive scenario: PUT request updates resource + */ +TEST_CASE("PUT request succeeds with 200 OK", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing PUT request - positive case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_200_ok; + mock_config.response_len = strlen(response_200_ok); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/resource/123", + .method = HTTP_METHOD_PUT, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + const char *put_data = "{\"status\":\"updated\"}"; + esp_http_client_set_post_field(client, put_data, strlen(put_data)); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: PUT request successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: DELETE request succeeds with 204 No Content + * + * Positive scenario: DELETE removes resource, no body returned + */ +TEST_CASE("DELETE request succeeds with 204 No Content", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing DELETE request - positive case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_204_no_content; + mock_config.response_len = strlen(response_204_no_content); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/resource/123", + .method = HTTP_METHOD_DELETE, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(204, esp_http_client_get_status_code(client)); + TEST_ASSERT_EQUAL(0, esp_http_client_get_content_length(client)); // No content + + ESP_LOGI(TAG, "OK: DELETE request successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: HEAD request succeeds with headers only + * + * Positive scenario: HEAD request returns headers but no body + */ +TEST_CASE("HEAD request succeeds with headers only", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing HEAD request - positive case"); + + // HEAD response has headers but no body + const char *response_head = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 1024\r\n" // Says content length but sends no body + "\r\n"; + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_head; + mock_config.response_len = strlen(response_head); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/resource", + .method = HTTP_METHOD_HEAD, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + // Note: Content-Length header says 1024, but no body should be received for HEAD + + ESP_LOGI(TAG, "OK: HEAD request successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/* ============================================ + * P0 Test: HTTP Status Codes - 4xx Client Errors + * ============================================ */ + +/** + * Test: Client handles 400 Bad Request gracefully + * + * Negative scenario: Server rejects malformed request + * Expected: Client reports error but doesn't crash, can be reused + */ +TEST_CASE("Client handles 400 Bad Request error", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing 400 Bad Request - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_400_bad_request; + mock_config.response_len = strlen(response_400_bad_request); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/bad", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Request completes but with error status + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); // Transport succeeds, but status is 400 + TEST_ASSERT_EQUAL(400, esp_http_client_get_status_code(client)); + + // Verify client can be reused after 4xx error + mock_config.response_data = response_200_ok; + mock_config.response_len = strlen(response_200_ok); + mock_http_transport_set_config(mock_transport, &mock_config); + + err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: 400 error handled gracefully, client reusable"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Client handles 401 Unauthorized + * + * Negative scenario: Authentication required, no credentials configured. + * + * characterization: master behavior, see refactor spec + * This test app's sdkconfig.ci.default sets + * CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y, so esp_http_client_add_auth() + * (esp_http_client.c ~L2132-2198) can recognize the "Basic" scheme in this + * response's WWW-Authenticate header. That function sets + * client->process_again = 1 purely from successfully parsing that header - + * it never checks whether any credentials are actually configured. The + * credential check happens later and separately, in + * esp_http_client_prepare() (~L800-803): it only gates whether an + * Authorization header gets *attached* to the retried request, not whether + * a retry is *attempted*. So this client - no auth_type, no + * username/password, no URL-embedded credentials - still retries on a 401: + * it resends a byte-identical, credential-less request. This is the same + * shared redirect_counter / max_authorization_retries mechanism the FSM + * refactor's auth-retry handling and counter-split fix are meant to + * address. To pin that credential-less-retry behavior deterministically + * (rather than depend on how many canned responses happen to be queued), + * this test caps the retry at 1 and queues a second identical 401, so the + * client hits esp_http_client_add_auth()'s own + * "redirect_counter >= max_authorization_retries" guard (~L2140-2143) + * on the second 401 and terminates with a real, mock-independent outcome. + */ +TEST_CASE("Client handles 401 Unauthorized error", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing 401 Unauthorized - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_401_unauthorized; + mock_config.response_len = strlen(response_401_unauthorized); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + /* Second 401 so the credential-less retry lands on the deterministic + * max_authorization_retries cap below, instead of exhausting the + * mock's queue and timing out. */ + mock_http_transport_queue_response(mock_transport, response_401_unauthorized, 0); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/protected", + .event_handler = basic_event_handler, + .max_authorization_retries = 1, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + /* characterization: master behavior, see refactor spec + * The first 401 is answered by a credential-less retry (see the file + * comment above). The second 401 then trips + * esp_http_client_add_auth()'s "redirect_counter(1) >= + * max_authorization_retries(1)" guard, which logs "reached + * max_authorization_retries" and returns ESP_FAIL directly; + * esp_http_client_perform() propagates that ESP_FAIL to the caller + * without any further retry. */ + TEST_ASSERT_EQUAL(ESP_FAIL, err); + /* characterization: master behavior, see refactor spec + * status_code is a direct field read of the last response actually + * parsed (the second 401) - untouched by the retry-cap error path. */ + TEST_ASSERT_EQUAL(401, esp_http_client_get_status_code(client)); + + /* characterization: master behavior, see refactor spec + * Confirms the retry actually happened (2 writes: the original + * request and the one credential-less retry) rather than the client + * simply giving up on the first 401. As in test_http_client_auth.c, + * this assumes one mock_write() call per request's header block, + * which held for every GET-with-no-body case observed in this suite; + * a refactor that splits header writes across multiple + * esp_transport_write() calls would need to update this count + * without necessarily changing behavior. */ + mock_http_transport_stats_t stats; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_stats(mock_transport, &stats)); + TEST_ASSERT_EQUAL(2, stats.write_calls); + + /* characterization: master behavior, see refactor spec + * The queued second 401 triggers a queue-advance on the retry's write + * (previous response fully read, a queued response is still pending - + * see test_http_client_mock_transport.h), which resets the capture + * buffer just before the retry is written. So this last-request capture + * holds only the credential-less retry, not the original request. + * Confirms no Authorization header is attached: no auth_type, + * username/password, or URL-embedded credentials were ever configured + * on this client, so esp_http_client_prepare()'s credential check + * (~L800-803) has nothing to attach even though add_auth() unconditionally + * schedules the retry. */ + char req[2048]; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock_transport, req, sizeof(req), NULL)); + /* Non-vacuity check: confirms the capture actually holds the retried + * request line (not empty, not the original request left over from a + * missed reset) before trusting the Authorization-absence assert below. */ + TEST_ASSERT_NOT_NULL(strstr(req, "GET /api/protected")); + TEST_ASSERT_NULL(strstr(req, "Authorization")); + + ESP_LOGI(TAG, "OK: 401 error handled, credential-less retry capped and reported"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Client handles 404 Not Found + * + * Negative scenario: Requested resource doesn't exist + */ +TEST_CASE("Client handles 404 Not Found error", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing 404 Not Found - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_404_not_found; + mock_config.response_len = strlen(response_404_not_found); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/nonexistent", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(404, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: 404 error handled gracefully"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/* ============================================ + * P0 Test: HTTP Status Codes - 5xx Server Errors + * ============================================ */ + +/** + * Test: Client handles 500 Internal Server Error + * + * Negative scenario: Server encounters internal error + * Expected: Client reports error, remains usable for retry + */ +TEST_CASE("Client handles 500 Internal Server Error", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing 500 Server Error - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_500_server_error; + mock_config.response_len = strlen(response_500_server_error); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/failing", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // First request gets 500 error + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(500, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "First request got 500 error"); + + // Simulate retry after server recovers + mock_config.response_data = response_200_ok; + mock_config.response_len = strlen(response_200_ok); + mock_http_transport_set_config(mock_transport, &mock_config); + + err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: 500 error handled, retry successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Client handles 503 Service Unavailable + * + * Negative scenario: Service temporarily down + */ +TEST_CASE("Client handles 503 Service Unavailable", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing 503 Service Unavailable - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_503_unavailable; + mock_config.response_len = strlen(response_503_unavailable); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/overloaded", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(503, esp_http_client_get_status_code(client)); + + // Verify Retry-After header could be read + // (In real scenarios, client would wait before retrying) + + ESP_LOGI(TAG, "OK: 503 error handled, service unavailable"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/* ============================================ + * P0 Test: Connection Failures - Negative Cases + * ============================================ */ + +/** + * Test: Client handles connection failure gracefully + * + * Negative scenario: Cannot connect to server + * Expected: Error reported, client remains in valid state + */ +TEST_CASE("Client handles connection failure", "[esp_http_client][basic][p0][negative]") +{ + ESP_LOGI(TAG, "Testing connection failure - negative case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_CONNECT_FAIL; + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://unreachable-server.local/api/test", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // Connection should fail + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_NOT_EQUAL(ESP_OK, err); + + ESP_LOGI(TAG, "Connection failed as expected: %s", esp_err_to_name(err)); + + // Verify client can retry after connection failure + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_200_ok; + mock_config.response_len = strlen(response_200_ok); + mock_http_transport_set_config(mock_transport, &mock_config); + + err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: Connection failure handled, retry successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: POST with empty body succeeds + * + * Edge case: POST request with no data + */ +TEST_CASE("POST with empty body succeeds", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing POST with empty body - edge case"); + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_201_created; + mock_config.response_len = strlen(response_201_created); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/resource", + .method = HTTP_METHOD_POST, + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + // POST with empty body (Content-Length: 0) + esp_http_client_set_post_field(client, "", 0); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(201, esp_http_client_get_status_code(client)); + + ESP_LOGI(TAG, "OK: POST with empty body successful"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +/** + * Test: Response with empty body (Content-Length: 0) + * + * Edge case: Server returns headers but no body + */ +TEST_CASE("Client handles response with empty body", "[esp_http_client][basic][p0][positive]") +{ + ESP_LOGI(TAG, "Testing response with empty body - edge case"); + + const char *response_empty_body = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: 0\r\n" + "\r\n"; + + mock_http_transport_config_t mock_config = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mock_config.mode = MOCK_TRANSPORT_MODE_NORMAL; + mock_config.response_data = response_empty_body; + mock_config.response_len = strlen(response_empty_body); + + esp_transport_handle_t mock_transport = mock_http_transport_create(&mock_config); + TEST_ASSERT_NOT_NULL(mock_transport); + + esp_http_client_config_t config = { + .url = "http://test-server.local/api/empty", + .event_handler = basic_event_handler, + .transport = mock_transport, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + TEST_ASSERT_NOT_NULL(client); + + esp_err_t err = esp_http_client_perform(client); + TEST_ASSERT_EQUAL(ESP_OK, err); + TEST_ASSERT_EQUAL(200, esp_http_client_get_status_code(client)); + TEST_ASSERT_EQUAL(0, esp_http_client_get_content_length(client)); + + ESP_LOGI(TAG, "OK: Empty response body handled correctly"); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock_transport); +} + +#endif // CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT 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/main/test_http_client_lifecycle.c b/components/esp_http_client/test_apps/main/test_http_client_lifecycle.c new file mode 100644 index 00000000000..eea74bdb886 --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_lifecycle.c @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_lifecycle.c + * @brief P0 Critical Tests: keep-alive connection reuse and get_state sequence + * + * This file characterizes: + * - Connection reuse across two esp_http_client_perform() calls when the + * response carries "Connection: keep-alive" + * - Forced reconnect on the next perform() when the response carries + * "Connection: close" + * - The exact esp_http_client_get_state() sequence observed from inside the + * event handler during a simple GET, pinned as a regression baseline for + * the state-machine refactor. + */ + +#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_ka = + "HTTP/1.1 200 OK\r\n" + "Connection: keep-alive\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"; + +static const char *resp_close = + "HTTP/1.1 200 OK\r\n" + "Connection: close\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"; + +TEST_CASE("keep-alive reuses connection across two performs", "[esp_http_client][lifecycle][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_ka; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + mock_http_transport_queue_response(mock, resp_ka, 0); + + esp_http_client_config_t cfg = { + .url = "http://test-server.local/a", + .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)); + /* characterization: parked state between requests is CONNECTED */ + TEST_ASSERT_EQUAL(HTTP_STATE_CONNECTED, esp_http_client_get_state(client)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client)); + + mock_http_transport_stats_t stats; + mock_http_transport_get_stats(mock, &stats); + TEST_ASSERT_EQUAL(1, stats.connect_calls); /* the whole point of keep-alive */ + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("Connection: close forces reconnect on next perform", "[esp_http_client][lifecycle][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_close; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + /* characterization: this queued response is never popped - the forced + * reconnect calls mock_close() then mock_connect(), both of which reset + * read_offset to 0, so the second perform() re-serves the identical + * initial buffer instead of advancing the queue; kept here for + * intent-documentation. */ + mock_http_transport_queue_response(mock, resp_close, 0); + + esp_http_client_config_t cfg = { + .url = "http://test-server.local/a", + .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(ESP_OK, esp_http_client_perform(client)); + + mock_http_transport_stats_t stats; + mock_http_transport_get_stats(mock, &stats); + TEST_ASSERT_EQUAL(2, stats.connect_calls); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +/* state sequence observed via events — pins the getter mapping for the refactor */ +static esp_http_state_t s_state_log[16]; +static int s_state_log_n; + +static esp_err_t state_logger(esp_http_client_event_t *evt) +{ + if (s_state_log_n < 16) { + s_state_log[s_state_log_n++] = esp_http_client_get_state(evt->client); + } + return ESP_OK; +} + +TEST_CASE("get_state sequence during a simple GET", "[esp_http_client][lifecycle][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_ka; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + + s_state_log_n = 0; + esp_http_client_config_t cfg = { + .url = "http://test-server.local/a", + .event_handler = state_logger, + .transport = mock, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + TEST_ASSERT_NOT_NULL(client); + TEST_ASSERT_EQUAL(HTTP_STATE_INIT, esp_http_client_get_state(client)); + + TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_perform(client)); + + /* characterization: master behavior, see refactor spec + * exact sequence observed by the event handler, frozen as a regression + * baseline for the state-machine refactor. get_state() lags the event + * that triggered it in several places: it still reports + * REQ_COMPLETE_DATA at ON_STATUS_CODE and both ON_HEADER callbacks, and + * still reports RES_COMPLETE_HEADER at ON_HEADERS_COMPLETE and ON_DATA; + * it only advances to RES_ON_DATA_START at ON_FINISH, after all + * response data has already been delivered to the handler. */ + TEST_ASSERT_EQUAL(8, s_state_log_n); + TEST_ASSERT_EQUAL(HTTP_STATE_CONNECTED, s_state_log[0]); /* at ON_CONNECTED */ + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_HEADER, s_state_log[1]); /* at HEADERS_SENT */ + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_DATA, s_state_log[2]); /* at ON_STATUS_CODE */ + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_DATA, s_state_log[3]); /* at ON_HEADER (Connection) */ + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_DATA, s_state_log[4]); /* at ON_HEADER (Content-Length) */ + TEST_ASSERT_EQUAL(HTTP_STATE_RES_COMPLETE_HEADER, s_state_log[5]); /* at ON_HEADERS_COMPLETE */ + TEST_ASSERT_EQUAL(HTTP_STATE_RES_COMPLETE_HEADER, s_state_log[6]); /* at ON_DATA */ + TEST_ASSERT_EQUAL(HTTP_STATE_RES_ON_DATA_START, s_state_log[7]); /* at ON_FINISH */ + + 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/main/test_http_client_redirect.c b/components/esp_http_client/test_apps/main/test_http_client_redirect.c new file mode 100644 index 00000000000..494e5478d71 --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_redirect.c @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @file test_http_client_redirect.c + * @brief P0 Critical Tests: auto redirect, disabled redirect, max redirect count + * + * This file characterizes: + * - A 302 response is followed automatically within a single perform() call, + * and the second request lands on the Location target. + * - disable_auto_redirect surfaces the 302 to the caller instead of following it. + * - max_redirection_count stops a redirect loop and reports it as an error. + */ + +#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_302 = + "HTTP/1.1 302 Found\r\n" + "Location: http://test-server.local/new\r\n" + "Content-Length: 0\r\n" + "\r\n"; + +static const char *resp_200 = + "HTTP/1.1 200 OK\r\n" + "Content-Length: 4\r\n" + "\r\n" + "done"; + +TEST_CASE("auto redirect follows 302 within one perform", "[esp_http_client][redirect][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_302; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + mock_http_transport_queue_response(mock, resp_200, 0); + + esp_http_client_config_t cfg = { + .url = "http://test-server.local/old", + .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)); + + char req[2048]; + TEST_ASSERT_EQUAL(ESP_OK, mock_http_transport_get_last_request(mock, req, sizeof(req), NULL)); + /* characterization: master behavior, see refactor spec + * resp_302 has neither "Connection: close" nor an HTTP/1.0 status line, + * so http_should_keep_alive() keeps the connection open and + * esp_http_client.c never closes/reconnects between the two requests + * (observed in the QEMU transport log: a single "Mock connect + * succeeded" and two "Mock write" calls for this case). The mock's + * request capture therefore never resets via mock_connect(), and + * mock_write()'s FIFO-advance-on-boundary logic pops resp_200 for the + * second write, so this assert depends on the connection staying open + * across the redirect, not merely on Location being followed. */ + TEST_ASSERT_NOT_NULL(strstr(req, "GET /new")); /* second request hit the new path */ + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("disable_auto_redirect returns the 302 to the caller", "[esp_http_client][redirect][p0]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_302; + 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/old", + .disable_auto_redirect = true, + .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(302, esp_http_client_get_status_code(client)); + + esp_http_client_cleanup(client); + mock_http_transport_destroy(mock); +} + +TEST_CASE("max_redirection_count stops a redirect loop", "[esp_http_client][redirect][p0][negative]") +{ + mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG(); + mc.response_data = resp_302; + esp_transport_handle_t mock = mock_http_transport_create(&mc); + TEST_ASSERT_NOT_NULL(mock); + mock_http_transport_queue_response(mock, resp_302, 0); + mock_http_transport_queue_response(mock, resp_302, 0); + mock_http_transport_queue_response(mock, resp_302, 0); + + esp_http_client_config_t cfg = { + .url = "http://test-server.local/loop", + .max_redirection_count = 2, + .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 + * with max_redirection_count=2, redirect_counter starts at 0 and is + * checked (>= max) BEFORE each redirect is taken: 1st 302 -> counter + * 0>=2 false, redirect, counter=1; 2nd 302 -> 1>=2 false, redirect, + * counter=2; 3rd 302 -> 2>=2 true, esp_http_check_response() returns + * ESP_ERR_HTTP_MAX_REDIRECT. Only 3 of the 4 queued 302 responses are + * ever consumed - the loop guard trips before the 4th is read. */ + TEST_ASSERT_EQUAL(ESP_ERR_HTTP_MAX_REDIRECT, 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/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..ec2082dfe2a --- /dev/null +++ b/components/esp_http_client/test_apps/main/test_http_client_streaming.c @@ -0,0 +1,165 @@ +/* + * 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)); + // characterization: master behavior, see refactor spec + // esp_http_client_open() (esp_http_client.c ~L1921) sets + // state = HTTP_STATE_REQ_COMPLETE_HEADER right after writing the + // request line and headers over the transport. + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_HEADER, esp_http_client_get_state(client)); + TEST_ASSERT_EQUAL(5, esp_http_client_write(client, body, 5)); + // characterization: master behavior, see refactor spec + // The public esp_http_client_write() (~L1978) never touches + // client->state - it only requires state >= REQ_COMPLETE_HEADER and + // writes bytes directly over the transport, so the state observed + // here is unchanged from the open() call above. + TEST_ASSERT_EQUAL(HTTP_STATE_REQ_COMPLETE_HEADER, esp_http_client_get_state(client)); + TEST_ASSERT_EQUAL(5, esp_http_client_fetch_headers(client)); + // characterization: master behavior, see refactor spec + // esp_http_client_fetch_headers() (~L1667-1697) unconditionally sets + // state = HTTP_STATE_REQ_COMPLETE_DATA on entry, then reads/parses + // until the response header-parse loop exits, then unconditionally + // sets state = HTTP_STATE_RES_ON_DATA_START before returning - it + // never stops at RES_COMPLETE_HEADER and does not depend on whether + // any body bytes were actually read yet. + TEST_ASSERT_EQUAL(HTTP_STATE_RES_ON_DATA_START, esp_http_client_get_state(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)); + // characterization: master behavior, see refactor spec + // esp_http_client_read() (~L1435) never assigns client->state at all. + // Also, for this canned response the single mock transport read done + // inside fetch_headers() above already delivered the whole 43-byte + // buffer (headers + 5-byte body) to the parser in one + // http_parser_execute() call, so this read() serves the body from the + // already-cached response buffer without issuing a second transport + // read - the state observed here is unchanged from fetch_headers(). + TEST_ASSERT_EQUAL(HTTP_STATE_RES_ON_DATA_START, esp_http_client_get_state(client)); + 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 new file mode 100644 index 00000000000..1ab0ec80dc8 --- /dev/null +++ b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.c @@ -0,0 +1,743 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "test_http_client_mock_transport.h" +#include "esp_log.h" +#include +#include +#include + +static const char *TAG = "mock_transport"; + +/** + * @brief Internal context for mock transport + */ +typedef struct { + mock_http_transport_config_t config; /*!< Current configuration */ + mock_http_transport_stats_t stats; /*!< Call statistics */ + 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() */ + int wb_writes_left; /*!< Remaining EAGAIN injections for mock_write() */ + const char *resp_queue[8]; /*!< FIFO of queued response pointers (caller-owned) */ + size_t resp_queue_len[8]; /*!< Lengths matching resp_queue entries */ + int resp_queue_count; /*!< Number of entries queued */ + int resp_queue_next; /*!< Index of the next entry to pop */ + char req_capture[2048]; /*!< Bytes written since the last request boundary */ + size_t req_capture_len; /*!< Number of valid bytes in req_capture */ +} mock_http_transport_ctx_t; + +// Forward declarations of transport function implementations +static int mock_connect(esp_transport_handle_t t, const char *host, int port, int timeout_ms); +static int mock_connect_async(esp_transport_handle_t t, const char *host, int port, int timeout_ms); +static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeout_ms); +static int mock_write(esp_transport_handle_t t, const char *buffer, int len, int timeout_ms); +static int mock_close(esp_transport_handle_t t); +static int mock_poll_read(esp_transport_handle_t t, int timeout_ms); +static int mock_poll_write(esp_transport_handle_t t, int timeout_ms); +static int mock_destroy(esp_transport_handle_t t); + +/** + * @brief Simulate delay (for timeouts and connection delays) + */ +static void simulate_delay_ms(int delay_ms) +{ + if (delay_ms <= 0) { + return; + } + + struct timeval tv; + tv.tv_sec = delay_ms / 1000; + tv.tv_usec = (delay_ms % 1000) * 1000; + select(0, NULL, NULL, NULL, &tv); +} + +/** + * @brief Check if error should be injected based on bytes processed + */ +static bool should_inject_error(mock_http_transport_ctx_t *ctx, size_t bytes_about_to_process) +{ + if (ctx->config.bytes_before_error < 0) { + return false; // No error injection configured + } + + 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 + */ +static int mock_connect(esp_transport_handle_t t, const char *host, int port, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx) { + ESP_LOGE(TAG, "Invalid transport context"); + errno = EINVAL; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.connect_calls++; + } + + ESP_LOGD(TAG, "Mock connect to %s:%d (mode=%d)", host, port, ctx->config.mode); + + // Simulate connection delay + if (ctx->config.connect_delay_ms > 0) { + simulate_delay_ms(ctx->config.connect_delay_ms); + } + + // Handle connect failure mode + if (ctx->config.mode == MOCK_TRANSPORT_MODE_CONNECT_FAIL) { + ESP_LOGD(TAG, "Mock connect failed (simulated)"); + errno = ECONNREFUSED; + return -1; + } + + // Success + 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; +} + +/** + * @brief Mock async connect implementation + * + * Simulates a non-blocking connect(): returns "in progress" for + * `async_connect_polls` calls, then reports success. Honors + * MOCK_TRANSPORT_MODE_CONNECT_FAIL to simulate an async connect failure. + */ +static int mock_connect_async(esp_transport_handle_t t, const char *host, int port, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx) { + ESP_LOGE(TAG, "Invalid transport context"); + errno = EINVAL; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.connect_calls++; + } + + if (ctx->config.mode == MOCK_TRANSPORT_MODE_CONNECT_FAIL) { + ESP_LOGD(TAG, "Mock connect_async failed (simulated)"); + return -1; /* ASYNC_TRANS_CONNECT_FAIL */ + } + + if (ctx->async_polls_left > 0) { + ctx->async_polls_left--; + ESP_LOGD(TAG, "Mock connect_async: still connecting (%d polls left)", ctx->async_polls_left); + return 0; /* ASYNC_TRANS_CONNECTING */ + } + + 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 */ +} + +/** + * @brief Mock read implementation + */ +static int mock_read(esp_transport_handle_t t, char *buffer, int len, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx || !buffer || len <= 0) { + errno = EINVAL; + return -1; + } + + // Would-block injection: simulate a non-blocking socket returning EAGAIN + if (ctx->wb_reads_left > 0) { + ctx->wb_reads_left--; + ESP_LOGD(TAG, "Mock read: EAGAIN (simulated would-block, %d left)", ctx->wb_reads_left); + errno = EAGAIN; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.read_calls++; + } + + if (!ctx->is_connected) { + ESP_LOGD(TAG, "Mock read: not connected"); + errno = ENOTCONN; + return -1; + } + + ESP_LOGD(TAG, "Mock read: requested %d bytes (mode=%d)", len, ctx->config.mode); + + // Handle read timeout mode + if (ctx->config.mode == MOCK_TRANSPORT_MODE_READ_TIMEOUT) { + ESP_LOGD(TAG, "Mock read: timeout (simulated)"); + errno = ETIMEDOUT; + return -1; + } + + // If no response data configured, return 0 (connection closed) + if (!ctx->response_buffer || ctx->config.response_len == 0) { + ESP_LOGD(TAG, "Mock read: no data available (EOF)"); + return 0; + } + + // Calculate available data + size_t remaining = ctx->config.response_len - ctx->read_offset; + if (remaining == 0) { + ESP_LOGD(TAG, "Mock read: all data consumed (EOF)"); + return 0; // All data consumed + } + + // Determine how much to read + size_t to_read = (len < remaining) ? len : remaining; + + // 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) { + 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 = 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; + } + // Connection closed + ctx->is_connected = false; + ESP_LOGD(TAG, "Mock read: connection closed (incomplete data)"); + return 0; + } + } + + // Normal read + 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; + } + + ESP_LOGI(TAG, "Mock read: returned %zu bytes (offset now=%zu, remaining=%zu)", + to_read, ctx->read_offset, ctx->config.response_len - ctx->read_offset); + 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 + */ +static int mock_write(esp_transport_handle_t t, const char *buffer, int len, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx || !buffer || len <= 0) { + errno = EINVAL; + return -1; + } + + // Would-block injection: simulate a non-blocking socket returning EAGAIN + if (ctx->wb_writes_left > 0) { + ctx->wb_writes_left--; + ESP_LOGD(TAG, "Mock write: EAGAIN (simulated would-block, %d left)", ctx->wb_writes_left); + errno = EAGAIN; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.write_calls++; + } + + if (!ctx->is_connected) { + ESP_LOGD(TAG, "Mock write: not connected"); + errno = ENOTCONN; + return -1; + } + + 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 (mock_write_check_fail(ctx, accept_len)) { + return -1; + } + + // Handle partial write mode + 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. + // Pop the next queued response into the active buffer via set_response() + // (which owns free/realloc + read_offset reset) and start a fresh capture. + if (ctx->resp_queue_next < ctx->resp_queue_count && + ctx->response_buffer && ctx->read_offset >= ctx->config.response_len) { + mock_http_transport_set_response(t, ctx->resp_queue[ctx->resp_queue_next], + ctx->resp_queue_len[ctx->resp_queue_next]); + ctx->resp_queue_next++; + ctx->req_capture_len = 0; + } + + // Capture written bytes (request content) for test assertions, capped to + // avoid overflowing the fixed-size buffer. + mock_capture_bytes(ctx, buffer, (size_t)accept_len); + + // Normal write (just track it, don't actually store) + ctx->bytes_processed += accept_len; + + if (ctx->config.track_calls) { + ctx->stats.total_bytes_written += accept_len; + } + + ESP_LOGI(TAG, "Mock write: completed %d bytes (total_processed=%zu)", + accept_len, ctx->bytes_processed); + return accept_len; +} + +/** + * @brief Mock close implementation + */ +static int mock_close(esp_transport_handle_t t) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx) { + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.close_calls++; + } + + ESP_LOGI(TAG, "Mock close (was_connected=%d)", ctx->is_connected); + + ctx->is_connected = false; + ctx->read_offset = 0; + ctx->bytes_processed = 0; + ctx->read_only_bytes = 0; + + return 0; +} + +/** + * @brief Mock poll_read implementation + */ +static int mock_poll_read(esp_transport_handle_t t, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx) { + errno = EINVAL; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.poll_read_calls++; + } + + if (!ctx->is_connected) { + errno = ENOTCONN; + return -1; + } + + // In normal mode, indicate data is available if we have response data + if (ctx->response_buffer && ctx->read_offset < ctx->config.response_len) { + return 1; // Data available + } + + // Handle timeout mode + if (ctx->config.mode == MOCK_TRANSPORT_MODE_READ_TIMEOUT) { + simulate_delay_ms(timeout_ms); + errno = ETIMEDOUT; + return 0; // Timeout + } + + return 0; // No data or timeout +} + +/** + * @brief Mock poll_write implementation + */ +static int mock_poll_write(esp_transport_handle_t t, int timeout_ms) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx) { + errno = EINVAL; + return -1; + } + + if (ctx->config.track_calls) { + ctx->stats.poll_write_calls++; + } + + if (!ctx->is_connected) { + errno = ENOTCONN; + return -1; + } + + // Usually can write (unless in write failure mode) + if (ctx->config.mode == MOCK_TRANSPORT_MODE_WRITE_FAIL) { + return 0; // Can't write + } + + return 1; // Can write +} + +/** + * @brief Mock destroy implementation + */ +static int mock_destroy(esp_transport_handle_t t) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (ctx) { + if (ctx->response_buffer) { + free(ctx->response_buffer); + } + free(ctx); + } + return 0; +} + +/* ============================================ + * Public API Implementation + * ============================================ */ + +esp_transport_handle_t mock_http_transport_create(const mock_http_transport_config_t *config) +{ + // Create transport handle + esp_transport_handle_t transport = esp_transport_init(); + if (!transport) { + ESP_LOGE(TAG, "Failed to create transport handle"); + return NULL; + } + + // Allocate context + mock_http_transport_ctx_t *ctx = calloc(1, sizeof(mock_http_transport_ctx_t)); + if (!ctx) { + ESP_LOGE(TAG, "Failed to allocate mock transport context"); + esp_transport_destroy(transport); + return NULL; + } + + // Initialize with config or defaults + if (config) { + memcpy(&ctx->config, config, sizeof(mock_http_transport_config_t)); + + // Copy response data if provided + if (config->response_data) { + size_t len = config->response_len > 0 ? config->response_len : strlen(config->response_data); + ctx->response_buffer = malloc(len); + if (ctx->response_buffer) { + memcpy(ctx->response_buffer, config->response_data, len); + ctx->config.response_len = len; + } else { + ESP_LOGE(TAG, "Failed to allocate response buffer"); + free(ctx); + esp_transport_destroy(transport); + return NULL; + } + } + } else { + // Use defaults + ctx->config.mode = MOCK_TRANSPORT_MODE_NORMAL; + ctx->config.bytes_before_error = -1; + ctx->config.track_calls = true; + } + + // Initialize error-injection countdown counters from config + ctx->async_polls_left = ctx->config.async_connect_polls; + ctx->wb_reads_left = ctx->config.would_block_reads; + ctx->wb_writes_left = ctx->config.would_block_writes; + + // Set context + esp_transport_set_context_data(transport, ctx); + + // Set transport functions + esp_transport_set_func(transport, + mock_connect, + mock_read, + mock_write, + mock_close, + mock_poll_read, + mock_poll_write, + mock_destroy); + esp_transport_set_async_connect_func(transport, mock_connect_async); + + ESP_LOGI(TAG, "Mock HTTP transport created (mode=%d)", ctx->config.mode); + return transport; +} + +esp_err_t mock_http_transport_destroy(esp_transport_handle_t transport) +{ + if (!transport) { + return ESP_ERR_INVALID_ARG; + } + + return esp_transport_destroy(transport); +} + +esp_err_t mock_http_transport_set_config(esp_transport_handle_t transport, + const mock_http_transport_config_t *config) +{ + if (!transport || !config) { + return ESP_ERR_INVALID_ARG; + } + + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(transport); + if (!ctx) { + return ESP_ERR_INVALID_ARG; + } + + // Update config + memcpy(&ctx->config, config, sizeof(mock_http_transport_config_t)); + + // Update response data if provided + if (config->response_data) { + if (ctx->response_buffer) { + free(ctx->response_buffer); + ctx->response_buffer = NULL; + } + + size_t len = config->response_len > 0 ? config->response_len : strlen(config->response_data); + ctx->response_buffer = malloc(len); + if (ctx->response_buffer) { + memcpy(ctx->response_buffer, config->response_data, len); + ctx->config.response_len = len; + } else { + ESP_LOGE(TAG, "Failed to allocate response buffer"); + return ESP_ERR_NO_MEM; + } + } + + // Reset state for new config (but don't change connection state) + // 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; + ctx->wb_reads_left = ctx->config.would_block_reads; + ctx->wb_writes_left = ctx->config.would_block_writes; + + ESP_LOGD(TAG, "Mock transport config updated (mode=%d)", ctx->config.mode); + return ESP_OK; +} + +esp_err_t mock_http_transport_get_stats(esp_transport_handle_t transport, + mock_http_transport_stats_t *stats) +{ + if (!transport || !stats) { + return ESP_ERR_INVALID_ARG; + } + + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(transport); + if (!ctx) { + return ESP_ERR_INVALID_ARG; + } + + memcpy(stats, &ctx->stats, sizeof(mock_http_transport_stats_t)); + return ESP_OK; +} + +esp_err_t mock_http_transport_reset_stats(esp_transport_handle_t transport) +{ + if (!transport) { + return ESP_ERR_INVALID_ARG; + } + + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(transport); + if (!ctx) { + return ESP_ERR_INVALID_ARG; + } + + memset(&ctx->stats, 0, sizeof(mock_http_transport_stats_t)); + ESP_LOGD(TAG, "Mock transport stats reset"); + return ESP_OK; +} + +esp_err_t mock_http_transport_set_response(esp_transport_handle_t transport, + const char *response_data, + size_t response_len) +{ + if (!transport) { + return ESP_ERR_INVALID_ARG; + } + + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(transport); + if (!ctx) { + return ESP_ERR_INVALID_ARG; + } + + // Free old buffer + if (ctx->response_buffer) { + free(ctx->response_buffer); + ctx->response_buffer = NULL; + ctx->config.response_len = 0; + } + + // Set new response if provided + if (response_data) { + size_t len = response_len > 0 ? response_len : strlen(response_data); + ctx->response_buffer = malloc(len); + if (!ctx->response_buffer) { + ESP_LOGE(TAG, "Failed to allocate response buffer"); + return ESP_ERR_NO_MEM; + } + memcpy(ctx->response_buffer, response_data, len); + ctx->config.response_len = len; + } + + // 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; +} + +esp_err_t mock_http_transport_queue_response(esp_transport_handle_t t, + const char *data, size_t len) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx || ctx->resp_queue_count >= 8 || !data) { + return ESP_ERR_INVALID_ARG; + } + + // Queue entries are caller-owned (e.g. string literals); store the pointer only. + ctx->resp_queue[ctx->resp_queue_count] = data; + ctx->resp_queue_len[ctx->resp_queue_count] = len > 0 ? len : strlen(data); + ctx->resp_queue_count++; + return ESP_OK; +} + +esp_err_t mock_http_transport_get_last_request(esp_transport_handle_t t, + char *buf, size_t buf_len, size_t *out_len) +{ + mock_http_transport_ctx_t *ctx = esp_transport_get_context_data(t); + if (!ctx || !buf || buf_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + size_t n = ctx->req_capture_len < buf_len - 1 ? ctx->req_capture_len : buf_len - 1; + memcpy(buf, ctx->req_capture, n); + buf[n] = '\0'; + if (out_len) { + *out_len = n; + } + 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 new file mode 100644 index 00000000000..ca548b6ecf5 --- /dev/null +++ b/components/esp_http_client/test_apps/mock_transport/test_http_client_mock_transport.h @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include "esp_transport.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Mock transport operation modes for testing error conditions + */ +typedef enum { + MOCK_TRANSPORT_MODE_NORMAL, /*!< Normal operation - successful read/write */ + MOCK_TRANSPORT_MODE_CONNECT_FAIL, /*!< Connection fails */ + MOCK_TRANSPORT_MODE_READ_TIMEOUT, /*!< Read operation times out */ + MOCK_TRANSPORT_MODE_WRITE_FAIL, /*!< Write operation fails */ + MOCK_TRANSPORT_MODE_INCOMPLETE_READ, /*!< Connection closes mid-read (incomplete data) */ + MOCK_TRANSPORT_MODE_WRITE_PARTIAL, /*!< Write only partial data (simulates buffer full) */ +} mock_transport_mode_t; + +/** + * @brief Configuration for mock HTTP transport + */ +typedef struct { + mock_transport_mode_t mode; /*!< Operation mode for error injection */ + const char *response_data; /*!< Canned HTTP response data to return on read */ + size_t response_len; /*!< Length of response data (0 = use strlen) */ + int bytes_before_error; /*!< Number of bytes to process before injecting error (-1 = no limit) */ + int connect_delay_ms; /*!< Delay before connect succeeds (0 = immediate) */ + bool track_calls; /*!< Enable call tracking for verification */ + 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. */ + 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; + +/** + * @brief Default configuration initializer + */ +#define MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG() { \ + .mode = MOCK_TRANSPORT_MODE_NORMAL, \ + .response_data = NULL, \ + .response_len = 0, \ + .bytes_before_error = -1, \ + .connect_delay_ms = 0, \ + .track_calls = true, \ + .async_connect_polls = 0, \ + .would_block_reads = 0, \ + .would_block_writes = 0, \ + .read_bytes_before_error = -1, \ + .max_write_chunk = 0, \ +} + +/** + * @brief Statistics tracked by mock transport + */ +typedef struct { + int connect_calls; /*!< Number of connect() calls */ + int read_calls; /*!< Number of read() calls */ + int write_calls; /*!< Number of write() calls */ + int close_calls; /*!< Number of close() calls */ + int poll_read_calls; /*!< Number of poll_read() calls */ + int poll_write_calls; /*!< Number of poll_write() calls */ + size_t total_bytes_written; /*!< Total bytes written */ + size_t total_bytes_read; /*!< Total bytes read */ +} mock_http_transport_stats_t; + +/** + * @brief Create a mock HTTP transport handle + * + * Creates a transport handle that can be used with esp_http_client for testing. + * The transport simulates network behavior according to the configuration. + * + * @param[in] config Configuration for mock behavior (can be NULL for defaults) + * + * @return + * - Mock transport handle on success + * - NULL on error (memory allocation failure) + * + * @note The returned handle must be destroyed with mock_http_transport_destroy() + */ +esp_transport_handle_t mock_http_transport_create(const mock_http_transport_config_t *config); + +/** + * @brief Destroy mock HTTP transport + * + * Frees all resources associated with the mock transport. + * + * @param[in] transport Mock transport handle + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if transport is NULL + */ +esp_err_t mock_http_transport_destroy(esp_transport_handle_t transport); + +/** + * @brief Update mock transport configuration at runtime + * + * Allows changing the mock behavior between requests without recreating the transport. + * This is useful for testing client reuse after errors. + * + * @param[in] transport Mock transport handle + * @param[in] config New configuration + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if transport is NULL or not a mock transport + */ +esp_err_t mock_http_transport_set_config(esp_transport_handle_t transport, + const mock_http_transport_config_t *config); + +/** + * @brief Get statistics from mock transport + * + * Retrieves call counts and byte counters for verification in tests. + * + * @param[in] transport Mock transport handle + * @param[out] stats Statistics structure to fill + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if transport or stats is NULL + */ +esp_err_t mock_http_transport_get_stats(esp_transport_handle_t transport, + mock_http_transport_stats_t *stats); + +/** + * @brief Reset statistics counters + * + * Clears all call counters and byte counters. Useful between test iterations. + * + * @param[in] transport Mock transport handle + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if transport is NULL + */ +esp_err_t mock_http_transport_reset_stats(esp_transport_handle_t transport); + +/** + * @brief Set canned response data + * + * Convenience function to update just the response data without changing other config. + * + * @param[in] transport Mock transport handle + * @param[in] response_data HTTP response to return on read (can be NULL to clear) + * @param[in] response_len Length of response (0 = use strlen) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if transport is NULL + */ +esp_err_t mock_http_transport_set_response(esp_transport_handle_t transport, + const char *response_data, + size_t response_len); + +/** + * @brief Queue a response to be served after the next request boundary + * + * Appends to a FIFO of up to 8 responses. When mock_write() detects a request + * boundary (the previously active response has been fully read and a new + * write arrives), the next queued response is popped into the active buffer + * via mock_http_transport_set_response(). Useful for testing keep-alive + * request/response sequences on a single connection. + * + * @param[in] t Mock transport handle + * @param[in] data Response data to queue (caller-owned, must outlive its use - e.g. a string literal) + * @param[in] len Length of data (0 = use strlen) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if t is NULL, data is NULL, or the queue is full (8 entries) + */ +esp_err_t mock_http_transport_queue_response(esp_transport_handle_t t, + const char *data, size_t len); + +/** + * @brief Retrieve the bytes captured from the most recent request + * + * mock_write() appends every written byte (capped at 2048 bytes) into an + * internal capture buffer. The buffer is reset only on a queue-advance: a + * write arriving after the current response has been fully read AND a + * queued response is still pending (see mock_http_transport_queue_response()). + * With the queue empty or exhausted, no reset happens and the capture + * concatenates bytes across requests. This lets tests assert on the + * serialized request content (headers, body). + * + * @param[in] t Mock transport handle + * @param[out] buf Buffer to receive the captured request bytes, NUL-terminated + * @param[in] buf_len Size of buf (including space for the NUL terminator) + * @param[out] out_len Optional, number of bytes copied (excluding NUL terminator) + * + * @return + * - ESP_OK on success + * - ESP_ERR_INVALID_ARG if t is NULL, buf is NULL, or buf_len is 0 + */ +esp_err_t mock_http_transport_get_last_request(esp_transport_handle_t t, + char *buf, size_t buf_len, size_t *out_len); + +#ifdef __cplusplus +} +#endif diff --git a/components/esp_http_client/test_apps/pytest_stage0_qemu.py b/components/esp_http_client/test_apps/pytest_stage0_qemu.py new file mode 100644 index 00000000000..f7d7b3d9434 --- /dev/null +++ b/components/esp_http_client/test_apps/pytest_stage0_qemu.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Unlicense OR CC0-1.0 +import pytest +from pytest_embedded import Dut +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(timeout=600) diff --git a/components/esp_http_client/test_apps/sdkconfig.ci.default b/components/esp_http_client/test_apps/sdkconfig.ci.default index e7d6da5519b..0e42092563e 100644 --- a/components/esp_http_client/test_apps/sdkconfig.ci.default +++ b/components/esp_http_client/test_apps/sdkconfig.ci.default @@ -7,3 +7,13 @@ CONFIG_COMPILER_STACK_CHECK_MODE_STRONG=y CONFIG_COMPILER_STACK_CHECK=y CONFIG_ESP_TASK_WDT_EN=n + +# Enable custom transport for mock testing +CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT=y + +# Enable HTTP Basic Authentication so the 401-retry path (auth_type +# auto-detected from WWW-Authenticate and process_again set) is compiled +# in and can be pinned by test_http_client_auth.c. Off by default upstream +# (unencrypted credentials without TLS); this is a test-only config, not a +# client-code change - the mock transport never touches the network. +CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y diff --git a/components/esp_http_client/test_apps/sdkconfig.ci.strict_header b/components/esp_http_client/test_apps/sdkconfig.ci.strict_header index fc86f5414d9..9c7f2715bee 100644 --- a/components/esp_http_client/test_apps/sdkconfig.ci.strict_header +++ b/components/esp_http_client/test_apps/sdkconfig.ci.strict_header @@ -1,2 +1,14 @@ CONFIG_ESP_HTTP_CLIENT_STRICT_HEADER_BUFFER=y CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT=y + +# Enable HTTP Basic Authentication so the 401-retry path (auth_type +# auto-detected from WWW-Authenticate and process_again set) is compiled +# in and can be pinned by test_http_client_auth.c and the adapted 401 +# case in test_http_client_basic.c. Off by default upstream (unencrypted +# credentials without TLS); this is a test-only config, not a client-code +# change - the mock transport never touches the network. Must be kept in +# sync with sdkconfig.ci.default: pytest_esp_http_client_ut.py runs the +# full unfiltered suite against both the 'default' and 'strict_header' +# configs (see docs/en/contribute/esp-idf-tests-with-pytest.rst), so any +# sdkconfig.ci.* file that suite parametrizes over must carry this option. +CONFIG_ESP_HTTP_CLIENT_ENABLE_BASIC_AUTH=y diff --git a/components/esp_http_client/test_apps/sdkconfig.defaults b/components/esp_http_client/test_apps/sdkconfig.defaults new file mode 100644 index 00000000000..e69fdbe645e --- /dev/null +++ b/components/esp_http_client/test_apps/sdkconfig.defaults @@ -0,0 +1,3 @@ +# Some of the test apps allocate 2048 size buffers on the stack. +# Increase the default stack size to accommodate for that +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192