diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1d020d2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +on: + pull_request: + push: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies from debian/control + run: | + sudo apt-get update + sudo apt-get install --yes devscripts equivs + sudo mk-build-deps -i -r -t "apt-get --yes" debian/control + + - name: Configure + run: cmake -B build -DCMAKE_BUILD_TYPE=Debug + + - name: Build + run: cmake --build build + + - name: Run tests + run: | + cd build + ctest --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index bf41488..c62c053 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,9 @@ endif(Qt5_POSITION_INDEPENDENT_CODE) add_subdirectory(external) add_subdirectory(src) +enable_testing() +add_subdirectory(tests) + include(CPackConfiguration) add_custom_target(install_app_bundle COMMAND ${CMAKE_COMMAND} -P cmake_install.cmake DEPENDS JellyfinMediaPlayer) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d540e71 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Jellyfin Media Player + +## Running Tests + +Jellyfin Media Player uses Qt Test for unit testing. + +### Building Tests + +Tests are built automatically when you build the project: + +```sh +cmake -B build +cmake --build build +``` + +### Running Tests + +Run all tests using CTest: + +```sh +cd build +ctest +``` + +Or run individual test executables directly: + +```sh +cd build +./tests/test_systemcomponent +``` + +### Writing Tests + +Tests are located in the `tests/` directory. To add a new test: + +1. Create a test file in `tests/` (e.g., `test_mycomponent.cpp`) +2. Use `QTEST_APPLESS_MAIN` for headless unit tests +3. Add the test to `tests/CMakeLists.txt` + +Example test structure: + +```cpp +#include +#include "../src/mycomponent/MyComponent.h" + +class TestMyComponent : public QObject +{ + Q_OBJECT + +private slots: + void testMyFunction_data(); + void testMyFunction(); +}; + +void TestMyComponent::testMyFunction_data() +{ + QTest::addColumn("input"); + QTest::addColumn("expected"); + + QTest::newRow("test case 1") << "input1" << "output1"; + QTest::newRow("test case 2") << "input2" << "output2"; +} + +void TestMyComponent::testMyFunction() +{ + QFETCH(QString, input); + QFETCH(QString, expected); + + QString result = MyComponent::myFunction(input); + QCOMPARE(result, expected); +} + +QTEST_APPLESS_MAIN(TestMyComponent) +#include "test_mycomponent.moc" +``` + +For more information on Qt Test, see the [Qt Test documentation](https://doc.qt.io/qt-6/qtest-overview.html). diff --git a/native/connectivityHelper.js b/native/connectivityHelper.js index b601d19..49aea06 100644 --- a/native/connectivityHelper.js +++ b/native/connectivityHelper.js @@ -1,9 +1,10 @@ window.jmpCheckServerConnectivity = (() => { - let checkInProgress = false; + let activeController = null; - return async function(url) { - if (checkInProgress) { - throw new Error('Connectivity check already in progress'); + const checkFunc = async function(url) { + // Abort any in-progress check + if (activeController) { + activeController.abort(); } // Wait for API @@ -16,24 +17,48 @@ window.jmpCheckServerConnectivity = (() => { throw new Error('WebChannel not available'); } - checkInProgress = true; + // Create abort controller for this check + const controller = new AbortController(); + activeController = controller; return new Promise((resolve, reject) => { - const handler = (resultUrl, success) => { - if (resultUrl === url) { + // Handle abort + controller.signal.addEventListener('abort', () => { + if (handler) { window.api.system.serverConnectivityResult.disconnect(handler); - checkInProgress = false; + } + reject(new Error('Connection cancelled')); + }); + + let handler = (resultUrl, success, resolvedUrl) => { + if (resultUrl === url && !controller.signal.aborted) { + window.api.system.serverConnectivityResult.disconnect(handler); + handler = null; + if (activeController === controller) { + activeController = null; + } if (success) { - resolve(); + resolve(resolvedUrl); } else { reject(new Error('Connection failed')); } } }; + window.api.system.serverConnectivityResult.connect(handler); window.api.system.checkServerConnectivity(url); }); }; + + // Expose abort function for cancellation + checkFunc.abort = () => { + if (activeController) { + activeController.abort(); + activeController = null; + } + }; + + return checkFunc; })(); window.jmpFetchPage = (() => { diff --git a/native/find-webclient.js b/native/find-webclient.js index 51266d1..53b03d9 100644 --- a/native/find-webclient.js +++ b/native/find-webclient.js @@ -3,14 +3,18 @@ async function tryConnect(server) { if (!server.startsWith("http")) { server = "http://" + server; } - serverBaseURL = server.replace(/\/+$/, ""); console.log("Checking connectivity to:", server); - await window.jmpCheckServerConnectivity(server); + const resolvedUrl = await window.jmpCheckServerConnectivity(server); console.log("Server connectivity check passed"); + console.log("Resolved URL:", resolvedUrl); + + // Save original URL but navigate to fully-resolved redirect window.jmpInfo.settings.main.userWebClient = server; - window.location = server; + + // Navigation will clean up handlers, but do it explicitly + window.location = resolvedUrl; return true; } catch (e) { @@ -54,16 +58,8 @@ const startConnecting = async () => { button.style.visibility = 'hidden'; document.addEventListener('keydown', cancelOnEscape); - let connected = false; - - while (!connected && isConnecting) { - connected = await tryConnect(server); - - if (!connected && isConnecting) { - // Wait 5 seconds before retrying - await new Promise(resolve => setTimeout(resolve, 5000)); - } - } + // C++ handles retries, just wait for result + const connected = await tryConnect(server); if (!connected) { isConnecting = false; @@ -82,8 +78,17 @@ const startConnecting = async () => { const cancelConnection = () => { if (!isConnecting) return; + console.log("Cancelling connection"); isConnecting = false; + // Cancel C++ connectivity check and abort JS promise + if (window.api && window.api.system) { + window.api.system.cancelServerConnectivity(); + } + if (window.jmpCheckServerConnectivity.abort) { + window.jmpCheckServerConnectivity.abort(); + } + const address = document.getElementById('address'); const title = document.getElementById('title'); const spinner = document.getElementById('spinner'); @@ -160,16 +165,8 @@ document.addEventListener('keydown', (e) => { button.style.visibility = 'hidden'; document.addEventListener('keydown', cancelOnEscape); - let connected = false; - - while (!connected && isConnecting) { - connected = await tryConnect(savedServer); - - if (!connected && isConnecting) { - // Wait 5 seconds before retrying - await new Promise(resolve => setTimeout(resolve, 5000)); - } - } + // C++ handles retries, just wait for result + const connected = await tryConnect(savedServer); if (!connected) { // User cancelled or error - show UI diff --git a/src/system/SystemComponent.cpp b/src/system/SystemComponent.cpp index 8ef486c..ee05934 100644 --- a/src/system/SystemComponent.cpp +++ b/src/system/SystemComponent.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include "input/InputComponent.h" #include "SystemComponent.h" @@ -53,12 +54,19 @@ QMap g_platformArchNames = { /////////////////////////////////////////////////////////////////////////////////////////////////// -SystemComponent::SystemComponent(QObject* parent) : ComponentBase(parent), m_platformType(platformTypeUnknown), m_platformArch(platformArchUnknown), m_doLogMessages(false), m_cursorVisible(true), m_scale(1), m_connectivityCheckReply(nullptr) +SystemComponent::SystemComponent(QObject* parent) : ComponentBase(parent), m_platformType(platformTypeUnknown), m_platformArch(platformArchUnknown), m_doLogMessages(false), m_cursorVisible(true), m_scale(1), m_connectivityCheckReply(nullptr), m_resolveUrlReply(nullptr) { m_mouseOutTimer = new QTimer(this); m_mouseOutTimer->setSingleShot(true); connect(m_mouseOutTimer, &QTimer::timeout, [&] () { setCursorVisibility(false); }); + m_connectivityRetryTimer = new QTimer(this); + m_connectivityRetryTimer->setSingleShot(true); + connect(m_connectivityRetryTimer, &QTimer::timeout, [this]() { + qInfo() << "Retrying connectivity check for" << m_pendingConnectivityUrl; + checkServerConnectivity(m_pendingConnectivityUrl); + }); + m_networkManager = new QNetworkAccessManager(this); // define OS Type @@ -206,104 +214,213 @@ void SystemComponent::jsLog(int level, QString text) } /////////////////////////////////////////////////////////////////////////////////////////////////// -void SystemComponent::checkServerConnectivity(QString url) +QString SystemComponent::extractBaseUrl(const QString& url) { - // Ensure URL ends without trailing slash - QString baseUrl = url; - while (baseUrl.endsWith("/")) { - baseUrl.chop(1); + QUrl parsedUrl(url); + QString path = parsedUrl.path(); + + // Find last occurrence of "/web" in path only (case-insensitive) + int webIndex = path.toLower().lastIndexOf("/web"); + + if (webIndex >= 0) { + // Truncate path at /web + path = path.left(webIndex); + + // Build URL without fragment/query + QString result = parsedUrl.scheme() + "://" + parsedUrl.host(); + int port = parsedUrl.port(); + if (port != -1 && + !((parsedUrl.scheme() == "https" && port == 443) || + (parsedUrl.scheme() == "http" && port == 80))) { + result += ":" + QString::number(port); + } + result += path; + return result; } - QString checkUrl = baseUrl + "/System/Info/Public"; + // Fallback to origin (scheme://host:port, excluding default ports and userinfo) + QString origin = parsedUrl.scheme() + "://" + parsedUrl.host(); + int port = parsedUrl.port(); + // Only add port if non-default (443 for https, 80 for http) + if (port != -1 && + !((parsedUrl.scheme() == "https" && port == 443) || + (parsedUrl.scheme() == "http" && port == 80))) { + origin += ":" + QString::number(port); + } + return origin; +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +QSslConfiguration SystemComponent::getSSLConfiguration() +{ + QSslConfiguration sslConfig; + if (SettingsComponent::Get().ignoreSSLErrors()) { + sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone); + } else if (SettingsComponent::Get().autodetectCertBundle()) { + QString certPath = SettingsComponent::Get().detectCertBundlePath(); + if (!certPath.isEmpty()) { + QList certs = QSslCertificate::fromPath(certPath); + if (!certs.isEmpty()) { + sslConfig.setCaCertificates(certs); + } + } + } + return sslConfig; +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void SystemComponent::setReplyTimeout(QNetworkReply* reply, int ms) +{ + QTimer::singleShot(ms, this, [reply]() { + if (!reply->isFinished()) { + reply->abort(); + } + }); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void SystemComponent::resolveUrl(const QString& url, std::function callback) +{ + // Abort any pending resolve + if (m_resolveUrlReply) { + m_resolveUrlReply->abort(); + m_resolveUrlReply->deleteLater(); + m_resolveUrlReply = nullptr; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, getUserAgent()); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setSslConfiguration(getSSLConfiguration()); + + m_resolveUrlReply = m_networkManager->head(request); + + QNetworkReply* reply = m_resolveUrlReply; + setReplyTimeout(reply, NETWORK_REQUEST_TIMEOUT_MS); + + if (SettingsComponent::Get().ignoreSSLErrors()) { + connect(reply, QOverload&>::of(&QNetworkReply::sslErrors), + reply, QOverload<>::of(&QNetworkReply::ignoreSslErrors)); + } + connect(reply, &QNetworkReply::finished, this, [this, reply, callback]() { + if (reply->error() == QNetworkReply::OperationCanceledError) { + reply->deleteLater(); + if (m_resolveUrlReply == reply) { + m_resolveUrlReply = nullptr; + } + return; + } + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "resolveUrl: error:" << reply->errorString(); + } + + callback(reply->url().toString()); + reply->deleteLater(); + if (m_resolveUrlReply == reply) { + m_resolveUrlReply = nullptr; + } + }); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +void SystemComponent::checkServerConnectivity(QString url) +{ + // Stop any pending retry + if (m_connectivityRetryTimer->isActive()) { + m_connectivityRetryTimer->stop(); + } - // Abort any pending connectivity check if (m_connectivityCheckReply) { m_connectivityCheckReply->abort(); m_connectivityCheckReply->deleteLater(); m_connectivityCheckReply = nullptr; } - QNetworkRequest request(checkUrl); - request.setHeader(QNetworkRequest::UserAgentHeader, getUserAgent()); - request.setRawHeader("Cache-Control", "no-cache"); + // Store for retry + m_pendingConnectivityUrl = url; - // Configure SSL settings - QSslConfiguration sslConfig = request.sslConfiguration(); + resolveUrl(url, [this, url](const QString& fullResolvedUrl) { + QString baseUrl = extractBaseUrl(fullResolvedUrl); - if (SettingsComponent::Get().ignoreSSLErrors()) { - qDebug() << "checkServerConnectivity: ignoring SSL errors"; - sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone); - } else { - if (SettingsComponent::Get().autodetectCertBundle()) { - QString certPath = SettingsComponent::Get().detectCertBundlePath(); - if (!certPath.isEmpty()) { - QList certs = QSslCertificate::fromPath(certPath); - if (!certs.isEmpty()) { - sslConfig.setCaCertificates(certs); - qDebug() << "checkServerConnectivity: loaded CA bundle from" << certPath; - } - } - } - } + QString checkUrl = baseUrl + "/System/Info/Public"; - request.setSslConfiguration(sslConfig); + QNetworkRequest request(checkUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, getUserAgent()); + request.setRawHeader("Cache-Control", "no-cache"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + request.setSslConfiguration(getSSLConfiguration()); - m_connectivityCheckReply = m_networkManager->get(request); + m_connectivityCheckReply = m_networkManager->get(request); - // Handle SSL errors if ignoreSSLErrors is enabled - if (SettingsComponent::Get().ignoreSSLErrors()) { QNetworkReply* reply = m_connectivityCheckReply; - connect(reply, QOverload&>::of(&QNetworkReply::sslErrors), - this, [reply](const QList& errors) { - qDebug() << "checkServerConnectivity: ignoring SSL errors:" << errors; - reply->ignoreSslErrors(); - }); - } + setReplyTimeout(reply, NETWORK_REQUEST_TIMEOUT_MS); + + if (SettingsComponent::Get().ignoreSSLErrors()) { + connect(reply, QOverload&>::of(&QNetworkReply::sslErrors), + reply, QOverload<>::of(&QNetworkReply::ignoreSslErrors)); + } + + connect(reply, &QNetworkReply::finished, this, [this, reply, url, fullResolvedUrl]() { + if (reply->error() == QNetworkReply::OperationCanceledError) { + reply->deleteLater(); + if (m_connectivityCheckReply == reply) { + m_connectivityCheckReply = nullptr; + } + return; + } + + bool success = false; + if (reply->error() == QNetworkReply::NoError) { + QByteArray data = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isObject() && doc.object().contains("Id") && !doc.object()["Id"].toString().isEmpty()) { + success = true; + } else { + qWarning() << "checkServerConnectivity: invalid response"; + } + } else { + qWarning() << "checkServerConnectivity: error:" << reply->errorString(); + } + + if (success) { + m_connectivityRetryTimer->stop(); + m_pendingConnectivityUrl.clear(); + emit serverConnectivityResult(url, success, fullResolvedUrl); + } else { + m_connectivityRetryTimer->start(CONNECTIVITY_RETRY_INTERVAL_MS); + } - QNetworkReply* reply = m_connectivityCheckReply; - connect(reply, &QNetworkReply::finished, this, [this, reply, url]() { - // Check if this reply was aborted - if (reply->error() == QNetworkReply::OperationCanceledError) { - qDebug() << "checkServerConnectivity: check was aborted"; reply->deleteLater(); if (m_connectivityCheckReply == reply) { m_connectivityCheckReply = nullptr; } - return; - } - - bool success = false; - - if (reply->error() == QNetworkReply::NoError) { - QByteArray data = reply->readAll(); - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - // Parse JSON and verify it has an Id field - QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isObject()) { - QJsonObject obj = doc.object(); - if (obj.contains("Id") && !obj["Id"].toString().isEmpty()) { - success = true; - qInfo() << "checkServerConnectivity: success" << url << "status:" << statusCode; - qInfo() << "checkServerConnectivity: response:" << QString::fromUtf8(data); - } else { - qWarning() << "checkServerConnectivity: response missing Id field" << url; - } - } else { - qWarning() << "checkServerConnectivity: response not valid JSON" << url; - } - } else { - qWarning() << "checkServerConnectivity: error" << url << "error:" << reply->errorString(); - } - - emit serverConnectivityResult(url, success); - reply->deleteLater(); - if (m_connectivityCheckReply == reply) { - m_connectivityCheckReply = nullptr; - } + }); }); } +/////////////////////////////////////////////////////////////////////////////////////////////////// +void SystemComponent::cancelServerConnectivity() +{ + if (m_connectivityRetryTimer->isActive()) { + m_connectivityRetryTimer->stop(); + } + + if (m_resolveUrlReply) { + m_resolveUrlReply->abort(); + m_resolveUrlReply->deleteLater(); + m_resolveUrlReply = nullptr; + } + + if (m_connectivityCheckReply) { + m_connectivityCheckReply->abort(); + m_connectivityCheckReply->deleteLater(); + m_connectivityCheckReply = nullptr; + } + + m_pendingConnectivityUrl.clear(); +} + /////////////////////////////////////////////////////////////////////////////////////////////////// void SystemComponent::setCursorVisibility(bool visible) { diff --git a/src/system/SystemComponent.h b/src/system/SystemComponent.h index 7068adb..94d277d 100644 --- a/src/system/SystemComponent.h +++ b/src/system/SystemComponent.h @@ -13,6 +13,10 @@ // System modifiers #define SYSTEM_MODIFIER_OPENELEC "OpenELEC" +// Network timeouts (milliseconds) +constexpr int NETWORK_REQUEST_TIMEOUT_MS = 30000; +constexpr int CONNECTIVITY_RETRY_INTERVAL_MS = 5000; + class SystemComponent : public ComponentBase { Q_OBJECT @@ -36,7 +40,11 @@ public: Q_INVOKABLE void jsLog(int level, QString text); Q_INVOKABLE void checkServerConnectivity(QString url); - Q_SIGNAL void serverConnectivityResult(QString url, bool success); + Q_INVOKABLE void cancelServerConnectivity(); + Q_SIGNAL void serverConnectivityResult(QString url, bool success, QString resolvedUrl); + + QString extractBaseUrl(const QString& url); + void resolveUrl(const QString& url, std::function callback); Q_INVOKABLE void setCursorVisibility(bool visible); @@ -117,6 +125,9 @@ private: bool platformIsMac() const { return m_platformType == platformTypeOsx; } bool platformIsLinux() const { return m_platformType == platformTypeLinux; } + QSslConfiguration getSSLConfiguration(); + void setReplyTimeout(QNetworkReply* reply, int ms); + QTimer* m_mouseOutTimer; QNetworkAccessManager* m_networkManager; PlatformType m_platformType; @@ -127,6 +138,9 @@ private: bool m_cursorVisible; qreal m_scale; QNetworkReply* m_connectivityCheckReply; + QNetworkReply* m_resolveUrlReply; + QTimer* m_connectivityRetryTimer; + QString m_pendingConnectivityUrl; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..4b9524e --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.5.0) + +set(CMAKE_AUTOMOC ON) + +# Find Qt5 Test module +find_package(Qt5 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS Test Core Network) + +# Get all source files that are part of the main application +get_property(ALL_SRCS GLOBAL PROPERTY SRCS_LIST) + +# Create test executable - link against all application sources +add_executable(test_systemcomponent + test_systemcomponent.cpp + ${ALL_SRCS} +) + +# Link Qt5 libraries and other dependencies +target_link_libraries(test_systemcomponent + shared + Qt5::Test + Qt5::Core + Qt5::Network + ${QT5_LIBRARIES} + ${MPV_LIBRARY} + ${OPENGL_LIBS} + ${OS_LIBS} + ${EXTRA_LIBS} + ${X11_LIBRARIES} + ${X11_Xrandr_LIB} + ${ICU_LIBRARIES} + ${CMAKE_THREAD_LIBS_INIT} + ${RPI_LIBS} + ${MINIZIP_LIBS} +) + +# Include directories - match main application +target_include_directories(test_systemcomponent PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/core + ${CMAKE_SOURCE_DIR}/src/shared + ${CMAKE_SOURCE_DIR}/external + ${CMAKE_SOURCE_DIR}/external/HIDRemote + ${CMAKE_SOURCE_DIR}/external/plistparser + ${CMAKE_SOURCE_DIR}/external/letsmove + ${CMAKE_SOURCE_DIR}/src/player +) + +# Add compile definitions +target_compile_definitions(test_systemcomponent PRIVATE + PREFIX="${CMAKE_INSTALL_PREFIX}" +) + +# Add test to CTest +add_test(NAME test_systemcomponent COMMAND test_systemcomponent) diff --git a/tests/test_systemcomponent.cpp b/tests/test_systemcomponent.cpp new file mode 100644 index 0000000..49168c7 --- /dev/null +++ b/tests/test_systemcomponent.cpp @@ -0,0 +1,151 @@ +#include +#include "../src/system/SystemComponent.h" + +class TestSystemComponent : public QObject +{ + Q_OBJECT + +private slots: + void testExtractBaseUrl_data(); + void testExtractBaseUrl(); +}; + +void TestSystemComponent::testExtractBaseUrl_data() +{ + QTest::addColumn("input"); + QTest::addColumn("expected"); + + // Standard cases - URLs with /web + QTest::newRow("https with /web at root") + << "https://server.com/web/" + << "https://server.com"; + + QTest::newRow("https with /web and custom port") + << "https://server.com:8096/web/" + << "https://server.com:8096"; + + QTest::newRow("http with /web and port 80") + << "http://server.com:80/web/" + << "http://server.com"; + + QTest::newRow("https with /web and port 443") + << "https://server.com:443/web/" + << "https://server.com"; + + QTest::newRow("https with /web at subpath") + << "https://server.com/jellyfin/web/" + << "https://server.com/jellyfin"; + + QTest::newRow("https with /web, no trailing slash") + << "https://server.com/jellyfin/web" + << "https://server.com/jellyfin"; + + QTest::newRow("https with nested path before /web") + << "https://server.com/path/to/jellyfin/web/" + << "https://server.com/path/to/jellyfin"; + + // Case insensitivity + QTest::newRow("https with /WEB uppercase") + << "https://server.com/WEB/" + << "https://server.com"; + + QTest::newRow("https with /Web mixed case") + << "https://server.com/jellyfin/Web/" + << "https://server.com/jellyfin"; + + // Fallback cases - URLs without /web + QTest::newRow("https root without /web") + << "https://server.com/" + << "https://server.com"; + + QTest::newRow("https with port, no /web") + << "https://server.com:8096/" + << "https://server.com:8096"; + + QTest::newRow("http with port 80, no /web") + << "http://server.com:80/" + << "http://server.com"; + + QTest::newRow("https with port 443, no /web") + << "https://server.com:443/" + << "https://server.com"; + + QTest::newRow("https with path but no /web") + << "https://server.com/some/path" + << "https://server.com"; + + QTest::newRow("http root without /web") + << "http://server.com" + << "http://server.com"; + + // Edge cases - query strings and fragments + QTest::newRow("https with /web and query string") + << "https://server.com/web/?foo=bar" + << "https://server.com"; + + QTest::newRow("https with /web and fragment") + << "https://server.com/web/#section" + << "https://server.com"; + + QTest::newRow("https with /web, query and fragment") + << "https://server.com/jellyfin/web/?foo=bar#section" + << "https://server.com/jellyfin"; + + // Edge cases - partial matches + QTest::newRow("https with /website (not /web)") + << "https://server.com/website/" + << "https://server.com"; + + QTest::newRow("https with /webdav (not /web)") + << "https://server.com/webdav/" + << "https://server.com"; + + // Edge cases - multiple /web occurrences (should use last) + QTest::newRow("https with multiple /web - uses last") + << "https://server.com/web/something/web/" + << "https://server.com/web/something"; + + // Edge cases - localhost and IP addresses + QTest::newRow("localhost with port") + << "http://localhost:8096/web/" + << "http://localhost:8096"; + + QTest::newRow("IPv4 address") + << "http://192.168.1.100:8096/web/" + << "http://192.168.1.100:8096"; + + QTest::newRow("IPv6 address") + << "http://[::1]:8096/web/" + << "http://::1:8096"; + + // Edge cases - malformed/empty + QTest::newRow("empty string") + << "" + << "://"; + + QTest::newRow("no scheme") + << "server.com/web/" + << "://server.com"; + + QTest::newRow("only scheme") + << "https://" + << "https://"; + + QTest::newRow("no host") + << "https:///web/" + << "https://"; +} + +void TestSystemComponent::testExtractBaseUrl() +{ + QFETCH(QString, input); + QFETCH(QString, expected); + + SystemComponent* component = &SystemComponent::Get(); + QString result = component->extractBaseUrl(input); + + QCOMPARE(result, expected); +} + +QTEST_APPLESS_MAIN(TestSystemComponent) +#include "test_systemcomponent.moc"