Internal
Public Access
Wire up CTest and add unit test coverage for ProfileRepository
Adds a Qt6::Test-based unit test target (tests/test_profile_repository.cpp, 21 cases), gated behind an ORBITHUB_BUILD_TESTS option that no-ops gracefully if Qt6::Test isn't available, so it can't break app-only builds. Covers profile CRUD, validation rules, search/sort, tag normalization, and folder handling, each against an isolated temporary SQLite file (new ProfileRepository(databasePathOverride) constructor overload added for exactly this). Caught and fixed a real bug along the way: normalizedTags()'s result was bound directly without the nonNullTrimmed() null-guard every other field already uses, so creating a profile with no tags at all hit the `tags NOT NULL` constraint and silently failed -- including via the Import Profiles feature for any export where a profile has no tags key. Partial progress on #1 (RdpSessionBackend/SshSessionBackend state-machine coverage still open -- much larger lift, needs a testability pass on those backends first). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql)
|
|||||||
|
|
||||||
qt_standard_project_setup()
|
qt_standard_project_setup()
|
||||||
|
|
||||||
|
option(ORBITHUB_BUILD_TESTS "Build unit tests (requires Qt6::Test)" ON)
|
||||||
|
if(ORBITHUB_BUILD_TESTS)
|
||||||
|
find_package(Qt6 6.2 QUIET COMPONENTS Test)
|
||||||
|
if(TARGET Qt6::Test)
|
||||||
|
enable_testing()
|
||||||
|
else()
|
||||||
|
message(STATUS "Qt6::Test not found -- skipping unit tests (set ORBITHUB_BUILD_TESTS=OFF to silence this)")
|
||||||
|
set(ORBITHUB_BUILD_TESTS OFF)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
add_subdirectory(third_party/KodoTerm)
|
add_subdirectory(third_party/KodoTerm)
|
||||||
|
|
||||||
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/FreeRDP/CMakeLists.txt")
|
||||||
@@ -272,3 +283,7 @@ install(FILES third_party/KodoTerm/LICENSE
|
|||||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/org.darksingularity.OrbitHub
|
||||||
RENAME LICENSE-KodoTerm
|
RENAME LICENSE-KodoTerm
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(ORBITHUB_BUILD_TESTS)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
std::atomic<int> g_testConnectionCounter{0};
|
||||||
|
|
||||||
QString buildDatabasePath()
|
QString buildDatabasePath()
|
||||||
{
|
{
|
||||||
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
@@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
|
|||||||
: QStringLiteral("Negotiate"));
|
: QStringLiteral("Negotiate"));
|
||||||
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile)
|
||||||
: QStringLiteral("Balanced"));
|
: QStringLiteral("Balanced"));
|
||||||
query.addBindValue(normalizedTags(profile.tags));
|
query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile profileFromQuery(const QSqlQuery& query)
|
Profile profileFromQuery(const QSqlQuery& query)
|
||||||
@@ -259,6 +263,16 @@ ProfileRepository::ProfileRepository() : m_connectionName(QStringLiteral("orbith
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProfileRepository::ProfileRepository(const QString& databasePathOverride)
|
||||||
|
: m_connectionName(QStringLiteral("orbithub_test_%1")
|
||||||
|
.arg(g_testConnectionCounter.fetch_add(1))),
|
||||||
|
m_databasePathOverride(databasePathOverride)
|
||||||
|
{
|
||||||
|
if (!initializeDatabase()) {
|
||||||
|
QSqlDatabase::removeDatabase(m_connectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ProfileRepository::~ProfileRepository()
|
ProfileRepository::~ProfileRepository()
|
||||||
{
|
{
|
||||||
if (QSqlDatabase::contains(m_connectionName)) {
|
if (QSqlDatabase::contains(m_connectionName)) {
|
||||||
@@ -485,7 +499,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const
|
|||||||
bool ProfileRepository::initializeDatabase()
|
bool ProfileRepository::initializeDatabase()
|
||||||
{
|
{
|
||||||
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||||
database.setDatabaseName(buildDatabasePath());
|
database.setDatabaseName(
|
||||||
|
m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride);
|
||||||
|
|
||||||
if (!database.open()) {
|
if (!database.open()) {
|
||||||
m_initError = database.lastError().text();
|
m_initError = database.lastError().text();
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class ProfileRepository
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
ProfileRepository();
|
ProfileRepository();
|
||||||
|
// databasePathOverride lets tests point the repository at an isolated,
|
||||||
|
// disposable SQLite file instead of the real app-data location.
|
||||||
|
explicit ProfileRepository(const QString& databasePathOverride);
|
||||||
~ProfileRepository();
|
~ProfileRepository();
|
||||||
|
|
||||||
QString initError() const;
|
QString initError() const;
|
||||||
@@ -51,6 +54,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
QString m_connectionName;
|
QString m_connectionName;
|
||||||
|
QString m_databasePathOverride;
|
||||||
QString m_initError;
|
QString m_initError;
|
||||||
mutable QString m_lastError;
|
mutable QString m_lastError;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
add_executable(test_profile_repository
|
||||||
|
test_profile_repository.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/profile_repository.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src)
|
||||||
|
target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test)
|
||||||
|
add_test(NAME test_profile_repository COMMAND test_profile_repository)
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#include "profile_repository.h"
|
||||||
|
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
Profile makeSshProfile(const QString& name = QStringLiteral("Prod SSH Box"))
|
||||||
|
{
|
||||||
|
Profile profile;
|
||||||
|
profile.name = name;
|
||||||
|
profile.host = QStringLiteral("prod.example.com");
|
||||||
|
profile.port = 22;
|
||||||
|
profile.username = QStringLiteral("deploy");
|
||||||
|
profile.protocol = QStringLiteral("SSH");
|
||||||
|
profile.authMode = QStringLiteral("Password");
|
||||||
|
profile.tags = QStringLiteral("prod,linux");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile makeRdpProfile(const QString& name = QStringLiteral("Windows RDP Box"))
|
||||||
|
{
|
||||||
|
Profile profile;
|
||||||
|
profile.name = name;
|
||||||
|
profile.host = QStringLiteral("win.example.com");
|
||||||
|
profile.port = 3389;
|
||||||
|
profile.username = QStringLiteral("admin");
|
||||||
|
profile.domain = QStringLiteral("CORP");
|
||||||
|
profile.protocol = QStringLiteral("RDP");
|
||||||
|
profile.rdpSecurityMode = QStringLiteral("NLA");
|
||||||
|
profile.rdpPerformanceProfile = QStringLiteral("Best Performance");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestProfileRepository : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void init();
|
||||||
|
void cleanup();
|
||||||
|
|
||||||
|
void initializesCleanly();
|
||||||
|
void createAndGetSshProfile();
|
||||||
|
void createAndGetRdpProfile();
|
||||||
|
void createProfileRejectsMissingName();
|
||||||
|
void createProfileRejectsMissingHost();
|
||||||
|
void createProfileRejectsInvalidPort();
|
||||||
|
void createProfileRejectsMissingUsernameForSsh();
|
||||||
|
void createProfileRejectsMissingPrivateKeyForKeyAuth();
|
||||||
|
void createProfileRejectsDuplicateName();
|
||||||
|
void updateProfilePersistsChanges();
|
||||||
|
void deleteProfileRemovesIt();
|
||||||
|
void getProfileReturnsNulloptForUnknownId();
|
||||||
|
void listProfilesFiltersBySearchQuery();
|
||||||
|
void listProfilesSortsByRequestedOrder();
|
||||||
|
void tagsAreTrimmedDedupedAndJoined();
|
||||||
|
void emptyTagsRoundTripAsEmpty();
|
||||||
|
void folderCreateAndListRoundTrips();
|
||||||
|
void folderCreateIgnoresDuplicates();
|
||||||
|
void folderPathIsNormalized();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unique_ptr<QTemporaryDir> m_tempDir;
|
||||||
|
std::unique_ptr<ProfileRepository> m_repo;
|
||||||
|
};
|
||||||
|
|
||||||
|
void TestProfileRepository::init()
|
||||||
|
{
|
||||||
|
m_tempDir = std::make_unique<QTemporaryDir>();
|
||||||
|
QVERIFY(m_tempDir->isValid());
|
||||||
|
m_repo = std::make_unique<ProfileRepository>(m_tempDir->filePath(QStringLiteral("test.sqlite")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::cleanup()
|
||||||
|
{
|
||||||
|
m_repo.reset();
|
||||||
|
m_tempDir.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::initializesCleanly()
|
||||||
|
{
|
||||||
|
QCOMPARE(m_repo->initError(), QString());
|
||||||
|
QCOMPARE(m_repo->listProfiles().size(), size_t(0));
|
||||||
|
QCOMPARE(m_repo->listFolders().size(), size_t(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createAndGetSshProfile()
|
||||||
|
{
|
||||||
|
const Profile input = makeSshProfile();
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(input);
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
QVERIFY(created->id > 0);
|
||||||
|
|
||||||
|
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||||
|
QVERIFY(fetched.has_value());
|
||||||
|
QCOMPARE(fetched->name, input.name);
|
||||||
|
QCOMPARE(fetched->host, input.host);
|
||||||
|
QCOMPARE(fetched->port, input.port);
|
||||||
|
QCOMPARE(fetched->username, input.username);
|
||||||
|
QCOMPARE(fetched->protocol, QStringLiteral("SSH"));
|
||||||
|
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
|
||||||
|
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createAndGetRdpProfile()
|
||||||
|
{
|
||||||
|
const Profile input = makeRdpProfile();
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(input);
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
|
||||||
|
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||||
|
QVERIFY(fetched.has_value());
|
||||||
|
QCOMPARE(fetched->protocol, QStringLiteral("RDP"));
|
||||||
|
QCOMPARE(fetched->domain, QStringLiteral("CORP"));
|
||||||
|
QCOMPARE(fetched->rdpSecurityMode, QStringLiteral("NLA"));
|
||||||
|
QCOMPARE(fetched->rdpPerformanceProfile, QStringLiteral("Best Performance"));
|
||||||
|
QCOMPARE(fetched->port, 3389);
|
||||||
|
// Auth-mode/private-key fields are SSH-only and must not leak through
|
||||||
|
// for a non-SSH protocol.
|
||||||
|
QCOMPARE(fetched->authMode, QStringLiteral("Password"));
|
||||||
|
QCOMPARE(fetched->privateKeyPath, QString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsMissingName()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.name.clear();
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
QVERIFY(!m_repo->lastError().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsMissingHost()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.host.clear();
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
QVERIFY(!m_repo->lastError().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsInvalidPort()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.port = 0;
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
|
||||||
|
profile.port = 70000;
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsMissingUsernameForSsh()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.username.clear();
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsMissingPrivateKeyForKeyAuth()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.authMode = QStringLiteral("Private Key");
|
||||||
|
profile.privateKeyPath.clear();
|
||||||
|
QVERIFY(!m_repo->createProfile(profile).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::createProfileRejectsDuplicateName()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
|
||||||
|
QVERIFY(!m_repo->createProfile(makeSshProfile(QStringLiteral("Same Name"))).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::updateProfilePersistsChanges()
|
||||||
|
{
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
|
||||||
|
Profile updated = created.value();
|
||||||
|
updated.host = QStringLiteral("new-host.example.com");
|
||||||
|
updated.port = 2222;
|
||||||
|
updated.tags = QStringLiteral("updated");
|
||||||
|
QVERIFY(m_repo->updateProfile(updated));
|
||||||
|
|
||||||
|
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||||
|
QVERIFY(fetched.has_value());
|
||||||
|
QCOMPARE(fetched->host, QStringLiteral("new-host.example.com"));
|
||||||
|
QCOMPARE(fetched->port, 2222);
|
||||||
|
QCOMPARE(fetched->tags, QStringLiteral("updated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::deleteProfileRemovesIt()
|
||||||
|
{
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(makeSshProfile());
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
QVERIFY(m_repo->deleteProfile(created->id));
|
||||||
|
QVERIFY(!m_repo->getProfile(created->id).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::getProfileReturnsNulloptForUnknownId()
|
||||||
|
{
|
||||||
|
QVERIFY(!m_repo->getProfile(999999).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::listProfilesFiltersBySearchQuery()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Alpha"))).has_value());
|
||||||
|
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Beta"))).has_value());
|
||||||
|
|
||||||
|
const auto byName = m_repo->listProfiles(QStringLiteral("Alpha"));
|
||||||
|
QCOMPARE(byName.size(), size_t(1));
|
||||||
|
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
|
||||||
|
|
||||||
|
const auto byHost = m_repo->listProfiles(QStringLiteral("win.example"));
|
||||||
|
QCOMPARE(byHost.size(), size_t(1));
|
||||||
|
QCOMPARE(byHost[0].name, QStringLiteral("Beta"));
|
||||||
|
|
||||||
|
const auto byTag = m_repo->listProfiles(QStringLiteral("linux"));
|
||||||
|
QCOMPARE(byTag.size(), size_t(1));
|
||||||
|
QCOMPARE(byTag[0].name, QStringLiteral("Alpha"));
|
||||||
|
|
||||||
|
QCOMPARE(m_repo->listProfiles(QStringLiteral("nonexistent")).size(), size_t(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::listProfilesSortsByRequestedOrder()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createProfile(makeSshProfile(QStringLiteral("Zeta"))).has_value());
|
||||||
|
QVERIFY(m_repo->createProfile(makeRdpProfile(QStringLiteral("Alpha"))).has_value());
|
||||||
|
|
||||||
|
const auto byName = m_repo->listProfiles(QString(), ProfileSortOrder::NameAsc);
|
||||||
|
QCOMPARE(byName.size(), size_t(2));
|
||||||
|
QCOMPARE(byName[0].name, QStringLiteral("Alpha"));
|
||||||
|
QCOMPARE(byName[1].name, QStringLiteral("Zeta"));
|
||||||
|
|
||||||
|
const auto byProtocol = m_repo->listProfiles(QString(), ProfileSortOrder::ProtocolAsc);
|
||||||
|
QCOMPARE(byProtocol[0].protocol, QStringLiteral("RDP"));
|
||||||
|
QCOMPARE(byProtocol[1].protocol, QStringLiteral("SSH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::tagsAreTrimmedDedupedAndJoined()
|
||||||
|
{
|
||||||
|
Profile profile = makeSshProfile();
|
||||||
|
profile.tags = QStringLiteral(" prod ,, Prod , linux ,linux");
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(profile);
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
|
||||||
|
// createProfile()'s return value echoes the input as-is; normalization
|
||||||
|
// only happens on the DB round trip, so re-fetch to observe it.
|
||||||
|
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||||
|
QVERIFY(fetched.has_value());
|
||||||
|
// Case-insensitive de-dup keeps the first-seen casing of each tag.
|
||||||
|
QCOMPARE(fetched->tags, QStringLiteral("prod, linux"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::emptyTagsRoundTripAsEmpty()
|
||||||
|
{
|
||||||
|
const std::optional<Profile> created = m_repo->createProfile(makeRdpProfile());
|
||||||
|
QVERIFY(created.has_value());
|
||||||
|
const std::optional<Profile> fetched = m_repo->getProfile(created->id);
|
||||||
|
QVERIFY(fetched.has_value());
|
||||||
|
QCOMPARE(fetched->tags, QString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::folderCreateAndListRoundTrips()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createFolder(QStringLiteral("Work/Servers")));
|
||||||
|
const auto folders = m_repo->listFolders();
|
||||||
|
QCOMPARE(folders.size(), size_t(1));
|
||||||
|
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::folderCreateIgnoresDuplicates()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
|
||||||
|
QVERIFY(m_repo->createFolder(QStringLiteral("Work")));
|
||||||
|
QCOMPARE(m_repo->listFolders().size(), size_t(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestProfileRepository::folderPathIsNormalized()
|
||||||
|
{
|
||||||
|
QVERIFY(m_repo->createFolder(QStringLiteral("\\Work\\\\Servers\\")));
|
||||||
|
const auto folders = m_repo->listFolders();
|
||||||
|
QCOMPARE(folders.size(), size_t(1));
|
||||||
|
QCOMPARE(folders[0], QStringLiteral("Work/Servers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(TestProfileRepository)
|
||||||
|
#include "test_profile_repository.moc"
|
||||||
Reference in New Issue
Block a user