From f537f302c62ca09dbec45803864574b08f1be285 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 17:46:30 +0100 Subject: [PATCH] Exercise member dialogs against a real worker and SQLite failures --- tools/chatgui/CMakeLists.txt | 10 + tools/chatgui/tests/test_member_dialogs.cpp | 234 ++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 tools/chatgui/tests/test_member_dialogs.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index f71b177b..f4832e3a 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -533,6 +533,16 @@ endif() add_test(NAME test_message_events COMMAND test_message_events) set_tests_properties(test_message_events PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) +add_executable(test_member_dialogs tests/test_member_dialogs.cpp src/renamedialog.cpp src/memberpropsdialog.cpp + db/db_manager.cpp transport/gui_bridge_impl.cpp src/connmonitorwindow.cpp) +target_link_libraries(test_member_dialogs PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun utun_voice pthread ${CMAKE_DL_LIBS}) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(test_member_dialogs PRIVATE UTUN_TEST_MEMBER_ALLOC_FAILURES) + target_link_options(test_member_dialogs PRIVATE "-Wl,--wrap=u_malloc_impl" "-Wl,--wrap=u_calloc_impl") +endif() +add_test(NAME test_member_dialogs COMMAND test_member_dialogs) +set_tests_properties(test_member_dialogs PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + # Настоящее окно и тот же набор исходников/зависимостей приложения, без второго main. get_target_property(DM_DELETION_SOURCES vibechat SOURCES) list(REMOVE_ITEM DM_DELETION_SOURCES src/main.cpp) diff --git a/tools/chatgui/tests/test_member_dialogs.cpp b/tools/chatgui/tests/test_member_dialogs.cpp new file mode 100644 index 00000000..22aea257 --- /dev/null +++ b/tools/chatgui/tests/test_member_dialogs.cpp @@ -0,0 +1,234 @@ +/* Реальные Qt-диалоги, ядро в отдельном uasync-потоке и SQLite: commit, отказы и lifetime результата. */ +#include "../src/renamedialog.h" +#include "../src/memberpropsdialog.h" +#include "../transport/member_edit.h" +#include "../transport/gui_bridge.h" +#include "../db/db_manager.h" +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "utun_instance.h" +#include "chat/chat_core.h" +#include "debug_config.h" +} + +#ifdef UTUN_TEST_MEMBER_ALLOC_FAILURES +static int failMalloc, failCalloc; +extern "C" void* __real_u_malloc_impl(uint32_t, const char*); +extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*); +extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* where) { + if (failMalloc && strstr(where, "gui_bridge_impl.cpp") && !--failMalloc) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member snapshot allocation failure"); return nullptr; + } + return __real_u_malloc_impl(bytes, where); +} +extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* where) { + if (failCalloc && strstr(where, "gui_bridge_impl.cpp") && !--failCalloc) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member queue allocation failure"); return nullptr; + } + return __real_u_calloc_impl(count, bytes, where); +} +#endif + +struct MemberCore { + QTemporaryDir directory; + DbManager db; + UTUN_INSTANCE* instance = nullptr; + UASYNC* ua = nullptr; + QString channel; + quint64 node = 0; + uint8_t flags = 0; + std::thread worker; + std::mutex mutex; + std::condition_variable wake; + bool paused = true, active = false, stopping = false; + + void stop() { + { std::lock_guard lock(mutex); stopping = true; wake.notify_all(); } + if (worker.joinable()) worker.join(); + db.setDb(nullptr); + } + ~MemberCore() { stop(); } + bool start() { + std::promise ready; auto result = ready.get_future(); + worker = std::thread([this, &ready] { + ua = uasync_create(); + instance = ua ? utun_instance_create_from_str(ua, + "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n" + "local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr; + bool ok = instance && !utun_core_start(instance) && !chat_service_start(instance); + uint64_t id = ok ? chat_core_create_channel_auto(instance, "Members") : 0; + ok = ok && id; + if (ok) { + channel = QString::number(id); node = instance->node_id; + const QByteArray sql = QString("UPDATE peers_%1 SET local_nick='Old nickname' WHERE node_id=%2") + .arg(channel).arg(static_cast(node)).toUtf8(); + ok = sqlite3_exec(instance->topo_sqlite_db, sql.constData(), nullptr, nullptr, nullptr) == SQLITE_OK; + uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE]; chat_member_display member{}; + ok = ok && !chat_core_get_single_member(instance, qPrintable(channel), node, wire) && + !chat_core_deserialize_member(wire, &member); + flags = member.flags; + gui_bridge_set_uasync(ua); gui_bridge_set_inst(instance); + } + ready.set_value(ok); + if (ok) { + std::unique_lock lock(mutex); + while (!stopping) { + wake.wait(lock, [this] { return !paused || stopping; }); + if (stopping) break; + active = true; lock.unlock(); uasync_poll(ua, 10); lock.lock(); active = false; wake.notify_all(); + } + } + gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); + if (ua) uasync_mark_stopped(ua); + if (instance) utun_instance_destroy(instance); + if (ua) uasync_destroy(ua, 0); + }); + if (!result.get()) return false; + db.setDb(instance->topo_sqlite_db); db.setMyNodeId(node); return true; + } + void resume() { std::lock_guard lock(mutex); paused = false; wake.notify_all(); } + void pause() { std::unique_lock lock(mutex); paused = true; wake.wait(lock, [this] { return !active; }); } + int run(std::function function) { + auto task = std::make_unique>(std::move(function)); auto result = task->get_future(); + gui_bridge_post_uasync_fn([](void* arg) { + std::unique_ptr> task(static_cast*>(arg)); (*task)(); + }, task.release()); + resume(); + if (result.wait_for(std::chrono::seconds(5)) != std::future_status::ready) qFatal("Member test core command timed out"); + int value = result.get(); pause(); return value; + } + QByteArray snapshot() { + sqlite3_stmt* st = nullptr; + QByteArray sql = QString("SELECT coalesce(local_nick,''),coalesce(adm_tags,''),hex(adm_tags_sig) FROM peers_%1 WHERE node_id=%2") + .arg(channel).arg(static_cast(node)).toUtf8(); + if (sqlite3_prepare_v2(db.m_db, sql.constData(), -1, &st, nullptr) != SQLITE_OK) qFatal("Snapshot prepare failed"); + QByteArray result; + if (sqlite3_step(st) == SQLITE_ROW) { + for (int i = 0; i < 3; ++i) { result += DbManager::colText(st, i).toUtf8(); result += '\n'; } + } + if (sqlite3_finalize(st) != SQLITE_OK) qFatal("Snapshot finalize failed"); + return result; + } +}; + +static std::unique_ptr makeDialog(MemberCore& core, bool properties) { + if (properties) return std::make_unique(&core.db, core.channel, core.node, "Member", core.flags); + return std::make_unique(&core.db, core.channel, core.node, "Member"); +} +static QLabel* errorLabel(QDialog* dialog) { return dialog->findChild("memberSaveError"); } +static bool save(QDialog* dialog) { return QMetaObject::invokeMethod(dialog, "onSave", Qt::DirectConnection); } + +class MemberDialogTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG); gui_bridge_init(); + } + void saves_data() { + QTest::addColumn("properties"); QTest::addColumn("failure"); + for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"}) + QTest::newRow(qPrintable(QString("%1-%2").arg(properties ? "properties" : "rename", failure))) << properties << QString(failure); + } + void saves() { + QFETCH(bool, properties); QFETCH(QString, failure); + MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties); auto before = core.snapshot(); + struct Probe { bool deny; int updates = 0; bool gui = false; } probe{failure == "prepare"}; + QCOMPARE(core.run([&] { + if (failure == "step") { + QByteArray sql = QString("CREATE TRIGGER refuse_nick BEFORE UPDATE OF local_nick ON peers_%1 " + "BEGIN SELECT RAISE(ABORT,'injected nickname failure'); END").arg(core.channel).toUtf8(); + if (sqlite3_exec(core.db.m_db, sql.constData(), nullptr, nullptr, nullptr) != SQLITE_OK) return -1; + } + if (failure == "missing") { + QByteArray sql = QString("DELETE FROM peers_%1").arg(core.channel).toUtf8(); + if (sqlite3_exec(core.db.m_db, sql.constData(), nullptr, nullptr, nullptr) != SQLITE_OK) return -1; + } + if (failure == "commit") sqlite3_commit_hook(core.db.m_db, [](void*) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member commit refused"); return 1; + }, nullptr); + return sqlite3_set_authorizer(core.db.m_db, [](void* arg, int action, const char*, const char* column, const char*, const char*) { + auto& probe = *static_cast(arg); + if (action != SQLITE_UPDATE || !column || strcmp(column, "local_nick")) return SQLITE_OK; + ++probe.updates; probe.gui |= QThread::currentThread() == qApp->thread(); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "[PROBE] nickname UPDATE gui_thread=%d deny=%d", probe.gui, probe.deny); + return probe.deny ? SQLITE_DENY : SQLITE_OK; + }, &probe); + }), SQLITE_OK); + if (failure == "missing") before = core.snapshot(); + dialog->findChild()->setText(QString::fromUtf8(" Новый ник ")); + QSignalSpy accepted(dialog.get(), &QDialog::accepted); + QVERIFY(save(dialog.get())); QVERIFY(save(dialog.get())); QCOMPARE(accepted.count(), 0); + QCOMPARE(core.snapshot(), before); // Ядро приостановлено: GUI ничего не записал. + core.resume(); + if (failure == "none") QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000); + else QTRY_VERIFY_WITH_TIMEOUT(!errorLabel(dialog.get())->text().isEmpty(), 3000); + core.pause(); QVERIFY(!probe.gui); + if (failure == "none") QVERIFY(core.snapshot().startsWith(QString::fromUtf8("Новый ник\n").toUtf8())); + else { + QCOMPARE(accepted.count(), 0); QCOMPARE(core.snapshot(), before); + QVERIFY(dialog->findChild()->text().contains(QString::fromUtf8("Новый ник"))); + QVERIFY(sqlite3_get_autocommit(core.db.m_db)); + } + QVERIFY(sqlite3_next_stmt(core.db.m_db, nullptr) == nullptr); + QCOMPARE(core.run([&] { + sqlite3_set_authorizer(core.db.m_db, nullptr, nullptr); sqlite3_commit_hook(core.db.m_db, nullptr, nullptr); + return sqlite3_exec(core.db.m_db, "DROP TRIGGER IF EXISTS refuse_nick", nullptr, nullptr, nullptr); + }), SQLITE_OK); + if (failure != "none" && failure != "missing") { + QVERIFY(save(dialog.get())); core.resume(); QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000); core.pause(); + QVERIFY(core.snapshot().startsWith(QString::fromUtf8("Новый ник\n").toUtf8())); + } + } + void lifetime_data() { + QTest::addColumn("properties"); QTest::addColumn("mode"); + for (bool properties : {false, true}) for (const char* mode : {"close", "destroy", "stop", "disconnected", "other-db"}) + QTest::newRow(qPrintable(QString("%1-%2").arg(properties ? "properties" : "rename", mode))) << properties << QString(mode); + } + void lifetime() { + QFETCH(bool, properties); QFETCH(QString, mode); + MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties); + dialog->findChild()->setText("Later nickname"); QSignalSpy accepted(dialog.get(), &QDialog::accepted); + sqlite3* other = nullptr; + if (mode == "disconnected") core.db.setDb(nullptr); + if (mode == "other-db") { QCOMPARE(sqlite3_open(":memory:", &other), SQLITE_OK); core.db.setDb(other); } + QVERIFY(save(dialog.get())); QCOMPARE(accepted.count(), 0); + if (mode == "destroy") dialog.reset(); + else if (mode == "close") dialog->reject(); + if (mode == "stop") core.stop(); + else if (mode != "disconnected") { core.run([] { return 0; }); } + QCoreApplication::processEvents(); + if (mode == "close" || mode == "destroy") { + QVERIFY(core.snapshot().startsWith("Later nickname\n")); QCOMPARE(accepted.count(), 0); + if (dialog) QCOMPARE(dialog->result(), int(QDialog::Rejected)); + } else { QCOMPARE(accepted.count(), 0); QVERIFY(!errorLabel(dialog.get())->text().isEmpty()); } + if (other) { core.db.setDb(core.instance->topo_sqlite_db); QCOMPARE(sqlite3_close(other), SQLITE_OK); } + } +#ifdef UTUN_TEST_MEMBER_ALLOC_FAILURES + void allocations_data() { + QTest::addColumn("properties"); QTest::addColumn("queue"); + for (bool properties : {false, true}) for (bool queue : {false, true}) + QTest::newRow(qPrintable(QString("%1-%2").arg(properties).arg(queue))) << properties << queue; + } + void allocations() { + QFETCH(bool, properties); QFETCH(bool, queue); + MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties); + auto before = core.snapshot(); QSignalSpy accepted(dialog.get(), &QDialog::accepted); + failMalloc = !queue; failCalloc = queue; QVERIFY(save(dialog.get())); + QCOMPARE(failMalloc, 0); QCOMPARE(failCalloc, 0); QCOMPARE(accepted.count(), 0); QCOMPARE(core.snapshot(), before); + QVERIFY(!errorLabel(dialog.get())->text().isEmpty()); + QVERIFY(save(dialog.get())); core.resume(); QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000); core.pause(); + } +#endif +}; +QTEST_MAIN(MemberDialogTest) +#include "test_member_dialogs.moc"