Exercise member dialogs against a real worker and SQLite failures

This commit is contained in:
evgeny committed 2026-10-28 00:58:59 +01:00
1 parent 5842855583
commit f537f302c6
2 files changed
+244

No files matched your search

+10
View File
@@ -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)
+234
View File
@@ -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 <QtTest>
#include <QLabel>
#include <QTemporaryDir>
#include <future>
#include <thread>
#include <condition_variable>
#include <memory>
#include <cstring>
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<std::mutex> lock(mutex); stopping = true; wake.notify_all(); }
if (worker.joinable()) worker.join();
db.setDb(nullptr);
}
~MemberCore() { stop(); }
bool start() {
std::promise<bool> 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<qint64>(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<std::mutex> 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<std::mutex> lock(mutex); paused = false; wake.notify_all(); }
void pause() { std::unique_lock<std::mutex> lock(mutex); paused = true; wake.wait(lock, [this] { return !active; }); }
int run(std::function<int()> function) {
auto task = std::make_unique<std::packaged_task<int()>>(std::move(function)); auto result = task->get_future();
gui_bridge_post_uasync_fn([](void* arg) {
std::unique_ptr<std::packaged_task<int()>> task(static_cast<std::packaged_task<int()>*>(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<qint64>(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<QDialog> makeDialog(MemberCore& core, bool properties) {
if (properties) return std::make_unique<MemberPropsDialog>(&core.db, core.channel, core.node, "Member", core.flags);
return std::make_unique<RenameDialog>(&core.db, core.channel, core.node, "Member");
}
static QLabel* errorLabel(QDialog* dialog) { return dialog->findChild<QLabel*>("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<bool>("properties"); QTest::addColumn<QString>("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<Probe*>(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<QLineEdit*>()->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<QLineEdit*>()->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<bool>("properties"); QTest::addColumn<QString>("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<QLineEdit*>()->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<bool>("properties"); QTest::addColumn<bool>("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"