Files
utun2/tools/chatgui/tests/test_member_detail.cpp
T

368 lines
20 KiB
C++

/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
#include "../src/accountlist.h"
#include "../src/memberlistmodel.h"
#include "../transport/gui_bridge.h"
#include "../db/db_manager.h"
#include <QtTest>
#include <QListView>
#include <QAbstractItemModelTester>
#include <QMenu>
#include <QScopeGuard>
#include <memory>
#include <functional>
#include <vector>
extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_member.h"
#include "member_files/protocol.h"
#include "debug_config.h"
#include "mem.h"
}
static UTUN_INSTANCE instance{};
static std::vector<chat_member_detail_req> requests;
static gui_member_detail_fn detailCallback;
static bool ready = true;
static bool rejectOwned = false;
static int ownedPosts = 0;
static std::vector<chat_member_list_req> memberRequests;
static std::vector<chat_connect_node_req> connectRequests;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return ready; }
extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; }
extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {}
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; }
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) {
if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast<chat_member_detail_req*>(arg));
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline)
memberRequests.push_back(*static_cast<chat_member_list_req*>(arg));
if (fn == chat_core_connect_node_trampoline) connectRequests.push_back(*static_cast<chat_connect_node_req*>(arg));
u_free(arg);
}
extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) {
++ownedPosts;
if (rejectOwned) return -1;
gui_bridge_post_uasync_fn(fn, arg);
return 0;
}
static QByteArray row(quint64 node, bool online = true, int up = 5, int rtt = 30) {
chat_member_display member{};
member.node_id = node; member.online = online; member.conn_presence = 5; member.conn_up = up;
member.rtt = rtt; member.nat_type = 0xff; member.flags = CHAT_MEMBER_FLAG_PHONE_KNOWN;
snprintf(member.name, sizeof(member.name), "peer%llu", (unsigned long long)node);
QByteArray bytes(CHAT_MEMBER_DISPLAY_SIZE, 0);
chat_core_serialize_member(&member, reinterpret_cast<uint8_t*>(bytes.data()));
return bytes;
}
static QByteArray reply(const chat_member_detail_req& req, const QList<quint64>& hops = {}, int result = 0) {
QByteArray bytes(CHAT_MEMBER_DETAIL_HEADER_SIZE + hops.size() * 8, 0);
auto* data = reinterpret_cast<uint8_t*>(bytes.data());
mf_put64(data, req.request_id); mf_put64(data + 8, req.group_id); mf_put64(data + 16, req.node_id);
data[24] = result; data[25] = result ? 0 : 7; data[28] = hops.size();
for (int i = 0; i < hops.size(); i++) mf_put64(data + CHAT_MEMBER_DETAIL_HEADER_SIZE + i * 8, hops[i]);
return bytes;
}
static void deliver(const QByteArray& bytes) {
if (detailCallback) detailCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
}
static void fill(AccountList& panel, const QString& channel, const QByteArray& bytes) {
panel.setChannel(channel);
panel.onMemberList(channel, bytes.size() / CHAT_MEMBER_DISPLAY_SIZE,
reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
}
class MemberDetailTest : public QObject {
Q_OBJECT
static QString body(AccountList& p) { return p.findChild<QTextEdit*>("memberDetailText")->toPlainText(); }
static QString header(AccountList& p) { return p.findChild<QLabel*>("memberDetailHeader")->text(); }
static bool select(AccountList& p, int rowNumber) {
return QMetaObject::invokeMethod(&p, "onMemberClicked", Qt::DirectConnection, Q_ARG(QModelIndex, p.model()->index(rowNumber)));
}
static bool context(AccountList& panel, const QString& actionText, const std::function<void()>& change = {}) {
const QPoint point = panel.findChild<QListView*>()->visualRect(panel.model()->index(0)).center();
bool clicked = false;
QTimer choose, deadline; choose.setSingleShot(true); deadline.setSingleShot(true);
QObject::connect(&deadline, &QTimer::timeout, &deadline, [] {
if (auto* menu = qobject_cast<QMenu*>(QApplication::activePopupWidget())) menu->close();
});
QObject::connect(&choose, &QTimer::timeout, &choose, [&] {
auto* menu = qobject_cast<QMenu*>(QApplication::activePopupWidget());
if (!menu) { QTest::qFail("member context menu was not shown", __FILE__, __LINE__); return; }
if (change) change();
if (!menu->isVisible()) return;
for (auto* action : menu->actions()) if (action->text() == actionText) {
if (!action->isEnabled()) { menu->close(); return; }
clicked = true;
QTest::mouseClick(menu, Qt::LeftButton, Qt::NoModifier, menu->actionGeometry(action).center());
return;
}
menu->close(); QTest::qFail("member context action was not found", __FILE__, __LINE__);
});
choose.start(0); deadline.start(1000);
const bool invoked = QMetaObject::invokeMethod(&panel, "onMemberContextMenu", Qt::DirectConnection, Q_ARG(QPoint, point));
return invoked && clicked;
}
private slots:
void init() {
requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
}
void flatModelPreservesRowsSnapshotsAndPersistentIndexes() {
MemberListModel model;
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
model.setChannel("123", false);
auto bytes = row(7) + row(8) + row(9);
model.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
QCOMPARE(model.rowCount(model.index(0)), 0);
const QPersistentModelIndex last = model.index(2);
const auto snapshot = model.member(9); QVERIFY(snapshot);
model.onMemberRemoved("123", 7);
QCOMPARE(last.row(), 1); QCOMPARE(model.nodeIdAt(last.row()), quint64(9));
const auto changed = row(9, false, 0, 0xffff);
model.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(changed.constData()));
QVERIFY(snapshot->online); QVERIFY(!model.member(9)->online);
const auto added = row(10);
model.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(added.constData()));
QCOMPARE(model.rowCount(), 3); QCOMPARE(model.nodeIdAt(2), quint64(10));
QVERIFY(!model.member(7)); QCOMPARE(model.nodeIdAt(-1), quint64(0));
}
void snapshotChangedSeesNewChannel() {
MemberListModel model; model.setChannel("123", false);
memberRequests.clear();
connect(&model, &MemberListModel::snapshotChanged, &model, [&] { model.refresh(); });
model.setChannel("456", false);
QCOMPARE(memberRequests.size(), size_t(2));
for (const auto& request : memberRequests) QCOMPARE(QString::fromUtf8(request.ch_id), QString("456"));
}
void losingOwnerPolicyHidesDeletedRowsWithoutCore() {
MemberListModel model; model.setChannel("123", true);
auto bytes = row(7) + row(8);
bytes[CHAT_MEMBER_DISPLAY_SIZE + 10] = CHAT_MEMBER_FLAG_DELETED;
model.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
QCOMPARE(model.rowCount(), 2);
ready = false; model.setChannel("123", false);
QCOMPARE(model.rowCount(), 1); QVERIFY(!model.member(8));
}
void malformedUpdatesKeepExistingSnapshot() {
MemberListModel model; model.setChannel("123", false);
const auto bytes = row(7);
model.onMemberList("123", 1, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
model.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
model.onMemberList("123", -1, nullptr, -1);
model.onMemberUpdated("123", nullptr);
QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7));
}
void rejectedMemberRequestsReleaseArguments() {
const auto before = u_get_allocated_count();
rejectOwned = true;
MemberListModel model; model.setChannel("123", false);
model.refresh(); model.refreshRtt();
QCOMPARE(ownedPosts, 3);
QCOMPARE(u_get_allocated_count(), before);
}
void invalidChannelDoesNotReplaceVisibleSnapshotOrSendRequests() {
MemberListModel model; model.setChannel("123", false);
const auto bytes = row(7);
model.onMemberList("123", 1, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
memberRequests.clear();
model.setChannel(QString(64, '1'), false);
model.setChannel(QString::fromUtf8("123\0suffix", 10), false);
QCOMPARE(model.rowCount(), 1); QCOMPARE(model.nodeIdAt(0), quint64(7));
QVERIFY(memberRequests.empty());
model.refresh(); QCOMPARE(memberRequests.size(), size_t(1));
QCOMPARE(QString::fromUtf8(memberRequests.back().ch_id), QString("123"));
}
void twoPanelsReceiveTheirRepliesAndSurviveOtherPanelDestruction() {
AccountList first; first.show(); fill(first, "123", row(7)); QVERIFY(select(first, 0));
const auto firstRequest = requests.back();
auto second = std::make_unique<AccountList>(); second->show(); fill(*second, "456", row(8)); QVERIFY(select(*second, 0));
const auto secondRequest = requests.back();
deliver(reply(firstRequest, {7, 111})); QVERIFY(body(first).contains("000000000000006f"));
deliver(reply(secondRequest, {8, 222})); QVERIFY(body(*second).contains("00000000000000de"));
second.reset();
QVERIFY(QMetaObject::invokeMethod(&first, "onDetailTimerTick"));
deliver(reply(requests.back(), {7, 333})); QVERIFY(body(first).contains("000000000000014d"));
}
void rejectedDetailRequestCanRetryImmediately() {
AccountList panel; panel.show(); fill(panel, "123", row(7));
rejectOwned = true; QVERIFY(select(panel, 0)); QVERIFY(requests.empty());
rejectOwned = false; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick"));
QCOMPARE(requests.size(), size_t(1));
deliver(reply(requests.back(), {7, 8})); QVERIFY(body(panel).contains("BGP:"));
}
void detailEscapesSocketNamesAndFormatsIpv6Ports() {
AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0));
auto bytes = reply(requests.back()); bytes.resize(CHAT_MEMBER_DETAIL_HEADER_SIZE + CHAT_MEMBER_DETAIL_LINK_SIZE);
auto* data = reinterpret_cast<uint8_t*>(bytes.data()); data[29] = 1;
auto* link = data + CHAT_MEMBER_DETAIL_HEADER_SIZE; memset(link, 0, CHAT_MEMBER_DETAIL_LINK_SIZE);
link[0] = 1; link[1] = 3; link[2] = 1; link[3] = 1;
link[5] = 6; link[21] = 1; link[22] = 0x35; link[23] = 5;
link[24] = 6; link[40] = 2; link[41] = 0xbb; link[42] = 1;
strcpy(reinterpret_cast<char*>(link + 43), "<sock&>");
deliver(bytes);
QVERIFY(body(panel).contains("[::1]:1333")); QVERIFY(body(panel).contains("[::2]:443"));
QVERIFY(body(panel).contains("<sock&>"));
}
void contextWithoutDatabaseUsesSnapshotAndOwnsConnectRequest() {
AccountList panel; panel.resize(300, 600); panel.show(); fill(panel, "123", row(7));
QSignalSpy calls(&panel, &AccountList::callRequested);
QVERIFY(context(panel, "Call peer")); QCOMPARE(calls.size(), 1);
QCOMPARE(calls.first()[0].toString(), QString("123")); QCOMPARE(calls.first()[1].toULongLong(), quint64(7));
QVERIFY(!context(panel, "Rename")); QVERIFY(!context(panel, "Edit"));
const auto before = u_get_allocated_count();
rejectOwned = true; QVERIFY(context(panel, "Connect")); QCOMPARE(u_get_allocated_count(), before);
QVERIFY(connectRequests.empty());
rejectOwned = false; QVERIFY(context(panel, "Connect")); QCOMPARE(connectRequests.size(), size_t(1));
QCOMPARE(QString::fromUtf8(connectRequests.back().ch_id), QString("123")); QCOMPARE(connectRequests.back().node_id, uint64_t(7));
}
void contextRevalidatesChannelMemberAndOnlineState() {
AccountList panel; panel.resize(300, 600); panel.show(); fill(panel, "123", row(7));
QSignalSpy calls(&panel, &AccountList::callRequested);
QVERIFY(context(panel, "Call peer", [&] {
const auto offline = row(7, false);
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(offline.constData()));
}));
QCOMPARE(calls.size(), 0);
fill(panel, "123", row(7));
QVERIFY(context(panel, "Call peer", [&] { fill(panel, "456", row(8)); }));
QCOMPARE(calls.size(), 0);
fill(panel, "123", row(7));
QVERIFY(context(panel, "Call peer", [&] { panel.onMemberRemoved("123", 7); }));
QCOMPARE(calls.size(), 0);
fill(panel, "123", row(7) + row(8));
QVERIFY(context(panel, "Call peer", [&] { fill(panel, "123", row(8) + row(7)); }));
QCOMPARE(calls.size(), 1); QCOMPARE(calls.first()[1].toULongLong(), quint64(7));
}
void destroyingPanelDuringContextMenuIsSafe() {
auto panel = std::make_unique<AccountList>(); panel->show(); fill(*panel, "123", row(7));
QPointer<AccountList> weak(panel.get());
QVERIFY(!context(*panel, "Call peer", [&] { panel.reset(); }));
QVERIFY(weak.isNull());
}
void destroyingBorrowedDatabaseDisablesEditing() {
auto database = std::make_unique<DbManager>();
AccountList panel(database.get()); panel.show(); fill(panel, "123", row(7));
database.reset();
QVERIFY(QMetaObject::invokeMethod(&panel, "onMemberDoubleClicked", Qt::DirectConnection,
Q_ARG(QModelIndex, panel.model()->index(0))));
QVERIFY(!context(panel, "Edit"));
}
void unavailableCoreDiscardsPendingTokenBeforeRetry() {
AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0));
const auto old = requests.back();
ready = false; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick"));
ready = true; QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick"));
QCOMPARE(requests.size(), size_t(2));
deliver(reply(old, {111})); QVERIFY(!body(panel).contains("BGP:"));
deliver(reply(requests.back(), {7, 222})); QVERIFY(body(panel).contains("00000000000000de"));
}
void malformedStoredAddressDoesNotReadOutsideBlob() {
sqlite3* database = nullptr; QCOMPARE(sqlite3_open(":memory:", &database), SQLITE_OK);
const auto close = qScopeGuard([&] { sqlite3_close(database); });
DbManager manager;
AccountList panel(&manager); panel.show(); fill(panel, "123", row(7));
const char* schema = "CREATE TABLE nodes(node_id,last_seen_at,created_at);"
"INSERT INTO nodes VALUES(7,0,0);"
"CREATE TABLE node_addresses(node_id,family,protocol,address,port,socket_id,addr_type,nat_mapping,nat_inbound,nat_source);"
"INSERT INTO node_addresses VALUES(7,6,2,x'01',555,1,1,0,0,0);"
"INSERT INTO node_addresses VALUES(7,4,2,x'7f000001',444,2,1,0,0,0);";
QCOMPARE(sqlite3_exec(database, schema, nullptr, nullptr, nullptr), SQLITE_OK);
manager.setDb(database);
QVERIFY(select(panel, 0));
QVERIFY(body(panel).contains("127.0.0.1:444"));
QVERIFY(!body(panel).contains(":555"));
}
void modelOwnsPresenceAndRttAndRefreshPreservesSelection() {
AccountList panel; panel.resize(600, 800); panel.show();
fill(panel, "123", row(7, false, 0, 0xffff) + row(8));
QVERIFY(select(panel, 0)); QCOMPARE(requests.size(), size_t(1));
deliver(reply(requests.back())); // Физический транспорт UP не делает участника онлайн.
QVERIFY(header(panel).contains("#9E9E9E")); QVERIFY(body(panel).contains("offline"));
const auto live = row(7, true, 4, 93);
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(live.constData()));
QVERIFY(header(panel).contains("#4CAF50")); QVERIFY(body(panel).contains("rtt=9.3ms"));
deliver(reply(requests.back(), {7, 9})); QVERIFY(body(panel).contains("0000000000000009"));
const auto other = row(7, false, 0, 0xffff);
panel.onMemberUpdated("456", reinterpret_cast<const uint8_t*>(other.constData()));
QVERIFY(header(panel).contains("#4CAF50"));
panel.refresh(); QCOMPARE(panel.model()->rowCount(), 2); QVERIFY(body(panel).contains("rtt=9.3ms"));
const auto reordered = row(8) + live;
panel.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(reordered.constData()), reordered.size());
QVERIFY(header(panel).contains("peer7"));
QCOMPARE(panel.findChild<QListView*>()->currentIndex().row(), 1);
}
void aToBToADiscardsOldResponsesWithoutClearingNewRequest() {
AccountList panel; panel.show(); fill(panel, "123", row(7) + row(8));
QVERIFY(select(panel, 0)); const auto first = requests.back();
QVERIFY(select(panel, 1)); const auto second = requests.back();
QVERIFY(select(panel, 0)); const auto third = requests.back();
QVERIFY(first.request_id < second.request_id && second.request_id < third.request_id);
deliver(reply(first, {111})); deliver(reply(second, {222}));
QVERIFY(!body(panel).contains("BGP:"));
auto wrongGroup = third; wrongGroup.group_id = 456;
deliver(reply(wrongGroup, {333})); QVERIFY(!body(panel).contains("BGP:"));
deliver(reply(third, {7, 444})); QVERIFY(body(panel).contains("00000000000001bc"));
const auto pending = requests.size();
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); QCOMPARE(requests.size(), pending + 1);
const auto oldChannel = requests.back();
fill(panel, "456", row(7)); QVERIFY(select(panel, 0)); const auto current = requests.back();
deliver(reply(oldChannel, {555})); QVERIFY(!body(panel).contains("BGP:"));
deliver(reply(current, {7, 666})); QVERIFY(body(panel).contains("000000000000029a"));
}
void newMemberEventInvalidatesPendingPathEvenWhenFieldsAreEqual() {
AccountList panel; panel.show(); const auto initial = row(7);
fill(panel, "123", initial); QVERIFY(select(panel, 0)); const auto first = requests.back();
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(initial.constData()));
QCOMPARE(requests.size(), size_t(1));
deliver(reply(first, {111})); QCOMPARE(requests.size(), size_t(2)); QVERIFY(!body(panel).contains("BGP:"));
const auto correct = reply(requests.back(), {7, 8});
deliver(correct.left(correct.size() - 1)); QVERIFY(!body(panel).contains("BGP:"));
deliver(correct); QVERIFY(body(panel).contains("0000000000000008"));
panel.onMemberRemoved("123", 7); QVERIFY(!header(panel).contains("peer7"));
deliver(correct); QVERIFY(!body(panel).contains("BGP:"));
}
void errorTimeoutAndHiddenPanelReleaseOnlyTheirOwnRequests() {
AccountList panel; panel.show(); fill(panel, "123", row(7)); QVERIFY(select(panel, 0));
deliver(reply(requests.back(), {}, CHAT_MEMBER_DETAIL_NOT_MEMBER));
QVERIFY(header(panel).contains("#4CAF50")); // Диагностическая ошибка не меняет live-снимок.
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto expired = requests.back();
QTRY_VERIFY_WITH_TIMEOUT(requests.back().request_id != expired.request_id, 6500);
const auto current = requests.back(); deliver(reply(expired, {111}));
deliver(reply(current, {7, 8})); QVERIFY(body(panel).contains("BGP:"));
QVERIFY(QMetaObject::invokeMethod(&panel, "onDetailTimerTick")); const auto hidden = requests.back();
panel.hide(); deliver(reply(hidden, {222})); panel.show();
QVERIFY(requests.back().request_id > hidden.request_id);
deliver(reply(hidden, {222})); QVERIFY(!body(panel).contains("00000000000000de"));
ready = false; panel.setChannel(QString()); deliver(reply(requests.back(), {333}));
QCOMPARE(panel.model()->rowCount(), 0); QVERIFY(!body(panel).contains("BGP:"));
}
};
QTEST_MAIN(MemberDetailTest)
#include "test_member_detail.moc"