mirror of
https://git.jabber.space/devs/cproof.git
synced 2026-07-18 08:26:21 +00:00
Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom endpoints) to provide AI-assisted chat within CProof. Users can start sessions with /ai start, send prompts, receive responses in a dedicated AI window, switch between providers and models, and manage API keys — all with tab-completion. Providers are configured via /ai set commands with per-provider API keys, endpoints, default models, and custom settings. Two default providers (openai, perplexity) are seeded on first use. Provider state persists in [ai/<name>] sections of the preferences keyfile with automatic migration from the previous flat-key format. The /ai command integrates into the existing command system with 8 subcommands covering provider management, session lifecycle, model fetching, and conversation clearing. Autocomplete uses the standard flat prefix-matching chain for reliable tab-completion at every nesting level. A new ProfAiWin window type is added to the window system. Architecture: Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s) Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM JSON parsing uses unified helpers for both chat responses and error envelopes with consistent escape decoding. The response parser tries Perplexity /v1/responses "text" field first, then falls back to OpenAI "content". Error parsing extracts provider error.message from the standard envelope format. Model parsing handles multiple API response formats (OpenAI list, Perplexity, array) including edge cases. Tests include 470+ lines of unit tests covering provider management, session lifecycle, JSON parsing (multiple formats), autocomplete cycling, and error handling, plus functional tests for /ai command dispatch. A stub_ai.c module isolates unit tests from UI dependencies.
375 lines
16 KiB
C
375 lines
16 KiB
C
/*
|
|
* functionaltests.c
|
|
*
|
|
* Functional tests for CProof XMPP client.
|
|
* Uses cmocka framework with stabber mock XMPP server.
|
|
*
|
|
* Each test is wrapped with PROF_FUNC_TEST macro which sets up
|
|
* init_prof_test (starts stabber server and profanity client)
|
|
* and close_prof_test (cleanup) as setup/teardown functions.
|
|
*
|
|
* NOTE: We restart client and server for each test to ensure complete
|
|
* isolation. This prevents state leakage between tests (roster entries,
|
|
* MUC rooms, presence subscriptions, etc.). While slower, it eliminates
|
|
* flaky tests caused by leftover state. The overhead is acceptable since
|
|
* functional tests run less frequently than unit tests.
|
|
*
|
|
* Tests are organized into groups balanced for parallel execution:
|
|
* Group 1: Connect, Ping, Rooms, Software, Last Activity, Autoping, Disco, Roster
|
|
* Group 2: Export/Import (core + bidirectional), Receipts
|
|
* Group 3: Presence, Disconnect, DB History, Message, MUC Database
|
|
* Group 4: Chat Session, MUC, Carbons, Migration Stress
|
|
*
|
|
* Parallel execution:
|
|
* ./functionaltests - run all tests sequentially
|
|
* ./functionaltests N - run group N only (N = 1..num_groups)
|
|
*
|
|
* For parallel execution, run multiple groups simultaneously:
|
|
* ./functionaltests 1 & ./functionaltests 2 & ./functionaltests 3 & ./functionaltests 4 & wait
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <unistd.h>
|
|
#include <fcntl.h>
|
|
#include <string.h>
|
|
#include "prof_cmocka.h"
|
|
#include <sys/stat.h>
|
|
|
|
#include "config.h"
|
|
|
|
#include "common.h"
|
|
#include "proftest.h"
|
|
#include "test_connect.h"
|
|
#include "test_ping.h"
|
|
#include "test_rooms.h"
|
|
#include "test_presence.h"
|
|
#include "test_message.h"
|
|
#include "test_carbons.h"
|
|
#include "test_chat_session.h"
|
|
#include "test_receipts.h"
|
|
#include "test_roster.h"
|
|
#include "test_software.h"
|
|
#include "test_muc.h"
|
|
#include "test_disconnect.h"
|
|
#include "test_lastactivity.h"
|
|
#include "test_autoping.h"
|
|
#include "test_disco.h"
|
|
#include "test_history.h"
|
|
#ifdef HAVE_SQLITE
|
|
#include "test_export_import.h"
|
|
#endif
|
|
#include "test_ai.h"
|
|
|
|
/* Macro to wrap each test with setup/teardown functions */
|
|
#define PROF_FUNC_TEST(test) \
|
|
{ \
|
|
#test, test, init_prof_test, close_prof_test, #test \
|
|
}
|
|
|
|
/* AI tests use a custom init that primes stabber via prof_connect so
|
|
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
|
|
* server thread. See ai_init_test() in test_ai.c. */
|
|
#define PROF_FUNC_TEST_AI(test) \
|
|
{ \
|
|
#test, test, ai_init_test, close_prof_test, #test \
|
|
}
|
|
|
|
#ifdef HAVE_SQLITE
|
|
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
|
static int
|
|
init_tz_plus3_test(void** state)
|
|
{
|
|
prof_test_tz = "TST-3";
|
|
int ret = init_prof_test(state);
|
|
prof_test_tz = "UTC";
|
|
return ret;
|
|
}
|
|
#endif
|
|
|
|
int
|
|
main(int argc, char* argv[])
|
|
{
|
|
int group = 0; /* 0 = all groups */
|
|
if (argc > 1) {
|
|
group = atoi(argv[1]);
|
|
if (group < 1 || group > 5) {
|
|
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
|
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
char group_env[16];
|
|
snprintf(group_env, sizeof(group_env), "%d", group);
|
|
setenv("PROF_TEST_GROUP", group_env, 1);
|
|
|
|
fprintf(stderr, "[PROF_TEST] Starting functional tests, group=%d\n", group);
|
|
|
|
/* ============================================================
|
|
* GROUP 1: Connect, Ping, Rooms, Software, Last Activity, Disco, Roster
|
|
* Basic XMPP session establishment, server queries, contact mgmt
|
|
* ============================================================ */
|
|
const struct CMUnitTest group1_tests[] = {
|
|
/* Connection tests - verify login, roster, bookmarks */
|
|
PROF_FUNC_TEST(connect_jid_requests_roster),
|
|
PROF_FUNC_TEST(connect_jid_sends_presence_after_receiving_roster),
|
|
PROF_FUNC_TEST(connect_jid_requests_bookmarks),
|
|
PROF_FUNC_TEST(connect_bad_password),
|
|
PROF_FUNC_TEST(connect_shows_presence_updates),
|
|
|
|
/* Ping tests - XEP-0199 XMPP Ping */
|
|
PROF_FUNC_TEST(ping_server),
|
|
PROF_FUNC_TEST(ping_server_not_supported),
|
|
PROF_FUNC_TEST(ping_responds_to_server_request),
|
|
PROF_FUNC_TEST(ping_jid),
|
|
PROF_FUNC_TEST(ping_jid_not_supported),
|
|
|
|
/* Room discovery - XEP-0045 */
|
|
PROF_FUNC_TEST(rooms_query),
|
|
|
|
/* Software Version - XEP-0092 */
|
|
PROF_FUNC_TEST(send_software_version_request),
|
|
PROF_FUNC_TEST(display_software_version_result),
|
|
PROF_FUNC_TEST(shows_message_when_software_version_error),
|
|
PROF_FUNC_TEST(display_software_version_result_when_from_domainpart),
|
|
PROF_FUNC_TEST(show_message_in_chat_window_when_no_resource),
|
|
PROF_FUNC_TEST(display_software_version_result_in_chat),
|
|
|
|
/* Last Activity - XEP-0012 */
|
|
PROF_FUNC_TEST(responds_to_last_activity_request),
|
|
PROF_FUNC_TEST(last_activity_request_to_contact),
|
|
|
|
/* Autoping command tests - fast, no waiting */
|
|
PROF_FUNC_TEST(autoping_set_interval),
|
|
PROF_FUNC_TEST(autoping_set_zero_disables),
|
|
PROF_FUNC_TEST(autoping_timeout_set),
|
|
PROF_FUNC_TEST(autoping_timeout_zero_disables),
|
|
|
|
/* Autoping slow tests - require sleep for timer triggers (~2s each) */
|
|
PROF_FUNC_TEST(autoping_sends_ping_after_interval),
|
|
PROF_FUNC_TEST(autoping_server_not_supporting_ping),
|
|
|
|
/* Service Discovery - XEP-0030 */
|
|
PROF_FUNC_TEST(disco_info_shows_identity),
|
|
PROF_FUNC_TEST(disco_info_shows_features),
|
|
PROF_FUNC_TEST(disco_info_to_server),
|
|
PROF_FUNC_TEST(disco_info_to_jid),
|
|
PROF_FUNC_TEST(disco_info_not_found),
|
|
PROF_FUNC_TEST(disco_items_shows_items),
|
|
PROF_FUNC_TEST(disco_items_empty_result),
|
|
PROF_FUNC_TEST(disco_requires_connection),
|
|
PROF_FUNC_TEST(disco_items_to_jid),
|
|
PROF_FUNC_TEST(disco_info_empty_result),
|
|
PROF_FUNC_TEST(disco_info_multiple_identities),
|
|
PROF_FUNC_TEST(disco_info_without_name),
|
|
PROF_FUNC_TEST(disco_items_without_name),
|
|
PROF_FUNC_TEST(disco_info_service_unavailable),
|
|
|
|
/* Roster management - add/remove/rename contacts */
|
|
PROF_FUNC_TEST(sends_new_item),
|
|
PROF_FUNC_TEST(sends_new_item_nick),
|
|
PROF_FUNC_TEST(sends_remove_item),
|
|
PROF_FUNC_TEST(sends_remove_item_nick),
|
|
PROF_FUNC_TEST(sends_nick_change),
|
|
};
|
|
|
|
/* ============================================================
|
|
* GROUP 2: Export/Import (core + bidirectional), Receipts
|
|
* Cross-backend migration, message timestamp round-trip
|
|
* ============================================================ */
|
|
const struct CMUnitTest group2_tests[] = {
|
|
#ifdef HAVE_SQLITE
|
|
/* Export/Import — cross-backend migration (SQLite ↔ flat-file) */
|
|
PROF_FUNC_TEST(export_sqlite_to_flatfile),
|
|
PROF_FUNC_TEST(import_flatfile_to_sqlite),
|
|
PROF_FUNC_TEST(export_idempotent_no_duplicates),
|
|
PROF_FUNC_TEST(export_lmc_correction_survives),
|
|
PROF_FUNC_TEST(switch_preserves_old_backend_data),
|
|
PROF_FUNC_TEST(export_all_contacts),
|
|
PROF_FUNC_TEST(import_double_dedup),
|
|
PROF_FUNC_TEST(verify_after_export),
|
|
PROF_FUNC_TEST(switch_backends_independent_messages),
|
|
PROF_FUNC_TEST(export_empty_contact),
|
|
|
|
/* Bidirectional migration — split timelines merged across backends */
|
|
PROF_FUNC_TEST(bidirectional_merge_to_flatfile),
|
|
PROF_FUNC_TEST(bidirectional_merge_to_sqlite),
|
|
PROF_FUNC_TEST(migration_incremental_accumulation),
|
|
PROF_FUNC_TEST(roundtrip_full_cycle),
|
|
PROF_FUNC_TEST(export_timestamps_preserved),
|
|
PROF_FUNC_TEST(import_timestamps_preserved),
|
|
{ "export_timestamps_non_utc", export_timestamps_non_utc, init_tz_plus3_test, close_prof_test, "export_timestamps_non_utc" },
|
|
#endif
|
|
|
|
/* Message receipts - XEP-0184 */
|
|
PROF_FUNC_TEST(does_not_send_receipt_request_to_barejid),
|
|
PROF_FUNC_TEST(send_receipt_request),
|
|
PROF_FUNC_TEST(send_receipt_on_request),
|
|
};
|
|
|
|
/* ============================================================
|
|
* GROUP 3: Presence, Disconnect, DB History, Message, MUC Database
|
|
* Status management, message persistence, MUC storage
|
|
* ============================================================ */
|
|
const struct CMUnitTest group3_tests[] = {
|
|
/* Presence - online/away/xa/dnd/chat status management */
|
|
PROF_FUNC_TEST(presence_online),
|
|
PROF_FUNC_TEST(presence_online_with_message),
|
|
PROF_FUNC_TEST(presence_away),
|
|
PROF_FUNC_TEST(presence_away_with_message),
|
|
PROF_FUNC_TEST(presence_xa),
|
|
PROF_FUNC_TEST(presence_xa_with_message),
|
|
PROF_FUNC_TEST(presence_dnd),
|
|
PROF_FUNC_TEST(presence_dnd_with_message),
|
|
PROF_FUNC_TEST(presence_chat),
|
|
PROF_FUNC_TEST(presence_chat_with_message),
|
|
PROF_FUNC_TEST(presence_set_priority),
|
|
PROF_FUNC_TEST(presence_includes_priority),
|
|
PROF_FUNC_TEST(presence_keeps_status),
|
|
PROF_FUNC_TEST(presence_received),
|
|
PROF_FUNC_TEST(presence_missing_resource_defaults),
|
|
|
|
/* Disconnect - clean session termination */
|
|
PROF_FUNC_TEST(disconnect_ends_session),
|
|
|
|
/* Database persistence - message write+read round-trip */
|
|
PROF_FUNC_TEST(message_db_history_on_reopen),
|
|
PROF_FUNC_TEST(message_db_history_multiple),
|
|
PROF_FUNC_TEST(message_db_history_contact_isolation),
|
|
PROF_FUNC_TEST(message_db_history_special_chars),
|
|
PROF_FUNC_TEST(message_db_history_outgoing),
|
|
PROF_FUNC_TEST(message_db_history_dialog),
|
|
PROF_FUNC_TEST(message_db_history_empty),
|
|
PROF_FUNC_TEST(message_db_history_long_message),
|
|
PROF_FUNC_TEST(message_db_history_newline),
|
|
PROF_FUNC_TEST(message_db_history_service_chars),
|
|
PROF_FUNC_TEST(message_db_history_verify),
|
|
PROF_FUNC_TEST(message_db_history_lmc),
|
|
PROF_FUNC_TEST(message_db_history_multi_resource),
|
|
|
|
/* Basic message send/receive */
|
|
PROF_FUNC_TEST(message_send),
|
|
PROF_FUNC_TEST(message_receive_console),
|
|
PROF_FUNC_TEST(message_receive_chatwin),
|
|
|
|
#ifdef HAVE_SQLITE
|
|
/* MUC (groupchat) database — export/import of type="muc" messages */
|
|
PROF_FUNC_TEST(muc_export_sqlite_to_flatfile),
|
|
PROF_FUNC_TEST(muc_import_flatfile_to_sqlite),
|
|
PROF_FUNC_TEST(muc_export_timestamps_preserved),
|
|
PROF_FUNC_TEST(muc_export_multiple_rooms),
|
|
#endif
|
|
};
|
|
|
|
/* ============================================================
|
|
* GROUP 4: Chat Session, MUC, Carbons, Migration Stress
|
|
* Session routing, multi-user chat, message sync, stress tests
|
|
* ============================================================ */
|
|
const struct CMUnitTest group4_tests[] = {
|
|
/* Chat session management - bare/full JID routing */
|
|
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_offline),
|
|
PROF_FUNC_TEST(sends_message_to_barejid_when_contact_online),
|
|
PROF_FUNC_TEST(sends_message_to_fulljid_when_received_from_fulljid),
|
|
PROF_FUNC_TEST(sends_subsequent_messages_to_fulljid),
|
|
PROF_FUNC_TEST(resets_to_barejid_after_presence_received),
|
|
PROF_FUNC_TEST(new_session_when_message_received_from_different_fulljid),
|
|
|
|
/* MUC room join with various options - XEP-0045 */
|
|
PROF_FUNC_TEST(sends_room_join),
|
|
PROF_FUNC_TEST(sends_room_join_with_nick),
|
|
PROF_FUNC_TEST(sends_room_join_with_password),
|
|
PROF_FUNC_TEST(sends_room_join_with_nick_and_password),
|
|
|
|
/* MUC room information display */
|
|
PROF_FUNC_TEST(shows_role_and_affiliation_on_join),
|
|
PROF_FUNC_TEST(shows_subject_on_join),
|
|
PROF_FUNC_TEST(shows_occupant_join),
|
|
|
|
/* MUC messaging */
|
|
PROF_FUNC_TEST(shows_message),
|
|
PROF_FUNC_TEST(shows_me_message_from_occupant),
|
|
PROF_FUNC_TEST(shows_me_message_from_self),
|
|
|
|
/* MUC console notification settings */
|
|
PROF_FUNC_TEST(shows_all_messages_in_console_when_window_not_focussed),
|
|
PROF_FUNC_TEST(shows_first_message_in_console_when_window_not_focussed),
|
|
PROF_FUNC_TEST(shows_no_message_in_console_when_window_not_focussed),
|
|
|
|
/* MUC moderation - XEP-0045 room admin */
|
|
PROF_FUNC_TEST(sends_affiliation_list_request),
|
|
PROF_FUNC_TEST(sends_kick_request),
|
|
|
|
/* Message Carbons - XEP-0280 (message sync across devices) */
|
|
PROF_FUNC_TEST(send_enable_carbons),
|
|
PROF_FUNC_TEST(connect_with_carbons_enabled),
|
|
PROF_FUNC_TEST(send_disable_carbons),
|
|
PROF_FUNC_TEST(receive_carbon),
|
|
PROF_FUNC_TEST(receive_self_carbon),
|
|
PROF_FUNC_TEST(receive_private_carbon),
|
|
|
|
#ifdef HAVE_SQLITE
|
|
/* Migration stress — pool accumulation, LMC chains, large bodies */
|
|
PROF_FUNC_TEST(export_message_pool_dated),
|
|
PROF_FUNC_TEST(export_lmc_pool_multiple),
|
|
PROF_FUNC_TEST(export_large_body_survives),
|
|
#endif
|
|
};
|
|
|
|
/* ============================================================
|
|
* GROUP 5: AI command surface (console + HTTP stub)
|
|
* Standalone because AI tests don't use stabber's XMPP path; mixing
|
|
* them with XMPP-bound tests in the same group poisons stabber state
|
|
* between fixture init/teardown cycles and hangs subsequent
|
|
* prof_connect() calls.
|
|
* ============================================================ */
|
|
const struct CMUnitTest group5_tests[] = {
|
|
/* Console-only — no network calls */
|
|
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
|
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
|
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
|
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
|
|
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
|
|
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
|
|
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
|
|
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
|
|
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
|
|
PROF_FUNC_TEST_AI(ai_remove_provider_works),
|
|
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
|
|
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
|
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
|
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
|
};
|
|
|
|
/* Test group registry for easy extension */
|
|
struct
|
|
{
|
|
const char* name;
|
|
const struct CMUnitTest* tests;
|
|
size_t count;
|
|
} groups[] = {
|
|
{ "Group 1: Connect/Ping/Disco/Roster", group1_tests, ARRAY_SIZE(group1_tests) },
|
|
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
|
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
|
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
|
{ "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) },
|
|
};
|
|
const int num_groups = ARRAY_SIZE(groups);
|
|
|
|
int result = 0;
|
|
|
|
if (group > 0 && group <= num_groups) {
|
|
/* Run specific group */
|
|
result = _cmocka_run_group_tests(groups[group - 1].name, groups[group - 1].tests,
|
|
groups[group - 1].count, NULL, NULL);
|
|
} else {
|
|
/* Run all groups sequentially */
|
|
for (int i = 0; i < num_groups; i++) {
|
|
result |= _cmocka_run_group_tests(groups[i].name, groups[i].tests,
|
|
groups[i].count, NULL, NULL);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|