mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 14:11:09 +00:00
feat(wallet): humanize shared wallet experience
Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QHostAddress>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QSettings>
|
||||
#include <QSignalSpy>
|
||||
#include <QTcpServer>
|
||||
#include <QTcpSocket>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTimer>
|
||||
#include <QtTest>
|
||||
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "FakeWalletProvider.h"
|
||||
#include "LogosWalletProvider.h"
|
||||
#include "WalletAccountModel.h"
|
||||
@@ -17,7 +24,33 @@
|
||||
namespace {
|
||||
const QString ACCOUNT_A(64, QLatin1Char('a'));
|
||||
const QString ACCOUNT_B(64, QLatin1Char('b'));
|
||||
const QString ACCOUNT_C(64, QLatin1Char('d'));
|
||||
const QString PROGRAM_ID(64, QLatin1Char('c'));
|
||||
const QString EOA_OWNER(64, QLatin1Char('0'));
|
||||
|
||||
class ScopedEnvironment final {
|
||||
public:
|
||||
ScopedEnvironment(QByteArray name, QByteArray value)
|
||||
: m_name(std::move(name)),
|
||||
m_hadValue(qEnvironmentVariableIsSet(m_name.constData())),
|
||||
m_previous(qgetenv(m_name.constData()))
|
||||
{
|
||||
qputenv(m_name.constData(), value);
|
||||
}
|
||||
|
||||
~ScopedEnvironment()
|
||||
{
|
||||
if (m_hadValue)
|
||||
qputenv(m_name.constData(), m_previous);
|
||||
else
|
||||
qunsetenv(m_name.constData());
|
||||
}
|
||||
|
||||
private:
|
||||
QByteArray m_name;
|
||||
bool m_hadValue;
|
||||
QByteArray m_previous;
|
||||
};
|
||||
|
||||
QString publicAccountJson(const QString& owner = PROGRAM_ID,
|
||||
const QString& balance = QStringLiteral("01000000000000000000000000000000"),
|
||||
@@ -39,6 +72,7 @@ QVariantMap accountEntry(const QString& id, bool isPublic)
|
||||
{ QStringLiteral("is_public"), isPublic },
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LogosWalletProviderTest : public QObject {
|
||||
@@ -47,18 +81,33 @@ class LogosWalletProviderTest : public QObject {
|
||||
private slots:
|
||||
void adoptsOpenWalletAndCachesSnapshots();
|
||||
void opensConfiguredWalletWhenNoSharedSessionExists();
|
||||
void opensStoredWalletAsynchronouslyWithoutAccountProbe();
|
||||
void opensAndReadsAsynchronously();
|
||||
void avoidsSavingAfterUnchangedAsynchronousSnapshots();
|
||||
void createsAndPersistsWallet();
|
||||
void validatesCompletePublicAccountPayloads();
|
||||
void fallsBackToBalanceWhenPublicReadFails();
|
||||
void createsAndPersistsAccounts();
|
||||
void preservesCreatedAccountWhenPublicReadFails();
|
||||
void preservesCreatedAccountWhenSnapshotRefreshFails();
|
||||
void createdAccountDoesNotRescanWallet();
|
||||
void dispatchesExactGenericTransaction();
|
||||
void rejectsInvalidSubmissionResponses();
|
||||
void exposesStableAccountModelRoles();
|
||||
void clearsStaleAccountPresentationsWithoutInvalidatingPrimary();
|
||||
void persistsHumanizedWalletPreferences();
|
||||
void fakeProviderImplementsConsumerContract();
|
||||
void controllerOwnsUiWalletFlow();
|
||||
void controllerSeparatesSnapshotsFromCosmeticState();
|
||||
void controllerOpenDoesNotWaitForWalletSync();
|
||||
void controllerCreationDoesNotWaitForWalletSync();
|
||||
void controllerSeedsDefaultWalletConfigWithConfiguredEndpoint();
|
||||
void controllerPreservesExistingDefaultWalletConfig();
|
||||
void controllerStopsReachabilityChecksAfterDisconnect();
|
||||
void completedAsyncSnapshotReleasesCallback();
|
||||
void deferredCallbacksIgnoreDestroyedController();
|
||||
void newerReachabilityResultWins();
|
||||
void coalescesReachabilityChecksForSameEndpoint();
|
||||
void controllerReportsPartialWalletCreation();
|
||||
};
|
||||
|
||||
void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
|
||||
@@ -84,11 +133,18 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
|
||||
QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1"));
|
||||
QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42"));
|
||||
QCOMPARE(session.snapshot.publicAccountReads.size(), 1);
|
||||
QCOMPARE(session.snapshot.accounts.at(0).readStatus, QStringLiteral("ok"));
|
||||
QCOMPARE(session.snapshot.accounts.at(0).programOwner, PROGRAM_ID);
|
||||
QCOMPARE(session.snapshot.accounts.at(0).dataHex, QStringLiteral("00ff"));
|
||||
QCOMPARE(session.snapshot.accounts.at(0).displayAddress,
|
||||
QStringLiteral("base58-") + ACCOUNT_A);
|
||||
QCOMPARE(session.snapshot.accounts.at(1).readStatus, QStringLiteral("private"));
|
||||
QCOMPARE(session.snapshot.currentBlockHeight, quint64(12));
|
||||
QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11));
|
||||
|
||||
const int listCalls = modules.logos_execution_zone.listCalls;
|
||||
const int readCalls = modules.logos_execution_zone.publicReadCalls;
|
||||
const int saveCalls = modules.logos_execution_zone.saveCalls;
|
||||
QVERIFY(provider.snapshot().ok());
|
||||
QCOMPARE(modules.logos_execution_zone.listCalls, listCalls);
|
||||
QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls);
|
||||
@@ -96,6 +152,7 @@ void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots()
|
||||
QVERIFY(provider.snapshot(true).ok());
|
||||
QVERIFY(modules.logos_execution_zone.listCalls > listCalls);
|
||||
QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls);
|
||||
QCOMPARE(modules.logos_execution_zone.saveCalls, saveCalls);
|
||||
|
||||
modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(
|
||||
PROGRAM_ID, QString(32, QLatin1Char('f')));
|
||||
@@ -131,6 +188,7 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
|
||||
QVERIFY(!session.adopted);
|
||||
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
|
||||
QCOMPARE(modules.logos_execution_zone.openedStorage, storage);
|
||||
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
|
||||
|
||||
LogosModules missingModules;
|
||||
LogosWalletProvider missingProvider(&missingModules);
|
||||
@@ -138,12 +196,83 @@ void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists()
|
||||
WalletFailure::WalletMissing);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::opensStoredWalletAsynchronouslyWithoutAccountProbe()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
const QString storage = directory.filePath(QStringLiteral("storage.json"));
|
||||
QFile file(storage);
|
||||
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||
file.close();
|
||||
|
||||
LogosModules modules;
|
||||
LogosWalletProvider provider(&modules);
|
||||
bool connected = false;
|
||||
provider.connectAsync({ directory.filePath(QStringLiteral("wallet.json")), storage },
|
||||
[&connected](WalletSession session) {
|
||||
connected = session.ok() && !session.adopted;
|
||||
});
|
||||
|
||||
QVERIFY(connected);
|
||||
QCOMPARE(modules.logos_execution_zone.openCalls, 1);
|
||||
QCOMPARE(modules.logos_execution_zone.listCalls, 1);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::opensAndReadsAsynchronously()
|
||||
{
|
||||
LogosModules modules;
|
||||
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
|
||||
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
|
||||
modules.logos_execution_zone.publicAccounts.insert(
|
||||
ACCOUNT_A, publicAccountJson(EOA_OWNER));
|
||||
LogosWalletProvider provider(&modules);
|
||||
|
||||
bool connected = false;
|
||||
provider.connectAsync({}, [&connected](WalletSession session) {
|
||||
connected = session.ok() && session.snapshot.accounts.size() == 1;
|
||||
});
|
||||
QVERIFY(connected);
|
||||
|
||||
bool refreshed = false;
|
||||
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
|
||||
refreshed = snapshot.ok() && snapshot.accounts.at(0).programOwner == EOA_OWNER;
|
||||
});
|
||||
QVERIFY(refreshed);
|
||||
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::avoidsSavingAfterUnchangedAsynchronousSnapshots()
|
||||
{
|
||||
LogosModules modules;
|
||||
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
|
||||
modules.logos_execution_zone.currentBlockHeight = 12;
|
||||
modules.logos_execution_zone.lastSyncedBlock = 12;
|
||||
LogosWalletProvider provider(&modules);
|
||||
|
||||
bool connected = false;
|
||||
provider.connectAsync({}, [&connected](WalletSession session) {
|
||||
connected = session.ok();
|
||||
});
|
||||
QVERIFY(connected);
|
||||
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
|
||||
|
||||
bool refreshed = false;
|
||||
provider.snapshotAsync(true, [&refreshed](WalletSnapshot snapshot) {
|
||||
refreshed = snapshot.ok();
|
||||
});
|
||||
QVERIFY(refreshed);
|
||||
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::createsAndPersistsWallet()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
|
||||
LogosModules modules;
|
||||
modules.logos_execution_zone.currentBlockHeight = 12;
|
||||
modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) };
|
||||
modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson());
|
||||
LogosWalletProvider provider(&modules);
|
||||
const WalletPaths paths {
|
||||
directory.filePath(QStringLiteral("config/wallet.json")),
|
||||
@@ -157,6 +286,9 @@ void LogosWalletProviderTest::createsAndPersistsWallet()
|
||||
QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage);
|
||||
QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret"));
|
||||
QVERIFY(modules.logos_execution_zone.saveCalls >= 1);
|
||||
QCOMPARE(modules.logos_execution_zone.syncCalls, 0);
|
||||
QCOMPARE(modules.logos_execution_zone.listCalls, 0);
|
||||
QCOMPARE(modules.logos_execution_zone.publicReadCalls, 0);
|
||||
|
||||
LogosModules rejectedModules;
|
||||
rejectedModules.logos_execution_zone.mnemonic.clear();
|
||||
@@ -227,12 +359,14 @@ void LogosWalletProviderTest::createsAndPersistsAccounts()
|
||||
QVERIFY(provider.connect({}).ok());
|
||||
|
||||
const int savesBeforeCreate = modules.logos_execution_zone.saveCalls;
|
||||
const int publicReadsBeforeCreate = modules.logos_execution_zone.publicReadCalls;
|
||||
const WalletAccountCreation creation = provider.createAccount(true);
|
||||
QVERIFY(creation.ok());
|
||||
QCOMPARE(creation.accountId, ACCOUNT_A);
|
||||
QVERIFY(creation.publicAccount.ok());
|
||||
QCOMPARE(creation.snapshot.accounts.size(), 1);
|
||||
QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate);
|
||||
QCOMPARE(modules.logos_execution_zone.publicReadCalls, publicReadsBeforeCreate + 1);
|
||||
|
||||
modules.logos_execution_zone.saveResult = 1;
|
||||
QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed);
|
||||
@@ -257,7 +391,7 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails()
|
||||
QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7"));
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
|
||||
void LogosWalletProviderTest::createdAccountDoesNotRescanWallet()
|
||||
{
|
||||
LogosModules modules;
|
||||
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
|
||||
@@ -268,11 +402,13 @@ void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails()
|
||||
|
||||
modules.logos_execution_zone.currentBlockHeight = 1;
|
||||
modules.logos_execution_zone.syncResult = 1;
|
||||
const int syncCalls = modules.logos_execution_zone.syncCalls;
|
||||
const WalletAccountCreation creation = provider.createAccount(true);
|
||||
|
||||
QVERIFY(creation.ok());
|
||||
QCOMPARE(creation.accountId, ACCOUNT_A);
|
||||
QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed);
|
||||
QVERIFY(creation.snapshot.ok());
|
||||
QCOMPARE(modules.logos_execution_zone.syncCalls, syncCalls);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::dispatchesExactGenericTransaction()
|
||||
@@ -298,8 +434,8 @@ void LogosWalletProviderTest::dispatchesExactGenericTransaction()
|
||||
QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds);
|
||||
QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements,
|
||||
QVariantList({ true, false }));
|
||||
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(),
|
||||
QVariantList({ 7U, 0U, 4294967295U }));
|
||||
QCOMPARE(modules.logos_execution_zone.submittedInstruction.toByteArray(),
|
||||
QByteArray::fromHex("0700000000000000ffffffff"));
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::rejectsInvalidSubmissionResponses()
|
||||
@@ -338,19 +474,171 @@ void LogosWalletProviderTest::exposesStableAccountModelRoles()
|
||||
WalletAccountModel model;
|
||||
QSignalSpy countChanged(&model, &WalletAccountModel::countChanged);
|
||||
model.replaceAccounts({
|
||||
{ ACCOUNT_A, QStringLiteral("10"), true },
|
||||
{ ACCOUNT_B, QStringLiteral("20"), false },
|
||||
});
|
||||
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
|
||||
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
|
||||
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, QStringLiteral("00") },
|
||||
}, { { ACCOUNT_A, QStringLiteral("Trading") } }, ACCOUNT_A);
|
||||
|
||||
QCOMPARE(model.count(), 2);
|
||||
QCOMPARE(model.count(), 3);
|
||||
QCOMPARE(countChanged.count(), 1);
|
||||
QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name"));
|
||||
QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("Account 1"));
|
||||
QStringLiteral("Trading"));
|
||||
QCOMPARE(model.data(model.index(0), WalletAccountModel::KindRole).toString(),
|
||||
QStringLiteral("user"));
|
||||
QVERIFY(model.data(model.index(0), WalletAccountModel::CanBePrimaryRole).toBool());
|
||||
QVERIFY(model.data(model.index(0), WalletAccountModel::IsPrimaryRole).toBool());
|
||||
QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B);
|
||||
QCOMPARE(model.roleNames().value(WalletAccountModel::DisplayAddressRole),
|
||||
QByteArray("displayAddress"));
|
||||
QCOMPARE(model.roleNames().value(WalletAccountModel::DecodedDataRole),
|
||||
QByteArray("decodedData"));
|
||||
QCOMPARE(model.data(model.index(1), WalletAccountModel::DisplayAddressRole).toString(),
|
||||
ACCOUNT_B);
|
||||
QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(),
|
||||
QStringLiteral("20"));
|
||||
QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool());
|
||||
QCOMPARE(model.data(model.index(2), WalletAccountModel::KindRole).toString(),
|
||||
QStringLiteral("program"));
|
||||
QVERIFY(!model.data(model.index(2), WalletAccountModel::CanBePrimaryRole).toBool());
|
||||
|
||||
QSignalSpy presentationsChanged(&model, &QAbstractItemModel::dataChanged);
|
||||
const QVector<WalletAccountPresentation> presentations {
|
||||
{
|
||||
ACCOUNT_A,
|
||||
QStringLiteral("program"),
|
||||
{},
|
||||
QStringLiteral("System"),
|
||||
QStringLiteral("UserAccount"),
|
||||
{},
|
||||
false,
|
||||
QStringLiteral("{\"public_key\":\"Public/test\"}"),
|
||||
},
|
||||
{
|
||||
ACCOUNT_C,
|
||||
QStringLiteral("token_holding"),
|
||||
QStringLiteral("TEST holding"),
|
||||
QStringLiteral("Token"),
|
||||
QStringLiteral("TokenHolding"),
|
||||
ACCOUNT_A,
|
||||
true,
|
||||
},
|
||||
};
|
||||
model.applyPresentations(presentations);
|
||||
QCOMPARE(presentationsChanged.count(), 1);
|
||||
QCOMPARE(model.data(model.index(2), WalletAccountModel::SectionRole).toString(),
|
||||
QStringLiteral("hidden"));
|
||||
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("TEST holding"));
|
||||
QCOMPARE(model.data(model.index(0), WalletAccountModel::DecodedDataRole).toString(),
|
||||
QStringLiteral("{\"public_key\":\"Public/test\"}"));
|
||||
model.setAlias(ACCOUNT_C, QStringLiteral("Reserve"));
|
||||
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("Reserve"));
|
||||
model.setAlias(ACCOUNT_C, {});
|
||||
QCOMPARE(model.data(model.index(2), WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("TEST holding"));
|
||||
|
||||
QSignalSpy redundantPresentation(&model, &QAbstractItemModel::dataChanged);
|
||||
QVERIFY(!model.applyPresentations(presentations));
|
||||
QCOMPARE(redundantPresentation.count(), 0);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::clearsStaleAccountPresentationsWithoutInvalidatingPrimary()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletPresentationClearingTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.connectResult.adopted = true;
|
||||
provider.connectResult.snapshot.accounts = {
|
||||
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
|
||||
{ ACCOUNT_C, QStringLiteral("20"), true, QStringLiteral("ok"), PROGRAM_ID,
|
||||
QStringLiteral("00ff") },
|
||||
};
|
||||
WalletController controller(provider, settingsApplication);
|
||||
|
||||
QVERIFY(controller.open());
|
||||
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
|
||||
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
|
||||
|
||||
controller.applyAccountPresentations({
|
||||
{
|
||||
ACCOUNT_C,
|
||||
QStringLiteral("token_holding"),
|
||||
QStringLiteral("TEST holding"),
|
||||
QStringLiteral("Token"),
|
||||
QStringLiteral("TokenHolding"),
|
||||
ACCOUNT_A,
|
||||
true,
|
||||
QStringLiteral("{\"amount\":\"20\"}"),
|
||||
},
|
||||
});
|
||||
|
||||
WalletAccountModel* model = controller.accountModel();
|
||||
const QModelIndex holding = model->index(model->indexOf(ACCOUNT_C));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
|
||||
QStringLiteral("token_holding"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
|
||||
QStringLiteral("hidden"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("TEST holding"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(),
|
||||
QStringLiteral("{\"amount\":\"20\"}"));
|
||||
|
||||
controller.applyAccountPresentations({});
|
||||
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::KindRole).toString(),
|
||||
QStringLiteral("program"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::SectionRole).toString(),
|
||||
QStringLiteral("advanced"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::NameRole).toString(),
|
||||
QStringLiteral("Program account"));
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::ProgramNameRole).toString(), QString());
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::AccountTypeRole).toString(), QString());
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::DefinitionIdRole).toString(), QString());
|
||||
QCOMPARE(model->data(holding, WalletAccountModel::DecodedDataRole).toString(), QString());
|
||||
QVERIFY(!model->data(holding, WalletAccountModel::CanBePrimaryRole).toBool());
|
||||
|
||||
const QModelIndex primary = model->index(model->indexOf(ACCOUNT_A));
|
||||
QVERIFY(model->data(primary, WalletAccountModel::CanBePrimaryRole).toBool());
|
||||
QVERIFY(model->data(primary, WalletAccountModel::IsPrimaryRole).toBool());
|
||||
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
|
||||
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("User account"));
|
||||
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::persistsHumanizedWalletPreferences()
|
||||
{
|
||||
const QString application = QStringLiteral("HumanizedWalletPreferencesTest");
|
||||
QSettings settings(QStringLiteral("Logos"), application);
|
||||
settings.clear();
|
||||
FakeWalletProvider provider;
|
||||
provider.connectResult.adopted = true;
|
||||
provider.connectResult.snapshot.accounts = {
|
||||
{ ACCOUNT_A, QStringLiteral("10"), true, QStringLiteral("ok"), EOA_OWNER, {} },
|
||||
{ ACCOUNT_B, QStringLiteral("20"), false, QStringLiteral("private"), {}, {} },
|
||||
{ ACCOUNT_C, QStringLiteral("30"), true, QStringLiteral("ok"), PROGRAM_ID, {} },
|
||||
};
|
||||
|
||||
{
|
||||
WalletController controller(provider, application);
|
||||
QVERIFY(controller.open());
|
||||
QCOMPARE(controller.state().primaryAccountAddress, ACCOUNT_A);
|
||||
QVERIFY(!controller.setPrimaryAccount(ACCOUNT_C));
|
||||
QVERIFY(controller.setAccountAlias(ACCOUNT_B, QStringLiteral(" Private savings ")));
|
||||
QVERIFY(controller.setPrimaryAccount(ACCOUNT_B));
|
||||
QCOMPARE(controller.state().primaryAccountName, QStringLiteral("Private savings"));
|
||||
QVERIFY(!controller.setAccountAlias(ACCOUNT_A, QString(41, QLatin1Char('x'))));
|
||||
}
|
||||
|
||||
WalletController reopened(provider, application);
|
||||
QVERIFY(reopened.open());
|
||||
QCOMPARE(reopened.state().primaryAccountAddress, ACCOUNT_B);
|
||||
QCOMPARE(reopened.state().primaryAccountName, QStringLiteral("Private savings"));
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::fakeProviderImplementsConsumerContract()
|
||||
@@ -419,6 +707,164 @@ void LogosWalletProviderTest::controllerOwnsUiWalletFlow()
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerSeparatesSnapshotsFromCosmeticState()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletSnapshotSignalTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.connectResult.snapshot.accounts = {
|
||||
{ ACCOUNT_A, QStringLiteral("5"), true },
|
||||
};
|
||||
provider.snapshotResult = provider.connectResult.snapshot;
|
||||
WalletController controller(provider, settingsApplication);
|
||||
QSignalSpy stateChanged(&controller, &WalletController::stateChanged);
|
||||
QSignalSpy snapshotChanged(&controller, &WalletController::snapshotChanged);
|
||||
|
||||
QVERIFY(controller.open());
|
||||
stateChanged.clear();
|
||||
snapshotChanged.clear();
|
||||
|
||||
QVERIFY(controller.setAccountAlias(ACCOUNT_A, QStringLiteral("Spending")));
|
||||
QCOMPARE(stateChanged.count(), 1);
|
||||
QCOMPARE(snapshotChanged.count(), 0);
|
||||
|
||||
controller.refresh();
|
||||
QCOMPARE(stateChanged.count(), 3);
|
||||
QCOMPARE(snapshotChanged.count(), 1);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerOpenDoesNotWaitForWalletSync()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletAsyncOpenTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.deferAsync = true;
|
||||
provider.connectResult.snapshot.accounts = {
|
||||
{ ACCOUNT_A, QStringLiteral("5"), true },
|
||||
};
|
||||
WalletController controller(provider, settingsApplication);
|
||||
|
||||
QVERIFY(controller.open());
|
||||
QCOMPARE(provider.connectCalls, 1);
|
||||
QVERIFY(!controller.state().isWalletOpen);
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("opening"));
|
||||
QCOMPARE(controller.accountModel()->count(), 0);
|
||||
|
||||
provider.finishConnect();
|
||||
QVERIFY(controller.state().isWalletOpen);
|
||||
QVERIFY(controller.state().canSubmit());
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
|
||||
QCOMPARE(controller.accountModel()->count(), 1);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerCreationDoesNotWaitForWalletSync()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletAsyncCreationTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.deferAsync = true;
|
||||
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
|
||||
provider.snapshotResult.accounts = {
|
||||
{ ACCOUNT_A, QStringLiteral("5"), true },
|
||||
};
|
||||
WalletController controller(provider, settingsApplication);
|
||||
|
||||
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
|
||||
provider.createWalletResult.mnemonic);
|
||||
QCOMPARE(provider.createWalletCalls, 1);
|
||||
QCOMPARE(provider.snapshotCalls, 0);
|
||||
QVERIFY(controller.state().isWalletOpen);
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
|
||||
QVERIFY(!controller.state().canSubmit());
|
||||
QCOMPARE(controller.accountModel()->count(), 0);
|
||||
|
||||
QTRY_COMPARE(provider.snapshotCalls, 1);
|
||||
QVERIFY(provider.lastForceRefresh);
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("syncing"));
|
||||
provider.finishSnapshot();
|
||||
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("ready"));
|
||||
QVERIFY(controller.state().canSubmit());
|
||||
QCOMPARE(controller.accountModel()->count(), 1);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerSeedsDefaultWalletConfigWithConfiguredEndpoint()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
|
||||
ScopedEnvironment walletHomeEnvironment(
|
||||
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
|
||||
const QString settingsApplication = QStringLiteral("WalletDefaultEndpointTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
|
||||
WalletController controller(provider, settingsApplication);
|
||||
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
|
||||
|
||||
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
|
||||
provider.createWalletResult.mnemonic);
|
||||
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
|
||||
QCOMPARE(provider.lastPaths.config, configPath);
|
||||
|
||||
QFile config(configPath);
|
||||
QVERIFY(config.open(QIODevice::ReadOnly));
|
||||
const QJsonDocument document = QJsonDocument::fromJson(config.readAll());
|
||||
QVERIFY(document.isObject());
|
||||
const QJsonObject values = document.object();
|
||||
QCOMPARE(values.value(QStringLiteral("sequencer_addr")).toString(),
|
||||
QStringLiteral("https://testnet.lez.logos.co/"));
|
||||
QCOMPARE(values.value(QStringLiteral("seq_poll_timeout")).toString(),
|
||||
QStringLiteral("12s"));
|
||||
QCOMPARE(values.value(QStringLiteral("seq_tx_poll_max_blocks")).toInt(), 5);
|
||||
QCOMPARE(values.value(QStringLiteral("seq_poll_max_retries")).toInt(), 5);
|
||||
QCOMPARE(values.value(QStringLiteral("seq_block_poll_max_amount")).toInt(), 100);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerPreservesExistingDefaultWalletConfig()
|
||||
{
|
||||
QTemporaryDir directory;
|
||||
QVERIFY(directory.isValid());
|
||||
const QString walletHome = directory.filePath(QStringLiteral("wallet"));
|
||||
QVERIFY(QDir().mkpath(walletHome));
|
||||
const QString configPath = walletHome + QStringLiteral("/wallet_config.json");
|
||||
const QByteArray existingConfig = QByteArrayLiteral(
|
||||
"{\"sequencer_addr\":\"http://127.0.0.1:3040/\",\"custom\":true}");
|
||||
QFile config(configPath);
|
||||
QVERIFY(config.open(QIODevice::WriteOnly));
|
||||
QCOMPARE(config.write(existingConfig), qint64(existingConfig.size()));
|
||||
config.close();
|
||||
|
||||
ScopedEnvironment walletHomeEnvironment(
|
||||
QByteArrayLiteral("LEE_WALLET_HOME_DIR"), walletHome.toLocal8Bit());
|
||||
const QString settingsApplication = QStringLiteral("WalletExistingEndpointTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
|
||||
WalletController controller(provider, settingsApplication);
|
||||
controller.setDefaultSequencerAddress(QStringLiteral("https://testnet.lez.logos.co/"));
|
||||
|
||||
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
|
||||
provider.createWalletResult.mnemonic);
|
||||
QVERIFY(config.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(config.readAll(), existingConfig);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletReachabilityTest");
|
||||
@@ -434,19 +880,164 @@ void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect()
|
||||
|
||||
QVERIFY(controller.open());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000);
|
||||
controller.disconnect();
|
||||
finished.clear();
|
||||
|
||||
auto* timer = controller.findChild<QTimer*>();
|
||||
QVERIFY(timer);
|
||||
QVERIFY(timer->isActive());
|
||||
controller.disconnect();
|
||||
QVERIFY(!timer->isActive());
|
||||
finished.clear();
|
||||
|
||||
timer->setInterval(1);
|
||||
controller.start();
|
||||
QTest::qWait(50);
|
||||
QVERIFY(!timer->isActive());
|
||||
QCOMPARE(finished.count(), 0);
|
||||
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::completedAsyncSnapshotReleasesCallback()
|
||||
{
|
||||
LogosModules modules;
|
||||
modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer");
|
||||
LogosWalletProvider provider(&modules);
|
||||
QVERIFY(provider.connect({}).ok());
|
||||
|
||||
bool completed = false;
|
||||
std::weak_ptr<int> callbackLifetime;
|
||||
{
|
||||
auto lifetime = std::make_shared<int>(1);
|
||||
callbackLifetime = lifetime;
|
||||
provider.snapshotAsync(true,
|
||||
[lifetime = std::move(lifetime), &completed](WalletSnapshot snapshot) {
|
||||
QVERIFY(snapshot.ok());
|
||||
completed = true;
|
||||
});
|
||||
}
|
||||
|
||||
QVERIFY(completed);
|
||||
QVERIFY(callbackLifetime.expired());
|
||||
QCOMPARE(modules.logos_execution_zone.saveCalls, 0);
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::deferredCallbacksIgnoreDestroyedController()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletDestroyedCallbackTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.deferAsync = true;
|
||||
{
|
||||
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
|
||||
QVERIFY(controller->open());
|
||||
}
|
||||
provider.finishConnect();
|
||||
|
||||
provider.deferAsync = false;
|
||||
{
|
||||
auto controller = std::make_unique<WalletController>(provider, settingsApplication);
|
||||
QVERIFY(controller->open());
|
||||
provider.deferAsync = true;
|
||||
controller->refresh();
|
||||
}
|
||||
provider.finishSnapshot();
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::newerReachabilityResultWins()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletReachabilityOrderTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
QTcpServer firstServer;
|
||||
QTcpServer secondServer;
|
||||
QVERIFY(firstServer.listen(QHostAddress::LocalHost));
|
||||
QVERIFY(secondServer.listen(QHostAddress::LocalHost));
|
||||
const QString firstEndpoint = QStringLiteral("http://127.0.0.1:%1")
|
||||
.arg(firstServer.serverPort());
|
||||
const QString secondEndpoint = QStringLiteral("http://127.0.0.1:%1")
|
||||
.arg(secondServer.serverPort());
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.connectResult.snapshot.sequencerAddress = firstEndpoint;
|
||||
WalletController controller(provider, settingsApplication);
|
||||
auto* network = controller.findChild<QNetworkAccessManager*>();
|
||||
QVERIFY(network);
|
||||
QSignalSpy finished(network, &QNetworkAccessManager::finished);
|
||||
|
||||
QVERIFY(controller.open());
|
||||
QTRY_VERIFY(firstServer.hasPendingConnections());
|
||||
QTcpSocket* first = firstServer.nextPendingConnection();
|
||||
QVERIFY(first);
|
||||
|
||||
provider.createAccountResult.accountId = ACCOUNT_B;
|
||||
provider.createAccountResult.snapshot.sequencerAddress = secondEndpoint;
|
||||
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
|
||||
QTRY_VERIFY(secondServer.hasPendingConnections());
|
||||
QTcpSocket* second = secondServer.nextPendingConnection();
|
||||
QVERIFY(second);
|
||||
|
||||
second->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
second->disconnectFromHost();
|
||||
QTRY_COMPARE(finished.size(), 1);
|
||||
QVERIFY(controller.state().sequencerReachable);
|
||||
|
||||
first->disconnectFromHost();
|
||||
QTRY_COMPARE(finished.size(), 2);
|
||||
QVERIFY(controller.state().sequencerReachable);
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::coalescesReachabilityChecksForSameEndpoint()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletReachabilityCoalesceTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
QTcpServer server;
|
||||
QVERIFY(server.listen(QHostAddress::LocalHost));
|
||||
const QString endpoint = QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort());
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.connectResult.snapshot.sequencerAddress = endpoint;
|
||||
WalletController controller(provider, settingsApplication);
|
||||
QVERIFY(controller.open());
|
||||
QTRY_VERIFY(server.hasPendingConnections());
|
||||
QTcpSocket* request = server.nextPendingConnection();
|
||||
QVERIFY(request);
|
||||
|
||||
provider.createAccountResult.accountId = ACCOUNT_B;
|
||||
provider.createAccountResult.snapshot.sequencerAddress = endpoint;
|
||||
QCOMPARE(controller.createAccount(true), ACCOUNT_B);
|
||||
QTest::qWait(50);
|
||||
QVERIFY(!server.hasPendingConnections());
|
||||
|
||||
request->write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
request->disconnectFromHost();
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
void LogosWalletProviderTest::controllerReportsPartialWalletCreation()
|
||||
{
|
||||
const QString settingsApplication = QStringLiteral("WalletPartialCreationTest");
|
||||
QSettings settings(QStringLiteral("Logos"), settingsApplication);
|
||||
settings.clear();
|
||||
|
||||
FakeWalletProvider provider;
|
||||
provider.createWalletResult.mnemonic = QStringLiteral("one two three");
|
||||
provider.createWalletResult.failure = WalletFailure::SaveFailed;
|
||||
WalletController controller(provider, settingsApplication);
|
||||
|
||||
QCOMPARE(controller.createDefaultWallet(QStringLiteral("secret")),
|
||||
provider.createWalletResult.mnemonic);
|
||||
QVERIFY(!controller.state().isWalletOpen);
|
||||
QCOMPARE(controller.state().syncStatus, QStringLiteral("error"));
|
||||
QCOMPARE(controller.state().syncError, QStringLiteral("save_failed"));
|
||||
settings.clear();
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(LogosWalletProviderTest)
|
||||
|
||||
#include "LogosWalletProviderTest.moc"
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
#include "SequencerIdentityProbe.h"
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QHash>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QQueue>
|
||||
#include <QSignalSpy>
|
||||
#include <QTcpServer>
|
||||
#include <QTcpSocket>
|
||||
#include <QUrl>
|
||||
#include <QtTest>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
const QString IDENTITY(64, QLatin1Char('a'));
|
||||
const QString OTHER_IDENTITY(64, QLatin1Char('b'));
|
||||
|
||||
SequencerNetworkContext::Configuration networkConfiguration()
|
||||
{
|
||||
return {
|
||||
QStringLiteral("testnet"),
|
||||
IDENTITY,
|
||||
QStringLiteral("checkpoint:"),
|
||||
};
|
||||
}
|
||||
|
||||
QByteArray jsonRpcResult(const QString& identity)
|
||||
{
|
||||
return QJsonDocument(QJsonObject {
|
||||
{ QStringLiteral("jsonrpc"), QStringLiteral("2.0") },
|
||||
{ QStringLiteral("id"), 1 },
|
||||
{ QStringLiteral("result"), identity },
|
||||
}).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
class RpcServer final : public QObject {
|
||||
public:
|
||||
RpcServer()
|
||||
{
|
||||
m_server.listen(QHostAddress::LocalHost);
|
||||
connect(&m_server, &QTcpServer::newConnection, this, [this]() {
|
||||
while (QTcpSocket* socket = m_server.nextPendingConnection())
|
||||
attach(socket);
|
||||
});
|
||||
}
|
||||
|
||||
QUrl endpoint() const
|
||||
{
|
||||
return QUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()));
|
||||
}
|
||||
|
||||
bool isListening() const { return m_server.isListening(); }
|
||||
|
||||
void enqueueResponse(int status, QByteArray body)
|
||||
{
|
||||
m_responses.enqueue({ status, std::move(body) });
|
||||
}
|
||||
|
||||
void holdNextResponse()
|
||||
{
|
||||
m_responses.enqueue({ 0, {} });
|
||||
}
|
||||
|
||||
void respondHeld(int status, QByteArray body)
|
||||
{
|
||||
while (!m_held.isEmpty()) {
|
||||
const QPointer<QTcpSocket> socket = m_held.dequeue();
|
||||
if (socket)
|
||||
sendResponse(socket, status, std::move(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int requestCount() const { return m_requests.size(); }
|
||||
QByteArray lastRequest() const { return m_requests.isEmpty() ? QByteArray() : m_requests.last(); }
|
||||
|
||||
private:
|
||||
struct Response {
|
||||
int status;
|
||||
QByteArray body;
|
||||
};
|
||||
|
||||
void attach(QTcpSocket* socket)
|
||||
{
|
||||
socket->setParent(this);
|
||||
connect(socket, &QTcpSocket::readyRead, this, [this, socket]() {
|
||||
QByteArray& request = m_partialRequests[socket];
|
||||
request.append(socket->readAll());
|
||||
const qsizetype headerEnd = request.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0)
|
||||
return;
|
||||
|
||||
const QByteArray headers = request.left(headerEnd);
|
||||
qsizetype contentLength = 0;
|
||||
for (const QByteArray& line : headers.split('\n')) {
|
||||
const qsizetype separator = line.indexOf(':');
|
||||
if (separator < 0)
|
||||
continue;
|
||||
if (line.left(separator).trimmed().compare("content-length",
|
||||
Qt::CaseInsensitive) == 0) {
|
||||
contentLength = line.mid(separator + 1).trimmed().toLongLong();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (request.size() < headerEnd + 4 + contentLength)
|
||||
return;
|
||||
|
||||
m_requests.append(request);
|
||||
m_partialRequests.remove(socket);
|
||||
if (m_responses.isEmpty()) {
|
||||
m_held.enqueue(socket);
|
||||
return;
|
||||
}
|
||||
|
||||
const Response response = m_responses.dequeue();
|
||||
if (response.status == 0) {
|
||||
m_held.enqueue(socket);
|
||||
return;
|
||||
}
|
||||
sendResponse(socket, response.status, response.body);
|
||||
});
|
||||
connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater);
|
||||
}
|
||||
|
||||
static void sendResponse(QTcpSocket* socket, int status, QByteArray body)
|
||||
{
|
||||
const QByteArray statusText = status >= 200 && status < 300
|
||||
? QByteArrayLiteral("OK") : QByteArrayLiteral("Service Unavailable");
|
||||
QByteArray response = QByteArrayLiteral("HTTP/1.1 ") + QByteArray::number(status)
|
||||
+ QByteArrayLiteral(" ") + statusText
|
||||
+ QByteArrayLiteral("\r\nContent-Type: application/json\r\nContent-Length: ")
|
||||
+ QByteArray::number(body.size())
|
||||
+ QByteArrayLiteral("\r\nConnection: close\r\n\r\n") + body;
|
||||
socket->write(response);
|
||||
socket->flush();
|
||||
socket->disconnectFromHost();
|
||||
}
|
||||
|
||||
QTcpServer m_server;
|
||||
QHash<QTcpSocket*, QByteArray> m_partialRequests;
|
||||
QQueue<Response> m_responses;
|
||||
QQueue<QPointer<QTcpSocket>> m_held;
|
||||
QList<QByteArray> m_requests;
|
||||
};
|
||||
|
||||
SequencerIdentityProbe::Request requestFor(const QUrl& endpoint)
|
||||
{
|
||||
return {
|
||||
endpoint,
|
||||
QStringLiteral("getChannelId"),
|
||||
QJsonArray { 10 },
|
||||
SequencerIdentityProbe::stringIdentity,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SequencerIdentityProbeTest final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void sendsConfiguredRequestAndAcceptsIdentity();
|
||||
void waitsForEndpointBeforeProbing();
|
||||
void retriesRejectedResponses_data();
|
||||
void retriesRejectedResponses();
|
||||
void supersedesEndpointReply();
|
||||
void abortsReplyWhenReachabilityIsLost();
|
||||
void retriesTransportFailureAfterEndpointChanges();
|
||||
void extractsCheckpointBlockHash();
|
||||
};
|
||||
|
||||
void SequencerIdentityProbeTest::sendsConfiguredRequestAndAcceptsIdentity()
|
||||
{
|
||||
RpcServer server;
|
||||
QVERIFY(server.isListening());
|
||||
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
|
||||
SequencerIdentityProbe probe;
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
|
||||
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
QCOMPARE(probe.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
|
||||
QCOMPARE(server.requestCount(), 1);
|
||||
QVERIFY(server.lastRequest().contains("\"method\":\"getChannelId\""));
|
||||
QVERIFY(server.lastRequest().contains("\"params\":[10]"));
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::waitsForEndpointBeforeProbing()
|
||||
{
|
||||
RpcServer server;
|
||||
QVERIFY(server.isListening());
|
||||
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
|
||||
SequencerIdentityProbe probe;
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(QUrl())));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
|
||||
QCOMPARE(server.requestCount(), 0);
|
||||
|
||||
QVERIFY(probe.setEndpoint(server.endpoint()));
|
||||
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
QCOMPARE(server.requestCount(), 1);
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::retriesRejectedResponses_data()
|
||||
{
|
||||
QTest::addColumn<int>("status");
|
||||
QTest::addColumn<QByteArray>("body");
|
||||
QTest::addColumn<QString>("failure");
|
||||
|
||||
QTest::newRow("http") << 503 << QByteArrayLiteral("{}")
|
||||
<< QStringLiteral("http_status");
|
||||
QTest::newRow("malformed") << 200 << QByteArrayLiteral("not-json")
|
||||
<< QStringLiteral("malformed_response");
|
||||
QTest::newRow("json-rpc-error") << 200
|
||||
<< QByteArrayLiteral("{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}")
|
||||
<< QStringLiteral("json_rpc_error");
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::retriesRejectedResponses()
|
||||
{
|
||||
QFETCH(int, status);
|
||||
QFETCH(QByteArray, body);
|
||||
QFETCH(QString, failure);
|
||||
RpcServer server;
|
||||
QVERIFY(server.isListening());
|
||||
server.enqueueResponse(status, body);
|
||||
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
|
||||
SequencerIdentityProbe probe;
|
||||
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
|
||||
QTRY_COMPARE(server.requestCount(), 2);
|
||||
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
QVERIFY(!failures.isEmpty());
|
||||
QCOMPARE(failures.first().at(0).toString(), failure);
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::supersedesEndpointReply()
|
||||
{
|
||||
RpcServer first;
|
||||
QVERIFY(first.isListening());
|
||||
first.holdNextResponse();
|
||||
RpcServer second;
|
||||
QVERIFY(second.isListening());
|
||||
second.enqueueResponse(200, jsonRpcResult(IDENTITY));
|
||||
SequencerIdentityProbe probe;
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(first.endpoint())));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
QTRY_COMPARE(first.requestCount(), 1);
|
||||
|
||||
QVERIFY(probe.setEndpoint(second.endpoint()));
|
||||
QTRY_COMPARE(second.requestCount(), 1);
|
||||
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
|
||||
first.respondHeld(200, jsonRpcResult(OTHER_IDENTITY));
|
||||
QTest::qWait(50);
|
||||
QCOMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::abortsReplyWhenReachabilityIsLost()
|
||||
{
|
||||
RpcServer server;
|
||||
QVERIFY(server.isListening());
|
||||
server.holdNextResponse();
|
||||
SequencerIdentityProbe probe;
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(server.endpoint())));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
QTRY_COMPARE(server.requestCount(), 1);
|
||||
|
||||
probe.setReachable(false);
|
||||
server.respondHeld(200, jsonRpcResult(IDENTITY));
|
||||
QTest::qWait(50);
|
||||
QCOMPARE(probe.snapshot().status, QStringLiteral("network_unknown"));
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::retriesTransportFailureAfterEndpointChanges()
|
||||
{
|
||||
QTcpServer unavailable;
|
||||
QVERIFY(unavailable.listen(QHostAddress::LocalHost));
|
||||
const QUrl unavailableEndpoint(
|
||||
QStringLiteral("http://127.0.0.1:%1").arg(unavailable.serverPort()));
|
||||
unavailable.close();
|
||||
|
||||
RpcServer server;
|
||||
QVERIFY(server.isListening());
|
||||
server.enqueueResponse(200, jsonRpcResult(IDENTITY));
|
||||
SequencerIdentityProbe probe;
|
||||
QSignalSpy failures(&probe, &SequencerIdentityProbe::probeFailed);
|
||||
|
||||
QVERIFY(probe.configure(networkConfiguration(), requestFor(unavailableEndpoint)));
|
||||
probe.setSequencerAvailable(true);
|
||||
probe.setReachable(true);
|
||||
QTRY_VERIFY(!failures.isEmpty());
|
||||
QCOMPARE(failures.first().at(0).toString(), QStringLiteral("transport_error"));
|
||||
|
||||
QVERIFY(probe.setEndpoint(server.endpoint()));
|
||||
QTRY_COMPARE(probe.snapshot().status, QStringLiteral("ready"));
|
||||
}
|
||||
|
||||
void SequencerIdentityProbeTest::extractsCheckpointBlockHash()
|
||||
{
|
||||
QByteArray block(72, '\0');
|
||||
const QByteArray expected(32, static_cast<char>(0xab));
|
||||
block.replace(40, expected.size(), expected);
|
||||
|
||||
QCOMPARE(SequencerIdentityProbe::checkpointBlockHash(
|
||||
QJsonValue(QString::fromLatin1(block.toBase64()))),
|
||||
QString::fromLatin1(expected.toHex()));
|
||||
QVERIFY(SequencerIdentityProbe::checkpointBlockHash(QJsonValue(QStringLiteral("bad"))).isEmpty());
|
||||
}
|
||||
|
||||
QTEST_MAIN(SequencerIdentityProbeTest)
|
||||
|
||||
#include "SequencerIdentityProbeTest.moc"
|
||||
@@ -0,0 +1,96 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "SequencerNetworkContext.h"
|
||||
|
||||
namespace {
|
||||
const QString NETWORK_ID = QStringLiteral("testnet");
|
||||
const QString IDENTITY(64, QLatin1Char('a'));
|
||||
|
||||
SequencerNetworkContext::Configuration configuration()
|
||||
{
|
||||
return {
|
||||
NETWORK_ID,
|
||||
IDENTITY,
|
||||
QStringLiteral("checkpoint:"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SequencerNetworkContextTest final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void acceptsMatchingIdentity();
|
||||
void rejectsLateReplyAfterReachabilityLoss();
|
||||
void rejectsSupersededProbe();
|
||||
void rejectsInvalidConfiguration();
|
||||
};
|
||||
|
||||
void SequencerNetworkContextTest::acceptsMatchingIdentity()
|
||||
{
|
||||
SequencerNetworkContext context;
|
||||
|
||||
QVERIFY(context.configure(configuration()));
|
||||
context.setSequencerAvailable(true);
|
||||
context.setReachable(true);
|
||||
const std::optional<quint64> probe = context.beginIdentityProbe();
|
||||
|
||||
QVERIFY(probe.has_value());
|
||||
QVERIFY(context.finishIdentityProbe(*probe, IDENTITY));
|
||||
QCOMPARE(context.snapshot().id, NETWORK_ID);
|
||||
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
|
||||
QCOMPARE(context.snapshot().fingerprint, QStringLiteral("checkpoint:") + IDENTITY);
|
||||
}
|
||||
|
||||
void SequencerNetworkContextTest::rejectsLateReplyAfterReachabilityLoss()
|
||||
{
|
||||
SequencerNetworkContext context;
|
||||
|
||||
QVERIFY(context.configure(configuration()));
|
||||
context.setSequencerAvailable(true);
|
||||
context.setReachable(true);
|
||||
const std::optional<quint64> probe = context.beginIdentityProbe();
|
||||
QVERIFY(probe.has_value());
|
||||
|
||||
context.setReachable(false);
|
||||
|
||||
QVERIFY(!context.finishIdentityProbe(*probe, IDENTITY));
|
||||
QCOMPARE(context.snapshot().status, QStringLiteral("network_unknown"));
|
||||
QVERIFY(context.snapshot().fingerprint.isEmpty());
|
||||
}
|
||||
|
||||
void SequencerNetworkContextTest::rejectsSupersededProbe()
|
||||
{
|
||||
SequencerNetworkContext context;
|
||||
|
||||
QVERIFY(context.configure(configuration()));
|
||||
context.setSequencerAvailable(true);
|
||||
context.setReachable(true);
|
||||
const std::optional<quint64> firstProbe = context.beginIdentityProbe();
|
||||
QVERIFY(firstProbe.has_value());
|
||||
|
||||
context.setReachable(false);
|
||||
context.setReachable(true);
|
||||
const std::optional<quint64> secondProbe = context.beginIdentityProbe();
|
||||
QVERIFY(secondProbe.has_value());
|
||||
|
||||
QVERIFY(!context.finishIdentityProbe(*firstProbe, IDENTITY));
|
||||
QVERIFY(context.finishIdentityProbe(*secondProbe, IDENTITY));
|
||||
QCOMPARE(context.snapshot().status, QStringLiteral("ready"));
|
||||
}
|
||||
|
||||
void SequencerNetworkContextTest::rejectsInvalidConfiguration()
|
||||
{
|
||||
SequencerNetworkContext context;
|
||||
SequencerNetworkContext::Configuration invalid = configuration();
|
||||
invalid.expectedIdentity = QString(64, QLatin1Char('A'));
|
||||
|
||||
QVERIFY(!context.configure(invalid));
|
||||
QVERIFY(!context.isConfigured());
|
||||
QCOMPARE(context.snapshot().id, NETWORK_ID);
|
||||
QCOMPARE(context.snapshot().status, QStringLiteral("config_missing"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(SequencerNetworkContextTest)
|
||||
|
||||
#include "SequencerNetworkContextTest.moc"
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "SequencerNetworkSettings.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QTemporaryFile>
|
||||
#include <QtTest>
|
||||
|
||||
class SequencerNetworkSettingsTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void loadsBundledTestnetIdentity();
|
||||
void loadsDevnetChannelIdentity();
|
||||
void rejectsInvalidDevnetIdentity();
|
||||
};
|
||||
|
||||
void SequencerNetworkSettingsTest::loadsBundledTestnetIdentity()
|
||||
{
|
||||
const auto settings = SequencerNetworkSettingsLoader::load(
|
||||
QStringLiteral("testnet"), {});
|
||||
|
||||
QVERIFY(settings);
|
||||
QCOMPARE(settings->context.id, QStringLiteral("testnet"));
|
||||
QCOMPARE(settings->context.expectedIdentity,
|
||||
QStringLiteral("0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a"));
|
||||
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("block10:"));
|
||||
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::CheckpointBlock);
|
||||
}
|
||||
|
||||
void SequencerNetworkSettingsTest::loadsDevnetChannelIdentity()
|
||||
{
|
||||
const QString identity(64, QLatin1Char('a'));
|
||||
QTemporaryFile config;
|
||||
QVERIFY(config.open());
|
||||
const QByteArray contents = QJsonDocument(QJsonObject {
|
||||
{ QStringLiteral("channelId"), identity },
|
||||
}).toJson(QJsonDocument::Compact);
|
||||
QCOMPARE(config.write(contents), qint64(contents.size()));
|
||||
config.flush();
|
||||
|
||||
const auto settings = SequencerNetworkSettingsLoader::load(
|
||||
QStringLiteral("devnet"), config.fileName());
|
||||
|
||||
QVERIFY(settings);
|
||||
QCOMPARE(settings->context.id, QStringLiteral("devnet"));
|
||||
QCOMPARE(settings->context.expectedIdentity, identity);
|
||||
QCOMPARE(settings->context.fingerprintPrefix, QStringLiteral("channel:"));
|
||||
QCOMPARE(settings->identityMethod, SequencerIdentityMethod::ChannelId);
|
||||
}
|
||||
|
||||
void SequencerNetworkSettingsTest::rejectsInvalidDevnetIdentity()
|
||||
{
|
||||
QTemporaryFile config;
|
||||
QVERIFY(config.open());
|
||||
const QByteArray contents = QJsonDocument(QJsonObject {
|
||||
{ QStringLiteral("channelId"), QStringLiteral("not-an-identity") },
|
||||
}).toJson(QJsonDocument::Compact);
|
||||
QCOMPARE(config.write(contents), qint64(contents.size()));
|
||||
config.flush();
|
||||
|
||||
QVERIFY(!SequencerNetworkSettingsLoader::load(
|
||||
QStringLiteral("devnet"), config.fileName()));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(SequencerNetworkSettingsTest)
|
||||
#include "SequencerNetworkSettingsTest.moc"
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "WalletIdlDecoder.h"
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
class WalletIdlDecoderLinkTest final : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void linksDefaultDecoder();
|
||||
};
|
||||
|
||||
void WalletIdlDecoderLinkTest::linksDefaultDecoder()
|
||||
{
|
||||
const WalletDecodeResult result = WalletIdlDecoder::decode(
|
||||
QByteArrayLiteral("not-json"), {});
|
||||
|
||||
QCOMPARE(result.status, QStringLiteral("error"));
|
||||
QCOMPARE(result.error, QStringLiteral("invalid_idl"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(WalletIdlDecoderLinkTest)
|
||||
#include "WalletIdlDecoderLinkTest.moc"
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "WalletPortfolioService.h"
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
#include <utility>
|
||||
|
||||
// The service normally links this symbol from wallet-idl-decoder. Every test
|
||||
// injects a decoder, so keep this target independent from the Rust FFI library.
|
||||
WalletDecodeResult WalletIdlDecoder::decode(const QByteArray&,
|
||||
const QVector<WalletAccountRead>&)
|
||||
{
|
||||
return {
|
||||
QStringLiteral("error"),
|
||||
QStringLiteral("unexpected_default_decoder"),
|
||||
{},
|
||||
};
|
||||
}
|
||||
|
||||
namespace {
|
||||
const QString DEFINITION_ID(64, QLatin1Char('a'));
|
||||
const QString DEFINITION_BASE58 = QStringLiteral("base58-definition");
|
||||
const QString HOLDING_ID(64, QLatin1Char('b'));
|
||||
const QString TOKEN_PROGRAM_ID(64, QLatin1Char('c'));
|
||||
const QString AMM_ACCOUNT_ID(64, QLatin1Char('d'));
|
||||
const QString AMM_PROGRAM_ID(64, QLatin1Char('e'));
|
||||
|
||||
WalletAccountRead read(const QString& accountId,
|
||||
const QString& programOwner,
|
||||
const QString& data)
|
||||
{
|
||||
WalletAccountRead result;
|
||||
result.accountId = accountId;
|
||||
result.status = QStringLiteral("ok");
|
||||
result.programOwner = programOwner;
|
||||
result.dataHex = data;
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletPortfolioRequest request(const QString& name = QStringLiteral("Test token"))
|
||||
{
|
||||
WalletSnapshot snapshot;
|
||||
snapshot.publicAccountReads = {
|
||||
read(DEFINITION_ID, TOKEN_PROGRAM_ID, QStringLiteral("definition")),
|
||||
read(HOLDING_ID, TOKEN_PROGRAM_ID, QStringLiteral("holding")),
|
||||
read(AMM_ACCOUNT_ID, AMM_PROGRAM_ID, QStringLiteral("amm")),
|
||||
};
|
||||
WalletPortfolioRequest result(snapshot);
|
||||
result.tokenDefinitionIds = { DEFINITION_BASE58 };
|
||||
result.tokens = { QVariantMap {
|
||||
{ QStringLiteral("definitionId"), DEFINITION_BASE58 },
|
||||
{ QStringLiteral("definitionIdHex"), DEFINITION_ID },
|
||||
{ QStringLiteral("name"), name },
|
||||
} };
|
||||
result.tokenProgramId = TOKEN_PROGRAM_ID;
|
||||
result.tokenIdl = QByteArrayLiteral("token-idl");
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletDecodedAccount tokenDefinition()
|
||||
{
|
||||
WalletDecodedAccount account;
|
||||
account.id = DEFINITION_ID;
|
||||
account.status = QStringLiteral("decoded");
|
||||
account.typeName = QStringLiteral("TokenDefinition");
|
||||
account.value = QJsonObject {
|
||||
{ QStringLiteral("Fungible"), QJsonObject {
|
||||
{ QStringLiteral("name"), QStringLiteral("Test token") },
|
||||
} },
|
||||
};
|
||||
return account;
|
||||
}
|
||||
|
||||
WalletDecodedAccount tokenHolding(bool decoded = true)
|
||||
{
|
||||
WalletDecodedAccount account;
|
||||
account.id = HOLDING_ID;
|
||||
account.status = decoded ? QStringLiteral("decoded") : QStringLiteral("error");
|
||||
account.typeName = QStringLiteral("TokenHolding");
|
||||
account.value = QJsonObject {
|
||||
{ QStringLiteral("Fungible"), QJsonObject {
|
||||
{ QStringLiteral("definition_id"), QStringLiteral("definition") },
|
||||
{ QStringLiteral("balance"), QStringLiteral("25") },
|
||||
} },
|
||||
};
|
||||
account.accountIds.insert(QStringLiteral("definition"), DEFINITION_ID);
|
||||
return account;
|
||||
}
|
||||
|
||||
WalletDecodedAccount ammAccount()
|
||||
{
|
||||
WalletDecodedAccount account;
|
||||
account.id = AMM_ACCOUNT_ID;
|
||||
account.status = QStringLiteral("decoded");
|
||||
account.typeName = QStringLiteral("Pool");
|
||||
account.value = QJsonObject {
|
||||
{ QStringLiteral("Pool"), QJsonObject {} },
|
||||
};
|
||||
return account;
|
||||
}
|
||||
|
||||
WalletPortfolioService::Decoder decoder(int* calls, bool failHolding = false)
|
||||
{
|
||||
return [calls, failHolding](const QByteArray&, const QVector<WalletAccountRead>& reads) {
|
||||
++*calls;
|
||||
WalletDecodeResult result;
|
||||
result.status = QStringLiteral("ok");
|
||||
for (const WalletAccountRead& item : reads) {
|
||||
if (item.accountId == DEFINITION_ID)
|
||||
result.accounts.append(tokenDefinition());
|
||||
else if (item.accountId == HOLDING_ID)
|
||||
result.accounts.append(tokenHolding(!failHolding));
|
||||
else if (item.accountId == AMM_ACCOUNT_ID)
|
||||
result.accounts.append(ammAccount());
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WalletPortfolioServiceTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void acceptsBase58DefinitionsAndReusesUnchangedDecodes();
|
||||
void exposesHoldingDecodeFailureWithoutZeroBalance();
|
||||
void exposesUnreadPublicAccountWithoutZeroBalance();
|
||||
void exposesUnresolvedDefinitions();
|
||||
};
|
||||
|
||||
void WalletPortfolioServiceTest::acceptsBase58DefinitionsAndReusesUnchangedDecodes()
|
||||
{
|
||||
int decodeCalls = 0;
|
||||
WalletPortfolioService service(decoder(&decodeCalls));
|
||||
service.registerProgram(AMM_PROGRAM_ID, QStringLiteral("AMM"), QByteArrayLiteral("amm-idl"));
|
||||
|
||||
const WalletPortfolioResult first = service.refresh(request());
|
||||
|
||||
QCOMPARE(first.status, QStringLiteral("ready"));
|
||||
QCOMPARE(first.assets.size(), 1);
|
||||
const QVariantMap asset = first.assets.first().toMap();
|
||||
QCOMPARE(asset.value(QStringLiteral("definitionId")).toString(), DEFINITION_ID);
|
||||
QCOMPARE(asset.value(QStringLiteral("displayDefinitionId")).toString(), DEFINITION_BASE58);
|
||||
QCOMPARE(asset.value(QStringLiteral("balance")).toString(), QStringLiteral("25"));
|
||||
QCOMPARE(first.presentations.size(), 3);
|
||||
const int firstDecodeCalls = decodeCalls;
|
||||
QVERIFY(firstDecodeCalls > 0);
|
||||
|
||||
const WalletPortfolioResult renamed = service.refresh(request(QStringLiteral("Renamed")));
|
||||
|
||||
QCOMPARE(decodeCalls, firstDecodeCalls);
|
||||
QCOMPARE(renamed.status, QStringLiteral("ready"));
|
||||
QCOMPARE(renamed.assets.first().toMap().value(QStringLiteral("name")).toString(),
|
||||
QStringLiteral("Renamed"));
|
||||
}
|
||||
|
||||
void WalletPortfolioServiceTest::exposesHoldingDecodeFailureWithoutZeroBalance()
|
||||
{
|
||||
int decodeCalls = 0;
|
||||
WalletPortfolioService service(decoder(&decodeCalls, true));
|
||||
|
||||
const WalletPortfolioResult result = service.refresh(request());
|
||||
|
||||
QCOMPARE(result.status, QStringLiteral("partial"));
|
||||
QCOMPARE(result.error, QStringLiteral("holding_decode_failed"));
|
||||
const QVariantMap asset = result.assets.first().toMap();
|
||||
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
|
||||
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
|
||||
}
|
||||
|
||||
void WalletPortfolioServiceTest::exposesUnreadPublicAccountWithoutZeroBalance()
|
||||
{
|
||||
int decodeCalls = 0;
|
||||
WalletPortfolioService service(decoder(&decodeCalls));
|
||||
WalletPortfolioRequest input = request();
|
||||
for (WalletAccountRead& account : input.publicAccountReads) {
|
||||
if (account.accountId == HOLDING_ID)
|
||||
account = WalletAccountRead { HOLDING_ID };
|
||||
}
|
||||
|
||||
const WalletPortfolioResult result = service.refresh(input);
|
||||
|
||||
QCOMPARE(result.status, QStringLiteral("partial"));
|
||||
QCOMPARE(result.error, QStringLiteral("public_account_read_failed"));
|
||||
const QVariantMap asset = result.assets.first().toMap();
|
||||
QCOMPARE(asset.value(QStringLiteral("status")).toString(), QStringLiteral("unavailable"));
|
||||
QVERIFY(asset.value(QStringLiteral("balance")).toString().isEmpty());
|
||||
}
|
||||
|
||||
void WalletPortfolioServiceTest::exposesUnresolvedDefinitions()
|
||||
{
|
||||
int decodeCalls = 0;
|
||||
WalletPortfolioService service(decoder(&decodeCalls));
|
||||
WalletPortfolioRequest input = request();
|
||||
input.tokens.clear();
|
||||
|
||||
const WalletPortfolioResult result = service.refresh(input);
|
||||
|
||||
QCOMPARE(result.status, QStringLiteral("error"));
|
||||
QCOMPARE(result.error, QStringLiteral("definitions_unavailable"));
|
||||
QCOMPARE(result.assets.size(), 1);
|
||||
QCOMPARE(result.assets.first().toMap().value(QStringLiteral("status")).toString(),
|
||||
QStringLiteral("unavailable"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(WalletPortfolioServiceTest)
|
||||
#include "WalletPortfolioServiceTest.moc"
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <QVariant>
|
||||
#include <QVariantList>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class LogosAPI;
|
||||
|
||||
class FakeExecutionZone {
|
||||
@@ -48,6 +50,13 @@ public:
|
||||
return openResult;
|
||||
}
|
||||
|
||||
void openAsync(const QString& config,
|
||||
const QString& storage,
|
||||
std::function<void(int)> callback)
|
||||
{
|
||||
callback(open(config, storage));
|
||||
}
|
||||
|
||||
QString create_new(const QString& config,
|
||||
const QString& storage,
|
||||
const QString& password)
|
||||
@@ -66,34 +75,69 @@ public:
|
||||
|
||||
QString create_account_public() { return publicAccountId; }
|
||||
QString create_account_private() { return privateAccountId; }
|
||||
QString account_id_to_base58(const QString& accountId) const
|
||||
{
|
||||
return QStringLiteral("base58-") + accountId;
|
||||
}
|
||||
|
||||
int get_last_synced_block() const { return lastSyncedBlock; }
|
||||
int get_current_block_height() const { return currentBlockHeight; }
|
||||
void get_last_synced_blockAsync(std::function<void(int)> callback)
|
||||
{
|
||||
callback(get_last_synced_block());
|
||||
}
|
||||
void get_current_block_heightAsync(std::function<void(int)> callback)
|
||||
{
|
||||
callback(get_current_block_height());
|
||||
}
|
||||
|
||||
int sync_to_block(quint64)
|
||||
{
|
||||
++syncCalls;
|
||||
return syncResult;
|
||||
}
|
||||
void sync_to_blockAsync(int blockId, std::function<void(int)> callback)
|
||||
{
|
||||
callback(sync_to_block(static_cast<quint64>(blockId)));
|
||||
}
|
||||
|
||||
QString get_sequencer_addr() const { return sequencerAddress; }
|
||||
void get_sequencer_addrAsync(std::function<void(QString)> callback)
|
||||
{
|
||||
callback(get_sequencer_addr());
|
||||
}
|
||||
|
||||
QVariantList list_accounts()
|
||||
{
|
||||
++listCalls;
|
||||
return accounts;
|
||||
}
|
||||
void list_accountsAsync(std::function<void(QVariantList)> callback)
|
||||
{
|
||||
callback(list_accounts());
|
||||
}
|
||||
|
||||
QString get_account_public(const QString& accountId)
|
||||
{
|
||||
++publicReadCalls;
|
||||
return publicAccounts.value(accountId);
|
||||
}
|
||||
void get_account_publicAsync(const QString& accountId,
|
||||
std::function<void(QString)> callback)
|
||||
{
|
||||
callback(get_account_public(accountId));
|
||||
}
|
||||
|
||||
QString get_balance(const QString& accountId, bool) const
|
||||
{
|
||||
return balances.value(accountId);
|
||||
}
|
||||
void get_balanceAsync(const QString& accountId,
|
||||
bool isPublic,
|
||||
std::function<void(QString)> callback)
|
||||
{
|
||||
callback(get_balance(accountId, isPublic));
|
||||
}
|
||||
|
||||
QString send_generic_public_transaction(
|
||||
const QStringList& accountIds,
|
||||
@@ -108,6 +152,7 @@ public:
|
||||
submittedProgramId = programId;
|
||||
return transactionResponse;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
struct LogosModules {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
import Logos.Wallet as Wallet
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
width: 360
|
||||
height: 240
|
||||
|
||||
Component {
|
||||
id: copyButtonComponent
|
||||
|
||||
Wallet.CopyButton {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: clipboardSinkComponent
|
||||
|
||||
TextEdit {}
|
||||
}
|
||||
|
||||
TestCase {
|
||||
name: "CopyButton"
|
||||
when: windowShown
|
||||
|
||||
function test_copiesText() {
|
||||
const value = "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
|
||||
const copyButton = createTemporaryObject(copyButtonComponent, root, {
|
||||
"copyText": value,
|
||||
"copyLabel": "Copy address"
|
||||
})
|
||||
const sink = createTemporaryObject(clipboardSinkComponent, root)
|
||||
verify(copyButton, "Copy button exists")
|
||||
verify(sink, "Clipboard sink exists")
|
||||
compare(copyButton.implicitWidth, 36)
|
||||
compare(copyButton.implicitHeight, 36)
|
||||
|
||||
copyButton.click()
|
||||
|
||||
verify(copyButton.copied)
|
||||
sink.paste()
|
||||
tryCompare(sink, "text", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,38 @@ Item {
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_activityStateDoesNotBlockCancellation() {
|
||||
const dialog = createTemporaryObject(dialogComponent, root)
|
||||
verify(dialog, "Dialog exists")
|
||||
dialog.openWithSnapshot({ amount: "5" })
|
||||
tryCompare(dialog, "opened", true)
|
||||
|
||||
dialog.activityBusy = true
|
||||
const cancelButton = findChild(dialog, "transactionCancelButton")
|
||||
const confirmButton = findChild(dialog, "transactionConfirmButton")
|
||||
verify(cancelButton.enabled)
|
||||
verify(!confirmButton.enabled)
|
||||
|
||||
dialog.cancel()
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_cancelUsesTheConfirmationButtonShape() {
|
||||
const dialog = createTemporaryObject(dialogComponent, root)
|
||||
verify(dialog, "Dialog exists")
|
||||
dialog.roundedCancelButton = true
|
||||
dialog.openWithSnapshot({ amount: "5" })
|
||||
tryCompare(dialog, "opened", true)
|
||||
|
||||
const cancelButtonLoader = findChild(dialog, "transactionCancelButtonLoader")
|
||||
const confirmButton = findChild(dialog, "transactionConfirmButton")
|
||||
verify(cancelButtonLoader)
|
||||
tryVerify(function() {
|
||||
return cancelButtonLoader.item
|
||||
&& cancelButtonLoader.item.background.radius === confirmButton.background.radius
|
||||
})
|
||||
}
|
||||
|
||||
function test_keepsActionsInsideShortViewport() {
|
||||
const viewport = createTemporaryObject(viewportComponent, root)
|
||||
verify(viewport, "Short viewport exists")
|
||||
|
||||
@@ -13,32 +13,65 @@ Item {
|
||||
QtObject {
|
||||
property bool isWalletOpen: false
|
||||
property bool walletExists: true
|
||||
property bool completeOpenImmediately: true
|
||||
property bool createWalletFails: false
|
||||
property bool createWalletRefreshFails: false
|
||||
property bool accountRefreshFails: false
|
||||
property string walletHome: "/wallet"
|
||||
property string walletSyncStatus: "closed"
|
||||
property string walletSyncError: ""
|
||||
property bool deferOpen: false
|
||||
property int openCalls: 0
|
||||
property int createCalls: 0
|
||||
property int publicAccountCalls: 0
|
||||
property int privateAccountCalls: 0
|
||||
property int disconnectCalls: 0
|
||||
property int primaryAccountCalls: 0
|
||||
property int aliasCalls: 0
|
||||
property string primaryAccountAddress: ""
|
||||
property string primaryAccountName: ""
|
||||
property string activeNetwork: "testnet"
|
||||
property string networkStatus: "ready"
|
||||
property string assetStatus: "ready"
|
||||
property string assetError: ""
|
||||
property var assets: []
|
||||
|
||||
function openExisting() {
|
||||
openCalls++
|
||||
isWalletOpen = true
|
||||
if (deferOpen) {
|
||||
walletSyncStatus = "opening"
|
||||
} else {
|
||||
isWalletOpen = true
|
||||
walletSyncStatus = "ready"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function createNewDefault(_password) {
|
||||
createCalls++
|
||||
if (createWalletFails)
|
||||
return ""
|
||||
isWalletOpen = true
|
||||
walletSyncStatus = createWalletRefreshFails ? "error" : "ready"
|
||||
walletSyncError = createWalletRefreshFails ? "read_failed" : ""
|
||||
return "alpha beta gamma"
|
||||
}
|
||||
|
||||
function createAccountPublic() {
|
||||
publicAccountCalls++
|
||||
if (accountRefreshFails) {
|
||||
walletSyncStatus = "error"
|
||||
walletSyncError = "read_failed"
|
||||
}
|
||||
return "a".repeat(64)
|
||||
}
|
||||
|
||||
function createAccountPrivate() {
|
||||
privateAccountCalls++
|
||||
if (accountRefreshFails) {
|
||||
walletSyncStatus = "error"
|
||||
walletSyncError = "read_failed"
|
||||
}
|
||||
return "b".repeat(64)
|
||||
}
|
||||
|
||||
@@ -46,6 +79,17 @@ Item {
|
||||
disconnectCalls++
|
||||
isWalletOpen = false
|
||||
}
|
||||
|
||||
function setPrimaryAccount(address) {
|
||||
primaryAccountCalls++
|
||||
primaryAccountAddress = address
|
||||
return true
|
||||
}
|
||||
|
||||
function setAccountAlias(_address, _alias) {
|
||||
aliasCalls++
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +98,25 @@ Item {
|
||||
ListModel { }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: portfolioComponent
|
||||
|
||||
QtObject {
|
||||
property string assetStatus: "ready"
|
||||
property string assetError: ""
|
||||
property var assets: []
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: networkComponent
|
||||
|
||||
QtObject {
|
||||
property string activeNetwork: ""
|
||||
property string networkStatus: "ready"
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: controlComponent
|
||||
Wallet.WalletControl {
|
||||
@@ -115,7 +178,7 @@ Item {
|
||||
const model = createTemporaryObject(modelComponent, root)
|
||||
verify(model, "Account model exists")
|
||||
for (const account of accounts || [])
|
||||
model.append(account)
|
||||
model.append(accountData(account))
|
||||
const control = createTemporaryObject(controlComponent, root, {
|
||||
wallet: backend,
|
||||
accountModel: model
|
||||
@@ -124,6 +187,25 @@ Item {
|
||||
return { backend, model, control }
|
||||
}
|
||||
|
||||
function accountData(account) {
|
||||
return {
|
||||
name: account.name || "Account",
|
||||
alias: account.alias || "",
|
||||
address: account.address || "",
|
||||
displayAddress: account.displayAddress || account.address || "",
|
||||
balance: account.balance || "0",
|
||||
isPublic: account.isPublic === true,
|
||||
kind: account.kind || (account.isPublic === false ? "private" : "user"),
|
||||
section: account.section || "accounts",
|
||||
programName: account.programName || "",
|
||||
accountType: account.accountType || "",
|
||||
decodedData: account.decodedData || "",
|
||||
visibility: account.visibility || (account.isPublic === false ? "private" : "public"),
|
||||
canBePrimary: account.canBePrimary === undefined ? true : account.canBePrimary,
|
||||
isPrimary: account.isPrimary === true
|
||||
}
|
||||
}
|
||||
|
||||
function test_opensExistingWallet() {
|
||||
const fixture = createControl({ walletExists: true }, [])
|
||||
const connectButton = findChild(fixture.control, "walletConnectButton")
|
||||
@@ -133,6 +215,19 @@ Item {
|
||||
tryCompare(fixture.control, "connected", true)
|
||||
}
|
||||
|
||||
function test_surfacesDeferredOpenFailure() {
|
||||
const fixture = createControl({ walletExists: true, deferOpen: true }, [])
|
||||
mouseClick(findChild(fixture.control, "walletConnectButton"))
|
||||
compare(fixture.backend.openCalls, 1)
|
||||
compare(fixture.control.syncStatus, "opening")
|
||||
|
||||
fixture.backend.walletSyncStatus = "error"
|
||||
fixture.backend.walletSyncError = "open_failed"
|
||||
const dialog = findChild(fixture.control, "walletMessageDialog")
|
||||
tryCompare(dialog, "opened", true)
|
||||
verify(dialog.message.includes("open_failed"))
|
||||
}
|
||||
|
||||
function test_requiresSeedBackupAcknowledgement() {
|
||||
const fixture = createControl({ walletExists: false }, [])
|
||||
mouseClick(findChild(fixture.control, "walletConnectButton"))
|
||||
@@ -165,6 +260,42 @@ Item {
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_showsWalletCreationFailure() {
|
||||
const fixture = createControl({ walletExists: false, createWalletFails: true }, [])
|
||||
mouseClick(findChild(fixture.control, "walletConnectButton"))
|
||||
const dialog = findChild(fixture.control, "createWalletDialog")
|
||||
tryCompare(dialog, "opened", true)
|
||||
findChild(dialog, "walletPasswordField").text = "secret"
|
||||
findChild(dialog, "walletConfirmPasswordField").text = "secret"
|
||||
findChild(dialog, "createWalletButton").clicked()
|
||||
compare(fixture.backend.createCalls, 1)
|
||||
compare(dialog.mnemonic, "")
|
||||
compare(dialog.errorText, "Wallet could not be created.")
|
||||
verify(dialog.opened)
|
||||
}
|
||||
|
||||
function test_warnsWhenCreatedWalletCannotRefresh() {
|
||||
const fixture = createControl({
|
||||
walletExists: false,
|
||||
createWalletRefreshFails: true
|
||||
}, [])
|
||||
mouseClick(findChild(fixture.control, "walletConnectButton"))
|
||||
const dialog = findChild(fixture.control, "createWalletDialog")
|
||||
tryCompare(dialog, "opened", true)
|
||||
findChild(dialog, "walletPasswordField").text = "secret"
|
||||
findChild(dialog, "walletConfirmPasswordField").text = "secret"
|
||||
findChild(dialog, "createWalletButton").clicked()
|
||||
tryCompare(dialog, "mnemonic", "alpha beta gamma")
|
||||
const message = findChild(fixture.control, "walletMessageDialog")
|
||||
verify(!message.opened)
|
||||
mouseClick(findChild(dialog, "walletBackupAcknowledgement"))
|
||||
mouseClick(findChild(dialog, "walletContinueButton"))
|
||||
|
||||
tryCompare(message, "opened", true)
|
||||
compare(message.message,
|
||||
"Wallet was created, but could not be refreshed. Reconnect the wallet to refresh it.")
|
||||
}
|
||||
|
||||
function test_clampsSelectionAndDisconnectsLocally() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true },
|
||||
@@ -173,12 +304,12 @@ Item {
|
||||
fixture.control.selectedIndex = 1
|
||||
compare(fixture.control.selectedAddress, "b".repeat(64))
|
||||
fixture.model.clear()
|
||||
tryCompare(fixture.control, "selectedIndex", 0)
|
||||
tryCompare(fixture.control, "selectedIndex", -1)
|
||||
compare(fixture.control.selectedAddress, "")
|
||||
|
||||
fixture.model.append({
|
||||
fixture.model.append(accountData({
|
||||
name: "One", address: "a".repeat(64), balance: "10", isPublic: true
|
||||
})
|
||||
}))
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
const disconnectButton = findChild(fixture.control, "walletDisconnectButton")
|
||||
tryVerify(function() { return disconnectButton.visible })
|
||||
@@ -187,6 +318,31 @@ Item {
|
||||
tryCompare(fixture.control, "connected", false)
|
||||
}
|
||||
|
||||
function test_waitsForPrimaryDelegateBeforeShowingAccountType() {
|
||||
const address = "a".repeat(64)
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
primaryAccountAddress: address,
|
||||
primaryAccountName: "Primary"
|
||||
}, [])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
|
||||
const accountType = findChild(fixture.control, "walletPrimaryAccountType")
|
||||
verify(accountType, "Primary account type exists")
|
||||
verify(!accountType.visible, "Account type waits for its selected delegate")
|
||||
|
||||
fixture.model.append(accountData({
|
||||
name: "Primary",
|
||||
address: address,
|
||||
balance: "10",
|
||||
isPublic: true,
|
||||
isPrimary: true
|
||||
}))
|
||||
tryCompare(fixture.control, "selectedAddress", address)
|
||||
tryCompare(accountType, "visible", true)
|
||||
compare(accountType.text, "Public user account")
|
||||
}
|
||||
|
||||
function test_connectedButtonClosesOpenMenu() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
@@ -200,6 +356,53 @@ Item {
|
||||
tryCompare(menu, "opened", false)
|
||||
}
|
||||
|
||||
function test_walletMenuClosesWithEscape() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
])
|
||||
const accountButton = findChild(fixture.control, "walletAccountButton")
|
||||
const menu = findChild(fixture.control, "walletMenu")
|
||||
|
||||
mouseClick(accountButton)
|
||||
tryCompare(menu, "opened", true)
|
||||
keyClick(Qt.Key_Escape)
|
||||
tryCompare(menu, "opened", false)
|
||||
}
|
||||
|
||||
function test_createAccountDialogOwnsKeyboardFocus() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
|
||||
const backButton = findChild(fixture.control, "walletAccountsBackButton")
|
||||
const addButton = findChild(fixture.control, "walletAddAccountButton")
|
||||
verify(backButton && addButton, "Account controls exist")
|
||||
mouseClick(addButton)
|
||||
|
||||
const dialog = findChild(fixture.control, "createAccountDialog")
|
||||
const privateSwitch = findChild(dialog, "privateAccountSwitch")
|
||||
tryCompare(dialog, "opened", true)
|
||||
tryVerify(function() { return privateSwitch.activeFocus })
|
||||
for (let index = 0; index < 6; ++index) {
|
||||
keyClick(Qt.Key_Tab)
|
||||
verify(!backButton.activeFocus, "Focus remains inside the dialog")
|
||||
}
|
||||
keyClick(Qt.Key_Escape)
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_walletMessageDialogClosesWithEscape() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [])
|
||||
const dialog = findChild(fixture.control, "walletMessageDialog")
|
||||
|
||||
dialog.open()
|
||||
tryCompare(dialog, "opened", true)
|
||||
keyClick(Qt.Key_Escape)
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_openMenuTracksControlMovement() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
@@ -226,8 +429,20 @@ Item {
|
||||
|
||||
function test_selectsAccount() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true },
|
||||
{ name: "Two", address: "b".repeat(64), balance: "20", isPublic: false }
|
||||
{
|
||||
name: "One",
|
||||
address: "a".repeat(64),
|
||||
displayAddress: "base58-one",
|
||||
balance: "10",
|
||||
isPublic: true
|
||||
},
|
||||
{
|
||||
name: "Two",
|
||||
address: "b".repeat(64),
|
||||
displayAddress: "base58-two",
|
||||
balance: "20",
|
||||
isPublic: false
|
||||
}
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
const accountsButton = findChild(fixture.control, "walletAccountsButton")
|
||||
@@ -238,9 +453,292 @@ Item {
|
||||
tryCompare(accountList, "count", 2)
|
||||
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
|
||||
const secondAccount = accountList.itemAtIndex(1)
|
||||
secondAccount.clicked()
|
||||
mouseClick(secondAccount)
|
||||
tryCompare(fixture.control, "selectedIndex", 1)
|
||||
compare(fixture.backend.primaryAccountAddress, "b".repeat(64))
|
||||
compare(fixture.control.selectedAddress, "b".repeat(64))
|
||||
compare(fixture.control.selectedDisplayAddress, "base58-two")
|
||||
}
|
||||
|
||||
function test_flatWalletActionsUseReadableForeground() {
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
assets: [{
|
||||
name: "Available",
|
||||
balance: "0",
|
||||
definitionId: "c".repeat(64),
|
||||
displayDefinitionId: "base58-available",
|
||||
status: "ready",
|
||||
section: "available"
|
||||
}]
|
||||
}, [
|
||||
{
|
||||
name: "One",
|
||||
address: "a".repeat(64),
|
||||
balance: "10",
|
||||
isPublic: true,
|
||||
isPrimary: true
|
||||
},
|
||||
{
|
||||
name: "Two",
|
||||
address: "b".repeat(64),
|
||||
balance: "20",
|
||||
isPublic: true
|
||||
}
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
|
||||
const available = findChild(fixture.control, "walletAvailableAssetsButton")
|
||||
tryVerify(function() { return available && available.visible })
|
||||
compare(available.flat, true)
|
||||
compare(available.palette.windowText, "#d4d4d8")
|
||||
compare(available.contentItem.color, "#d4d4d8")
|
||||
mouseClick(available)
|
||||
tryCompare(fixture.control, "availableExpanded", true)
|
||||
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
const advanced = findChild(fixture.control, "walletAdvancedAccountsButton")
|
||||
tryVerify(function() { return advanced && advanced.visible })
|
||||
compare(advanced.flat, true)
|
||||
compare(advanced.palette.windowText, "#d4d4d8")
|
||||
compare(advanced.contentItem.color, "#d4d4d8")
|
||||
|
||||
const accountList = findChild(fixture.control, "walletAccountList")
|
||||
tryVerify(function() { return accountList.itemAtIndex(1) !== null })
|
||||
const secondAccount = accountList.itemAtIndex(1)
|
||||
const rename = findChild(secondAccount, "walletRenameButton")
|
||||
const makePrimary = findChild(secondAccount, "walletMakePrimaryButton")
|
||||
verify(rename && makePrimary, "Account action buttons exist")
|
||||
for (const action of [rename, makePrimary]) {
|
||||
compare(action.flat, true)
|
||||
compare(action.palette.windowText, "#d4d4d8")
|
||||
compare(action.contentItem.color, "#d4d4d8")
|
||||
}
|
||||
}
|
||||
|
||||
function test_tokenAssetsRenderInBoxes() {
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
assets: [
|
||||
{
|
||||
name: "Held token",
|
||||
balance: "42",
|
||||
definitionId: "c".repeat(64),
|
||||
displayDefinitionId: "base58-held-token",
|
||||
status: "ready",
|
||||
section: "assets"
|
||||
},
|
||||
{
|
||||
name: "Available token",
|
||||
balance: "0",
|
||||
definitionId: "d".repeat(64),
|
||||
displayDefinitionId: "base58-available-token",
|
||||
status: "ready",
|
||||
section: "available"
|
||||
}
|
||||
]
|
||||
}, [])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
|
||||
const heldRepeater = findChild(fixture.control, "walletAssetRepeater")
|
||||
verify(heldRepeater, "Held token repeater exists")
|
||||
let held = null
|
||||
tryVerify(function() {
|
||||
held = heldRepeater.itemAt(0)
|
||||
return held !== null
|
||||
})
|
||||
verify(held, "Held token box exists")
|
||||
tryCompare(held, "visible", true)
|
||||
compare(held.implicitHeight, 68)
|
||||
compare(held.radius, 10)
|
||||
compare(held.border.width, 1)
|
||||
compare(held.border.color, "#3f3f46")
|
||||
|
||||
const availableRepeater = findChild(fixture.control, "walletAvailableAssetRepeater")
|
||||
verify(availableRepeater, "Available token repeater exists")
|
||||
let available = null
|
||||
tryVerify(function() {
|
||||
available = availableRepeater.itemAt(1)
|
||||
return available !== null
|
||||
})
|
||||
verify(available, "Available token box exists")
|
||||
compare(available.visible, false)
|
||||
mouseClick(findChild(fixture.control, "walletAvailableAssetsButton"))
|
||||
tryCompare(available, "visible", true)
|
||||
compare(available.implicitHeight, 64)
|
||||
compare(available.radius, 10)
|
||||
compare(available.border.width, 1)
|
||||
compare(available.border.color, "#3f3f46")
|
||||
}
|
||||
|
||||
function test_usesExplicitPortfolioAndNetworkProviders() {
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
activeNetwork: "wallet network",
|
||||
networkStatus: "error",
|
||||
assetStatus: "ready",
|
||||
assets: [{
|
||||
name: "Wallet available token",
|
||||
balance: "0",
|
||||
definitionId: "a".repeat(64),
|
||||
status: "ready",
|
||||
section: "available"
|
||||
}]
|
||||
}, [])
|
||||
const portfolio = createTemporaryObject(portfolioComponent, root, {
|
||||
assetStatus: "loading",
|
||||
assets: [{
|
||||
name: "Portfolio token",
|
||||
balance: "42",
|
||||
definitionId: "b".repeat(64),
|
||||
status: "ready",
|
||||
section: "assets"
|
||||
}]
|
||||
})
|
||||
const network = createTemporaryObject(networkComponent, root, {
|
||||
activeNetwork: "shared testnet",
|
||||
networkStatus: "loading"
|
||||
})
|
||||
verify(portfolio && network, "Shared providers exist")
|
||||
|
||||
fixture.control.portfolio = portfolio
|
||||
fixture.control.network = network
|
||||
|
||||
compare(fixture.control.portfolioProvider, portfolio)
|
||||
compare(fixture.control.networkProvider, network)
|
||||
compare(fixture.control.walletAssets[0].name, "Portfolio token")
|
||||
compare(fixture.control.assetStatus, "loading")
|
||||
compare(fixture.control.activeNetwork, "shared testnet")
|
||||
compare(fixture.control.networkStatus, "loading")
|
||||
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
const indicator = findChild(fixture.control, "walletNetworkStatusIndicator")
|
||||
const networkName = findChild(fixture.control, "walletNetworkName")
|
||||
const loading = findChild(fixture.control, "walletAssetsLoadingLabel")
|
||||
const heldAssets = findChild(fixture.control, "walletAssetRepeater")
|
||||
verify(indicator && networkName && loading && heldAssets, "Provider UI exists")
|
||||
tryCompare(indicator, "color", "#f59e0b")
|
||||
tryCompare(networkName, "text", "shared testnet")
|
||||
tryCompare(loading, "visible", true)
|
||||
tryCompare(heldAssets, "count", 1)
|
||||
tryCompare(heldAssets.itemAt(0), "visible", true)
|
||||
}
|
||||
|
||||
function test_accountNavigationKeepsOverviewInsidePopup() {
|
||||
const assets = []
|
||||
for (let index = 0; index < 10; ++index) {
|
||||
assets.push({
|
||||
name: "Token " + index,
|
||||
balance: "100",
|
||||
definitionId: "c".repeat(64),
|
||||
displayDefinitionId: "base58-token-" + index,
|
||||
status: "ready",
|
||||
section: "assets"
|
||||
})
|
||||
}
|
||||
const fixture = createControl({ isWalletOpen: true, assets: assets }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
const stack = findChild(fixture.control, "walletStack")
|
||||
verify(stack, "Wallet stack exists")
|
||||
verify(stack.clip, "Wallet pages are clipped to the popup")
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
tryCompare(stack, "busy", false)
|
||||
compare(stack.depth, 2)
|
||||
|
||||
mouseClick(findChild(fixture.control, "walletAccountsBackButton"))
|
||||
tryCompare(stack, "busy", false)
|
||||
compare(stack.depth, 1)
|
||||
compare(stack.currentItem.x, 0)
|
||||
const overviewContent = findChild(fixture.control, "walletOverviewContent")
|
||||
verify(overviewContent, "Wallet overview content exists")
|
||||
compare(overviewContent.mapToItem(stack, 0, 0).x, 0)
|
||||
}
|
||||
|
||||
function test_programRecordCannotBecomePrimary() {
|
||||
const userAddress = "a".repeat(64)
|
||||
const programAddress = "c".repeat(64)
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
primaryAccountAddress: userAddress,
|
||||
primaryAccountName: "Trading"
|
||||
}, [
|
||||
{
|
||||
name: "Trading",
|
||||
address: userAddress,
|
||||
balance: "10",
|
||||
isPublic: true,
|
||||
kind: "user",
|
||||
isPrimary: true
|
||||
},
|
||||
{
|
||||
name: "Token definition",
|
||||
address: programAddress,
|
||||
balance: "0",
|
||||
isPublic: true,
|
||||
kind: "token_definition",
|
||||
section: "advanced",
|
||||
programName: "Token",
|
||||
accountType: "TokenDefinition",
|
||||
canBePrimary: false
|
||||
}
|
||||
])
|
||||
compare(fixture.control.selectedAddress, userAddress)
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton"))
|
||||
const list = findChild(fixture.control, "walletAccountList")
|
||||
tryVerify(function() { return list.itemAtIndex(1) !== null })
|
||||
mouseClick(list.itemAtIndex(1))
|
||||
compare(fixture.backend.primaryAccountCalls, 0)
|
||||
compare(fixture.control.selectedAddress, userAddress)
|
||||
}
|
||||
|
||||
function test_advancedShowsProgramAndDecodedData() {
|
||||
const decodedData = "{\n \"name\": \"Test token\"\n}"
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{
|
||||
name: "Token definition",
|
||||
address: "c".repeat(64),
|
||||
balance: "0",
|
||||
isPublic: true,
|
||||
kind: "token_definition",
|
||||
section: "advanced",
|
||||
programName: "Token",
|
||||
accountType: "TokenDefinition",
|
||||
decodedData: decodedData,
|
||||
canBePrimary: false
|
||||
}
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAdvancedAccountsButton"))
|
||||
|
||||
const list = findChild(fixture.control, "walletAccountList")
|
||||
tryVerify(function() { return list.itemAtIndex(0) !== null })
|
||||
const program = findChild(list.itemAtIndex(0), "walletProgramName")
|
||||
const decoded = findChild(list.itemAtIndex(0), "walletDecodedData")
|
||||
verify(program && decoded, "Advanced details exist")
|
||||
tryCompare(program, "visible", true)
|
||||
compare(program.text, "Program: Token")
|
||||
tryCompare(decoded, "visible", true)
|
||||
compare(decoded.text, decodedData)
|
||||
}
|
||||
|
||||
function test_onlyProgramRecordsLeavesPrimaryEmpty() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [{
|
||||
name: "Token definition",
|
||||
address: "c".repeat(64),
|
||||
balance: "0",
|
||||
isPublic: true,
|
||||
kind: "token_definition",
|
||||
section: "advanced",
|
||||
canBePrimary: false
|
||||
}])
|
||||
compare(fixture.control.selectedIndex, -1)
|
||||
compare(fixture.control.selectedAddress, "")
|
||||
compare(fixture.control.primaryName, "")
|
||||
}
|
||||
|
||||
function test_createsAccount() {
|
||||
@@ -262,6 +760,47 @@ Item {
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_createsPrivateAccount() {
|
||||
const fixture = createControl({ isWalletOpen: true }, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
const addButton = findChild(fixture.control, "walletAddAccountButton")
|
||||
tryVerify(function() { return addButton.visible })
|
||||
addButton.clicked()
|
||||
const dialog = findChild(fixture.control, "createAccountDialog")
|
||||
tryCompare(dialog, "opened", true)
|
||||
mouseClick(findChild(dialog, "privateAccountSwitch"))
|
||||
findChild(dialog, "createAccountButton").clicked()
|
||||
compare(fixture.backend.privateAccountCalls, 1)
|
||||
tryCompare(dialog, "opened", false)
|
||||
}
|
||||
|
||||
function test_warnsWhenCreatedAccountCannotRefresh() {
|
||||
const fixture = createControl({
|
||||
isWalletOpen: true,
|
||||
walletSyncStatus: "ready",
|
||||
accountRefreshFails: true
|
||||
}, [
|
||||
{ name: "One", address: "a".repeat(64), balance: "10", isPublic: true }
|
||||
])
|
||||
mouseClick(findChild(fixture.control, "walletAccountButton"))
|
||||
mouseClick(findChild(fixture.control, "walletAccountsButton"))
|
||||
const addButton = findChild(fixture.control, "walletAddAccountButton")
|
||||
tryVerify(function() { return addButton.visible })
|
||||
addButton.clicked()
|
||||
const dialog = findChild(fixture.control, "createAccountDialog")
|
||||
tryCompare(dialog, "opened", true)
|
||||
findChild(dialog, "createAccountButton").clicked()
|
||||
tryCompare(dialog, "opened", false)
|
||||
|
||||
const message = findChild(fixture.control, "walletMessageDialog")
|
||||
tryCompare(message, "opened", true)
|
||||
compare(message.message,
|
||||
"Account was created, but could not be refreshed. Reconnect the wallet to refresh it.")
|
||||
}
|
||||
|
||||
function test_compactLayoutHasStableWidth() {
|
||||
const fixture = createControl({ isWalletOpen: false }, [])
|
||||
fixture.control.viewportWidth = 480
|
||||
@@ -300,12 +839,12 @@ Item {
|
||||
const model = createTemporaryObject(modelComponent, root)
|
||||
verify(backend && model, "Wallet fixture exists")
|
||||
for (let index = 0; index < 10; ++index) {
|
||||
model.append({
|
||||
model.append(accountData({
|
||||
name: "Account " + index,
|
||||
address: String(index).repeat(64),
|
||||
balance: String(index),
|
||||
isPublic: true
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const window = createTemporaryObject(compactWindowComponent, root)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "WalletProvider.h"
|
||||
|
||||
class FakeWalletProvider final : public WalletProvider {
|
||||
@@ -23,6 +25,9 @@ public:
|
||||
bool lastAccountWasPublic = false;
|
||||
WalletPaths lastPaths;
|
||||
WalletTransaction lastTransaction;
|
||||
bool deferAsync = false;
|
||||
SessionCallback pendingConnectCallback;
|
||||
SnapshotCallback pendingSnapshotCallback;
|
||||
|
||||
WalletSession connect(const WalletPaths& paths) override
|
||||
{
|
||||
@@ -31,6 +36,16 @@ public:
|
||||
return connectResult;
|
||||
}
|
||||
|
||||
void connectAsync(const WalletPaths& paths, SessionCallback callback) override
|
||||
{
|
||||
++connectCalls;
|
||||
lastPaths = paths;
|
||||
if (deferAsync)
|
||||
pendingConnectCallback = std::move(callback);
|
||||
else
|
||||
callback(connectResult);
|
||||
}
|
||||
|
||||
WalletCreation createWallet(const WalletPaths& paths,
|
||||
const QString&) override
|
||||
{
|
||||
@@ -46,6 +61,16 @@ public:
|
||||
return snapshotResult;
|
||||
}
|
||||
|
||||
void snapshotAsync(bool forceRefresh, SnapshotCallback callback) override
|
||||
{
|
||||
++snapshotCalls;
|
||||
lastForceRefresh = forceRefresh;
|
||||
if (deferAsync)
|
||||
pendingSnapshotCallback = std::move(callback);
|
||||
else
|
||||
callback(snapshotResult);
|
||||
}
|
||||
|
||||
void clearSnapshot() override { ++clearCalls; }
|
||||
|
||||
WalletAccountCreation createAccount(bool isPublic) override
|
||||
@@ -72,4 +97,19 @@ public:
|
||||
}
|
||||
|
||||
void disconnect() override { ++disconnectCalls; }
|
||||
|
||||
void finishConnect()
|
||||
{
|
||||
SessionCallback callback = std::move(pendingConnectCallback);
|
||||
if (callback)
|
||||
callback(connectResult);
|
||||
}
|
||||
|
||||
void finishSnapshot()
|
||||
{
|
||||
SnapshotCallback callback = std::move(pendingSnapshotCallback);
|
||||
if (callback)
|
||||
callback(snapshotResult);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user