Files
cproof/tests/functionaltests/test_ai_http.c
jabber.developer2 e67ae4e7a5 test(ai): expand unit and functional coverage for /ai feature
Unit tests (tests/unittests/test_ai_client.c): +50 cases on top of the
existing 50 — total 100. Covers:
  - chat response parser (ai_parse_response): OpenAI content + Perplexity
    text formats, escape decoding, empty/null/missing inputs, format-string
    safety, multiline content
  - error envelope parser (ai_parse_error_message): standard envelope,
    nested escapes, missing fields, null/empty
  - extended JSON escape: \b, \f, \r, all-specials, UTF-8 pass-through
  - provider autocomplete cycling with >=2 matches and wrap-around
  - session edge cases: NULL args, 100-message order preservation,
    set_model(NULL), ref/unref(NULL)
  - provider edge cases: get/remove with NULL, double-remove, survival
    via session ref after ai_remove_provider
  - settings: multi-key independence, missing key, cross-provider
    isolation
  - model parsing edges: data not array, empty data, "id" outside data,
    multiple models
  - prefs round-trip: set token -> shutdown -> init -> token reloaded
    from disk (uses load_preferences fixture)

Functional tests:
  - Tier A (test_ai.c): 15 cases for the /ai command surface that don't
    need HTTP. Covers /ai help, providers list, set provider/token,
    start with/without key/unknown provider, clear, remove, default
    provider/model, switch without window, bad subcommand.
  - Tier B (test_ai_http.c + ai_http_stub.{c,h,py}): 5 cases that
    exercise the libcurl path against a local Python HTTP stub. The
    stub serves canned bodies in five modes (ok, openai, 401, 500,
    models) so each chat/models/error path is hit end-to-end without
    network.

Infrastructure:
  - TEST_GROUPS bumped to 5 in proftest.c; AI tests live in their own
    Group 5 because mixing them with stabber-driven tests in Group 4
    poisoned stbbr_stop() teardown.
  - PROF_FUNC_TEST_AI macro in functionaltests.c registers AI tests
    with ai_init_test() which wraps init_prof_test with a prof_connect()
    so stabber sees a graceful disconnect at teardown.
  - dist_check_DATA ships ai_http_stub.py with the source tarball.

Source-level changes to enable testing:
  - src/ai/ai_client.{c,h}: dropped 'static' from _parse_ai_response
    (renamed ai_parse_response) and _parse_error_response (renamed
    ai_parse_error_message); declared under a "Parsing helpers (exposed
    for testing)" section. Same approach as ai_parse_models_from_json.

Results in Docker (cproof-debian image):
  - 595/595 unit tests pass
  - 20/20 AI functional tests pass (Group 5)
2026-05-11 16:21:45 +03:00

158 lines
4.4 KiB
C

/*
* test_ai_http.c
*
* Functional tests for the /ai command surface that exercise the libcurl
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
* per-test on a free local port; profanity is configured to point at it
* via `/ai set provider`. The stub serves canned JSON whose shape is
* controlled via AI_STUB_MODE.
*
* These tests intentionally do not race the worker thread directly —
* they rely on prof_timeout() retries to catch the response once it
* propagates back through the AI window.
*/
#include <glib.h>
#include "prof_cmocka.h"
#include <stdlib.h>
#include <string.h>
#include "proftest.h"
#include "ai_http_stub.h"
#define WAIT_AI_RESPONSE_SEC 10
/*
* Configure profanity to use the spawned stub as a provider named "mock"
* with a dummy bearer token, then issue /ai start to open the AI window.
* Returns the stub port on success, fails the test on stub-spawn failure.
*/
static int
_setup_mock_provider(const char* mode, const char* reply)
{
int port = ai_http_stub_start(mode, reply);
assert_int_not_equal(0, port);
char cmd[256];
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
prof_input(cmd);
prof_timeout(5);
assert_true(prof_output_regex("Provider 'mock' configured"));
prof_timeout_reset();
prof_input("/ai set token mock fake-test-token");
prof_timeout(5);
assert_true(prof_output_regex("API token set for provider: mock"));
prof_timeout_reset();
return port;
}
void
ai_http_prompt_returns_ok(void** state)
{
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
_setup_mock_provider("ok", "STUB-HELLO");
prof_input("/ai start mock testmodel");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
prof_timeout_reset();
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
prof_input("hello stub");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("STUB-HELLO"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_openai_format(void** state)
{
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
_setup_mock_provider("openai", "FROM-CHOICES");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("anything");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("FROM-CHOICES"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_401_shows_error(void** state)
{
/* 401 with an OpenAI-style error envelope.
* ai_parse_error_message() should extract the inner message; the AI
* window then displays "HTTP 401: <message>". */
_setup_mock_provider("401", "Bad token, try another");
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("prompt");
prof_timeout(WAIT_AI_RESPONSE_SEC);
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_prompt_500_surfaced(void** state)
{
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
_setup_mock_provider("500", NULL);
prof_input("/ai start mock m");
prof_timeout(5);
assert_true(prof_output_regex("AI Chat: mock/m"));
prof_timeout_reset();
prof_input("trigger");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("HTTP 500"));
prof_timeout_reset();
ai_http_stub_stop();
}
void
ai_http_models_fetch_lists_models(void** state)
{
/* /v1/models endpoint returns a list of three models. /ai models <p>
* caches them silently; /ai models <p> --cached then displays the names. */
_setup_mock_provider("models", NULL);
prof_input("/ai models mock");
prof_timeout(WAIT_AI_RESPONSE_SEC);
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
prof_timeout_reset();
prof_input("/ai models mock --cached");
prof_timeout(5);
assert_true(prof_output_regex("model-a"));
assert_true(prof_output_regex("model-b"));
assert_true(prof_output_regex("model-c"));
prof_timeout_reset();
ai_http_stub_stop();
}