From 3fab2f9de3bedc68fd8239296be7bf4c0bab88ee Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 15 Sep 2026 06:39:20 -0600 Subject: [PATCH] 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 --- CMakeLists.txt | 15 ++ src/profile_repository.cpp | 19 +- src/profile_repository.h | 4 + tests/CMakeLists.txt | 7 + tests/test_profile_repository.cpp | 288 ++++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_profile_repository.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 617bb1b..d643a5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,17 @@ find_package(Qt6 6.2 REQUIRED COMPONENTS Widgets Sql) 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) 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 RENAME LICENSE-KodoTerm ) + +if(ORBITHUB_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/profile_repository.cpp b/src/profile_repository.cpp index 71a2b07..7d8e257 100644 --- a/src/profile_repository.cpp +++ b/src/profile_repository.cpp @@ -9,7 +9,11 @@ #include #include +#include + namespace { +std::atomic g_testConnectionCounter{0}; + QString buildDatabasePath() { QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); @@ -176,7 +180,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile) : QStringLiteral("Negotiate")); query.addBindValue(isRdp ? normalizedRdpPerformanceProfile(profile.rdpPerformanceProfile) : QStringLiteral("Balanced")); - query.addBindValue(normalizedTags(profile.tags)); + query.addBindValue(nonNullTrimmed(normalizedTags(profile.tags))); } 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() { if (QSqlDatabase::contains(m_connectionName)) { @@ -485,7 +499,8 @@ bool ProfileRepository::deleteProfile(qint64 id) const bool ProfileRepository::initializeDatabase() { QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); - database.setDatabaseName(buildDatabasePath()); + database.setDatabaseName( + m_databasePathOverride.isEmpty() ? buildDatabasePath() : m_databasePathOverride); if (!database.open()) { m_initError = database.lastError().text(); diff --git a/src/profile_repository.h b/src/profile_repository.h index 41cde3e..43a8754 100644 --- a/src/profile_repository.h +++ b/src/profile_repository.h @@ -35,6 +35,9 @@ class ProfileRepository { public: 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(); QString initError() const; @@ -51,6 +54,7 @@ public: private: QString m_connectionName; + QString m_databasePathOverride; QString m_initError; mutable QString m_lastError; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..f99255e --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/test_profile_repository.cpp b/tests/test_profile_repository.cpp new file mode 100644 index 0000000..4d533ce --- /dev/null +++ b/tests/test_profile_repository.cpp @@ -0,0 +1,288 @@ +#include "profile_repository.h" + +#include +#include + +#include + +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 m_tempDir; + std::unique_ptr m_repo; +}; + +void TestProfileRepository::init() +{ + m_tempDir = std::make_unique(); + QVERIFY(m_tempDir->isValid()); + m_repo = std::make_unique(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 created = m_repo->createProfile(input); + QVERIFY(created.has_value()); + QVERIFY(created->id > 0); + + const std::optional 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 created = m_repo->createProfile(input); + QVERIFY(created.has_value()); + + const std::optional 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 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 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 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 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 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 created = m_repo->createProfile(makeRdpProfile()); + QVERIFY(created.has_value()); + const std::optional 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"