diff --git a/tests/test_dm_history.c b/tests/test_dm_history.c index 6d7b7f8f..b2bc2753 100644 --- a/tests/test_dm_history.c +++ b/tests/test_dm_history.c @@ -16,12 +16,28 @@ #define REQUIRE(value) do { if (!(value)) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "DM history test failed line=%d", __LINE__); return 1; } } while (0) static int messages, delete_results, delete_success, preparation_failures; +static int snapshots, snapshot_valid; +static uint64_t snapshot_generation; static struct SC_MYKEYS identities[2]; static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int len) { (void)inst; if (type == CHAT_EVT_DM_MSG_RECEIVED) messages++; if (type == CHAT_EVT_ATTACHMENT_PREPARATION && len >= 18 && data[len - 1] == 2) preparation_failures++; if (type == CHAT_EVT_DM_DELETE_RESULT && len == 25) { delete_results++; delete_success = data[24]; } + if (type == CHAT_EVT_DM_MESSAGES) { + snapshots++; + snapshot_valid = len > 9 + data[0] && data[0] && data[9 + data[0]] == '['; + if (snapshot_valid) memcpy(&snapshot_generation, data + 1 + data[0], 8); + } +} + +/* Проверяем настоящий trampoline, включая захват поколения и отбрасывание запроса до чтения. */ +static int snapshot(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation) { + struct dm_messages_req* req = u_calloc(1, sizeof(*req)); + if (!req) return -1; + req->inst = inst; req->generation = generation; + snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv); dm_messages_trampoline(req); + return 0; } static int scalar(struct UTUN_INSTANCE* inst, const char* sql) { @@ -82,6 +98,7 @@ int main(void) { REQUIRE(!body(a, 1, first, &first_len) && !body(a, 2, second, &second_len)); REQUIRE(!dm_accept_message(b, second, second_len, receipt)); /* seq=1 ещё не получен. */ REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"reply", 5)); + REQUIRE(!snapshot(b, conv, 1) && snapshots == 1 && snapshot_valid && snapshot_generation == 1); int before = messages; REQUIRE(!sqlite3_exec(b->topo_sqlite_db, "CREATE TEMP TRIGGER fail_delete BEFORE DELETE ON dm_messages" " BEGIN SELECT RAISE(ABORT,'injected history delete failure'); END", NULL, NULL, NULL)); @@ -96,6 +113,7 @@ int main(void) { REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 1); char json[8192]; size_t length; REQUIRE(!dm_list_conversations_json(b, json, sizeof(json), &length) && !strcmp(json, "[]")); + REQUIRE(!snapshot(b, conv, 0) && snapshots == 1); /* Скрытая история не попадает в GUI. */ REQUIRE(!dm_accept_message(b, second, second_len, receipt) && messages == before); REQUIRE(!dm_verify_receipt(a, receipt, second, second_len)); REQUIRE(scalar(b, "SELECT count(*) FROM dm_messages") == 0 && scalar(b, "SELECT hidden FROM dm_history") == 1); @@ -104,6 +122,9 @@ int main(void) { REQUIRE(dm_accept_message(b, conflict, second_len, receipt) < 0); REQUIRE(!dm_accept_message(b, first, first_len, receipt) && messages == before + 1); REQUIRE(scalar(b, "SELECT hidden FROM dm_history") == 0); /* Впервые доставленное seq=1 остаётся новым. */ + REQUIRE(!snapshot(b, conv, 1) && snapshots == 1); /* Старый запрос не читает повторно открытый PM. */ + REQUIRE(!snapshot(b, conv, 2) && snapshots == 2 && snapshot_valid && snapshot_generation == 2); + puts("PASS: history snapshots carry the exact generation; hidden and obsolete requests publish nothing"); REQUIRE(!dm_send(b, conv, "text", (const uint8_t*)"after", 5)); REQUIRE(scalar(b, "SELECT last_out_seq FROM dm_conversations") == 2); puts("PASS: atomic delete/rollback, exact replay receipts/conflicts, out-of-order delivery and preserved outgoing seq"); diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 67f11514..8a25dd67 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -497,6 +497,18 @@ target_link_libraries(test_message_events PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun add_test(NAME test_message_events COMMAND test_message_events) set_tests_properties(test_message_events PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) +# Настоящее окно и тот же набор исходников/зависимостей приложения, без второго main. +get_target_property(DM_DELETION_SOURCES vibechat SOURCES) +list(REMOVE_ITEM DM_DELETION_SOURCES src/main.cpp) +add_executable(test_dm_deletion tests/test_dm_deletion.cpp ${DM_DELETION_SOURCES}) +add_dependencies(test_dm_deletion chatgui_translations) +target_include_directories(test_dm_deletion PRIVATE $) +target_compile_definitions(test_dm_deletion PRIVATE $) +get_target_property(DM_DELETION_LIBRARIES vibechat LINK_LIBRARIES) +target_link_libraries(test_dm_deletion PRIVATE ${DM_DELETION_LIBRARIES} ${QT_TEST_LIB}) +add_test(NAME test_dm_deletion COMMAND test_dm_deletion) +set_tests_properties(test_dm_deletion PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + # Ручной запуск с DISPLAY: проверяет применение desktop hints оконным менеджером, не меняя настройки рабочих столов. if(TARGET X11::X11) add_executable(test_message_notification_x11 tests/test_message_notification_x11.cpp) diff --git a/tools/chatgui/tests/test_dm_deletion.cpp b/tools/chatgui/tests/test_dm_deletion.cpp new file mode 100644 index 00000000..7a3fab14 --- /dev/null +++ b/tools/chatgui/tests/test_dm_deletion.cpp @@ -0,0 +1,186 @@ +/* Настоящие MainWindow/uasync/SQLite: подтверждение, rollback, скрытие PM и старые поколения GUI. */ +#include "../src/mainwindow.h" +#include "../src/channellist.h" +#include "../src/channeldelegate.h" +#include "../src/messagelist.h" +#include "../src/messagedelegate.h" +#include "../src/chatview.h" +#include "../src/inputbar.h" +#include "../db/db_manager.h" +#include "../transport/utun_node.h" +#include "../transport/gui_bridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "utun_instance.h" +#include "dm/dm_core.h" +#include "dm/dm_history.h" +#include "debug_config.h" +} + +static MessageList* historyView; +static QList snapshots; + +/* Все изменения ядра/БД идут в его потоке; GUI не нужен для завершения этой задачи. */ +static int inCore(std::function operation) { + auto* task = new std::packaged_task([operation] { return operation(gui_bridge_get_inst()); }); + auto result = task->get_future(); + gui_bridge_post_uasync_fn([](void* arg) { + std::unique_ptr> work(static_cast*>(arg)); + (*work)(); + }, task); + if (result.wait_for(std::chrono::seconds(5)) != std::future_status::ready) qFatal("DM test: core task timed out"); + return result.get(); +} + +static void answerConfirmation(bool remove) { + QTimer::singleShot(0, [remove] { + auto* box = qobject_cast(QApplication::activeModalWidget()); + if (!box) qFatal("DM test: confirmation missing"); + for (auto* button : box->buttons()) if (button->text() == (remove ? "Delete" : "Cancel")) { button->click(); return; } + qFatal("DM test: confirmation button missing"); + }); +} + +static void dismissError() { + auto* box = qobject_cast(QApplication::activeModalWidget()); + QVERIFY(box); + QCOMPARE(box->text(), QString("Could not delete the conversation. Please try again.")); + box->accept(); +} + +class DmDeletionTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { debug_config_init(); gui_bridge_init(); } + + void deleteThroughWindowAndCore() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const QString configPath = directory.filePath("node.cfg"); + QFile config(configPath); QVERIFY(config.open(QIODevice::WriteOnly)); + const QByteArray text = "[global]\ntun_enabled=no\n[server:local]\naddr=127.0.0.1:0\ntype=private\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n" + "[ntp]\nenabled=no\n"; + QCOMPARE(config.write(text), text.size()); config.close(); + DbManager db; + MainWindow window(nullptr, &db, configPath, directory.filePath("db"), directory.filePath("dm.log"), + "warn", "dm=debug,general=info"); + window.show(); + QVERIFY2(window.utunNode()->isRunning(), qPrintable(window.utunNode()->startupError())); + QTRY_VERIFY_WITH_TIMEOUT(db.isOpen(), 3000); + auto* channels = window.findChild(); QVERIFY(channels); + auto* history = window.findChild(); QVERIFY(history); + auto* view = history->findChild(); QVERIFY(view); + auto* input = history->findChild(); QVERIFY(input); + auto* editor = input->findChild(); QVERIFY(editor); + historyView = history; + gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) { + snapshots.append(QByteArray(reinterpret_cast(data), len)); + historyView->onDmMessages(data, len); + }); + + SC_MYKEYS peer{}; uint8_t ed[32]; + QCOMPARE(sc_generate_keypair(&peer), SC_OK); QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), SC_OK); + const quint64 peerId = sc_derive_node_id_from_pubkey(peer.public_key); + const auto startPeer = [peer, ed, peerId](UTUN_INSTANCE* inst) { + return dm_start(inst, peerId, peer.public_key, ed, "Alice", nullptr); + }; + QCOMPARE(inCore(startPeer), 0); + QTRY_COMPARE_WITH_TIMEOUT(db.getDmConversations().size(), 1, 3000); + const QString conv = db.getDmConversations().first().convId; + QCOMPARE(db.getDmConversations().first().generation, quint64(1)); + const auto send = [conv](UTUN_INSTANCE* inst) { + return dm_send(inst, qPrintable(conv), "text", reinterpret_cast("one"), 3); + }; + QCOMPARE(inCore(send), 0); + QTRY_COMPARE_WITH_TIMEOUT(view->model()->rowCount(), 1, 3000); + QVERIFY(input->isEnabled()); QVERIFY(!snapshots.isEmpty()); + const QByteArray oldHistory = snapshots.last(); + + editor->setPlainText("keep draft on cancellation"); + answerConfirmation(false); window.onDmDeleteRequested(conv, 1, "Alice"); + QVERIFY(input->isEnabled()); QCOMPARE(editor->toPlainText(), QString("keep draft on cancellation")); + QCOMPARE(db.getDmConversations().size(), 1); + + QCOMPARE(inCore([](UTUN_INSTANCE* inst) { + return sqlite3_exec(inst->topo_sqlite_db, "CREATE TEMP TRIGGER refuse_delete BEFORE DELETE ON dm_messages" + " BEGIN SELECT RAISE(ABORT,'injected UI deletion failure'); END", nullptr, nullptr, nullptr); + }), SQLITE_OK); + answerConfirmation(true); window.onDmDeleteRequested(conv, 1, "Alice"); QVERIFY(!input->isEnabled()); + QTRY_VERIFY_WITH_TIMEOUT(QApplication::activeModalWidget() != nullptr, 3000); dismissError(); + QVERIFY(input->isEnabled()); QCOMPARE(db.getDmConversations().first().generation, quint64(1)); + QCOMPARE(editor->toPlainText(), QString("keep draft on cancellation")); + QCOMPARE(inCore([](UTUN_INSTANCE* inst) { + return sqlite3_exec(inst->topo_sqlite_db, "DROP TRIGGER refuse_delete", nullptr, nullptr, nullptr); + }), SQLITE_OK); + + // ПКМ запускает тот же диалог; после подтверждения отправки заблокированы до результата. + auto* list = channels->findChild(); QVERIFY(list); + QModelIndex dmIndex; + for (int row = 0; row < list->model()->rowCount(); ++row) { + auto index = list->model()->index(row, 0); + if (index.data(ChannelChannelIdRole).toString() == "dm:" + conv) dmIndex = index; + } + QVERIFY(dmIndex.isValid()); QCOMPARE(dmIndex.data(ChannelDmGenerationRole).toULongLong(), quint64(1)); + QSignalSpy requested(channels, &ChannelList::dmDeleteRequested); + QTimer::singleShot(0, [] { + auto* menu = qobject_cast(QApplication::activePopupWidget()); + if (!menu) qFatal("DM test: PM context menu missing"); + auto* action = menu->findChild("deleteDmAction"); + if (!action || !action->isEnabled()) qFatal("DM test: delete action unavailable"); + menu->setActiveAction(action); answerConfirmation(true); QTest::keyClick(menu, Qt::Key_Return); + }); + QMetaObject::invokeMethod(list, "customContextMenuRequested", Qt::DirectConnection, + Q_ARG(QPoint, list->visualRect(dmIndex).center())); + QCOMPARE(requested.size(), 1); QVERIFY(!input->isEnabled()); + QTRY_VERIFY_WITH_TIMEOUT(db.getDmConversations().isEmpty(), 3000); + QTRY_COMPARE_WITH_TIMEOUT(view->model()->rowCount(), 0, 3000); + QVERIFY(editor->toPlainText().isEmpty()); QVERIFY(!input->isEnabled()); + history->onDmMessages(reinterpret_cast(oldHistory.constData()), oldHistory.size()); + QCOMPARE(view->model()->rowCount(), 0); + + QCOMPARE(inCore(startPeer), 0); QCOMPARE(inCore(send), 0); + QTRY_COMPARE_WITH_TIMEOUT(db.getDmConversations().size(), 1, 3000); + QCOMPARE(db.getDmConversations().first().generation, quint64(2)); + QTRY_COMPARE_WITH_TIMEOUT(view->model()->rowCount(), 1, 3000); QVERIFY(input->isEnabled()); + editor->setPlainText("new generation draft"); + history->onDmMessages(reinterpret_cast(oldHistory.constData()), oldHistory.size()); + QCOMPARE(view->model()->rowCount(), 1); + QCOMPARE(view->model()->index(0, 0).data(MsgIdRole).toLongLong(), qint64(2)); + answerConfirmation(true); window.onDmDeleteRequested(conv, 1, "Alice"); + QTRY_VERIFY_WITH_TIMEOUT(QApplication::activeModalWidget() != nullptr, 3000); dismissError(); + QCOMPARE(db.getDmConversations().first().generation, quint64(2)); + QCOMPARE(editor->toPlainText(), QString("new generation draft")); QVERIFY(input->isEnabled()); + + // Удаление другой беседы сохраняет выбранный PM и его черновик. + SC_MYKEYS other{}; uint8_t otherEd[32]; + QCOMPARE(sc_generate_keypair(&other), SC_OK); QCOMPARE(sc_derive_ed25519_pubkey(other.private_key, otherEd), SC_OK); + const quint64 otherId = sc_derive_node_id_from_pubkey(other.public_key); + QCOMPARE(inCore([other, otherEd, otherId](UTUN_INSTANCE* inst) { + return dm_start(inst, otherId, other.public_key, otherEd, "Bob", nullptr); + }), 0); + QTRY_COMPARE_WITH_TIMEOUT(db.getDmConversations().size(), 2, 3000); + QCoreApplication::processEvents(); channels->selectChannel("dm:" + conv); + QTRY_VERIFY(input->isEnabled()); editor->setPlainText("selected PM stays"); + QString otherConv; + for (const auto& dm : db.getDmConversations()) if (dm.convId != conv) otherConv = dm.convId; + QVERIFY(!otherConv.isEmpty()); + answerConfirmation(true); window.onDmDeleteRequested(otherConv, 1, "Bob"); + QVERIFY(input->isEnabled()); + QTRY_COMPARE_WITH_TIMEOUT(db.getDmConversations().size(), 1, 3000); + QCoreApplication::processEvents(); QCOMPARE(editor->toPlainText(), QString("selected PM stays")); + QCOMPARE(list->currentIndex().data(ChannelChannelIdRole).toString(), "dm:" + conv); + QVERIFY(input->isEnabled()); + DEBUG_INFO(DEBUG_CATEGORY_DM, "DM desktop: cancel, SQL rollback, context menu, generations and unselected deletion PASS"); + } +}; + +QTEST_MAIN(DmDeletionTest) +#include "test_dm_deletion.moc"