mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
Merge branch 'test/esp_http_client_characterization' into 'master'
test(esp_http_client): add mock transport and basic characterization tests See merge request espressif/esp-idf!52212
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <string.h>
|
||||
#include <errno.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_200 =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Length: 5\r\n"
|
||||
"\r\n"
|
||||
"hello";
|
||||
|
||||
TEST_CASE("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
|
||||
@@ -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 <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
#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
|
||||
@@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
#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
|
||||
@@ -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 <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_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
|
||||
@@ -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 <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_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
|
||||
@@ -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 <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_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
|
||||
@@ -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 <string.h>
|
||||
#include "esp_http_client.h"
|
||||
#include "unity.h"
|
||||
#include "sdkconfig.h"
|
||||
#include "test_http_client_mock_transport.h"
|
||||
|
||||
/*
|
||||
* Every case in this file drives the client through a mock transport injected
|
||||
* via esp_http_client_config_t::transport. Without custom transport support
|
||||
* the clients would fall back to a real transport aimed at test-server.local,
|
||||
* which does not exist, so the whole file compiles out.
|
||||
*/
|
||||
#if CONFIG_ESP_HTTP_CLIENT_ENABLE_CUSTOM_TRANSPORT
|
||||
|
||||
static const char *resp_200 =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Content-Length: 5\r\n"
|
||||
"\r\n"
|
||||
"hello";
|
||||
|
||||
TEST_CASE("open/write/fetch_headers/read sequence works", "[esp_http_client][streaming][p0]")
|
||||
{
|
||||
mock_http_transport_config_t mc = MOCK_HTTP_TRANSPORT_DEFAULT_CONFIG();
|
||||
mc.response_data = resp_200;
|
||||
esp_transport_handle_t mock = mock_http_transport_create(&mc);
|
||||
TEST_ASSERT_NOT_NULL(mock);
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = "http://test-server.local/upload",
|
||||
.method = HTTP_METHOD_POST,
|
||||
.transport = mock,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
TEST_ASSERT_NOT_NULL(client);
|
||||
|
||||
const char *body = "abcde";
|
||||
TEST_ASSERT_EQUAL(ESP_OK, esp_http_client_open(client, 5));
|
||||
// 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
|
||||
+743
@@ -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 <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
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;
|
||||
}
|
||||
+226
@@ -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 <stdbool.h>
|
||||
|
||||
#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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user