From b852979215ac2a9dfa68ed4f8c740c7a59921ad8 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Wed, 12 Aug 2026 14:40:47 -0400 Subject: [PATCH 1/2] Initial onboarding flow --- Package.swift | 21 ++ Sources/Luminate/Luminate.swift | 24 +- .../Errors/JellyfinClientError.swift | 3 + .../Errors/ServerAddressError.swift | 62 +++++ .../Errors/ServerConnectionError.swift | 43 ++++ .../Models/JellyfinQuickConnect.swift | 129 ++++++++++ .../Models/OnboardDestination.swift | 32 +++ .../LuminateCore/Models/ServerAddress.swift | 139 +++++++++++ .../Models/ServerConnection.swift | 50 ++++ .../JellyfinService+QuickConnect.swift | 28 +++ .../ForgotPasswordPage.swift | 76 ++++++ Sources/LuminateOnboarding/IntroPage.swift | 38 +++ Sources/LuminateOnboarding/LoginPage.swift | 135 ++++++++++ .../LuminateOnboarding/ManualLoginPage.swift | 97 ++++++++ .../LuminateOnboarding/OnboardWindow.swift | 65 +++++ .../LuminateOnboarding/QuickConnectPage.swift | 154 ++++++++++++ .../LuminateOnboarding/ServerConfigPage.swift | 158 ++++++++++++ .../LuminateOnboarding/UserLoginPage.swift | 105 ++++++++ .../Discovery/HTTPSDowngradeGuard.swift | 48 ++++ .../Discovery/ServerConnectionProbe.swift | 98 ++++++++ .../Session/JellyfinClient.swift | 1 + .../Preferences/Preferences+Session.swift | 32 +++ .../JellyfinQuickConnectTests.swift | 133 ++++++++++ .../JellyfinServiceMock.swift | 233 ++++++++++++++++++ .../ServerAddressTests.swift | 169 +++++++++++++ .../ServerConnectionSummaryTests.swift | 46 ++++ .../OnboardWindowTests.swift | 33 +++ .../ServerConfigPageTests.swift | 41 +++ .../JellyfinClientAuthTests.swift | 80 ++++++ .../ServerConnectionProbeTests.swift | 162 ++++++++++++ Tests/LuminateUITests/PreferenceTests.swift | 34 +++ 31 files changed, 2462 insertions(+), 7 deletions(-) create mode 100644 Sources/LuminateCore/Errors/ServerAddressError.swift create mode 100644 Sources/LuminateCore/Errors/ServerConnectionError.swift create mode 100644 Sources/LuminateCore/Models/JellyfinQuickConnect.swift create mode 100644 Sources/LuminateCore/Models/OnboardDestination.swift create mode 100644 Sources/LuminateCore/Models/ServerAddress.swift create mode 100644 Sources/LuminateCore/Models/ServerConnection.swift create mode 100644 Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift create mode 100644 Sources/LuminateOnboarding/ForgotPasswordPage.swift create mode 100644 Sources/LuminateOnboarding/IntroPage.swift create mode 100644 Sources/LuminateOnboarding/LoginPage.swift create mode 100644 Sources/LuminateOnboarding/ManualLoginPage.swift create mode 100644 Sources/LuminateOnboarding/OnboardWindow.swift create mode 100644 Sources/LuminateOnboarding/QuickConnectPage.swift create mode 100644 Sources/LuminateOnboarding/ServerConfigPage.swift create mode 100644 Sources/LuminateOnboarding/UserLoginPage.swift create mode 100644 Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift create mode 100644 Sources/LuminateServices/Discovery/ServerConnectionProbe.swift create mode 100644 Sources/LuminateUI/Preferences/Preferences+Session.swift create mode 100644 Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift create mode 100644 Tests/LuminateCoreTests/JellyfinServiceMock.swift create mode 100644 Tests/LuminateCoreTests/ServerAddressTests.swift create mode 100644 Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift create mode 100644 Tests/LuminateOnboardingTests/OnboardWindowTests.swift create mode 100644 Tests/LuminateOnboardingTests/ServerConfigPageTests.swift create mode 100644 Tests/LuminateServicesTests/ServerConnectionProbeTests.swift diff --git a/Package.swift b/Package.swift index 10b7e23..d2d852a 100644 --- a/Package.swift +++ b/Package.swift @@ -96,6 +96,16 @@ let package = Package( dependencies: uiDeps, swiftSettings: uiSwiftSettings ), + .target( + name: "LuminateOnboarding", + dependencies: [ + "LuminateCore", + "LuminateServices", + "LuminateUI", + .product(name: "Portico", package: "portico"), + ], + swiftSettings: uiSwiftSettings + ), .executableTarget( name: "Luminate", dependencies: [ @@ -103,11 +113,22 @@ let package = Package( "LuminateStore", "LuminateServices", "LuminateUI", + "LuminateOnboarding", .product(name: "Portico", package: "portico"), .product(name: "Logging", package: "swift-log"), ], swiftSettings: uiSwiftSettings ), + .testTarget( + name: "LuminateOnboardingTests", + dependencies: [ + "LuminateCore", + "LuminateServices", + "LuminateUI", + "LuminateOnboarding", + ], + swiftSettings: uiSwiftSettings + ), .testTarget( name: "LuminateCoreTests", dependencies: ["LuminateCore"], diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 23d6c30..df2f07a 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -21,6 +21,7 @@ import Foundation import LuminateCore +import LuminateOnboarding import LuminateServices import LuminateStore import LuminateUI @@ -29,30 +30,39 @@ import Portico /// The Luminate application entry point. /// /// Owns the process-wide Jellyfin client and publishes it to the whole view tree; preferences -/// install themselves through `Preferences.bootstrap(_:)`. +/// install themselves through `Preferences.bootstrap(_:)`. The stored access token is the only +/// login state, and writing it swaps the onboarding window for the main window. @main struct Luminate: App { var applicationId: String? { AppInfo.identifier } - private let jellyfin: JellyfinClient + private let jellyfinClient: JellyfinClient private let sessionBinder: ClientSessionBinder init() { LogBootstrap.bootstrap() Preferences.bootstrap(PreferenceBackend.open()) - jellyfin = JellyfinClient( + jellyfinClient = JellyfinClient( configuration: JellyfinClientConfiguration( serverURL: Preferences.shared[.serverURL], accessToken: Preferences.shared[.accessToken] ) ) - sessionBinder = ClientSessionBinder(client: jellyfin) + sessionBinder = ClientSessionBinder(client: jellyfinClient) } var body: some Scene { - ApplicationWindow { _ in - RootView() - .environment(\.client, jellyfin) + if Preferences.shared.isAuthenticated { + ApplicationWindow { _ in + RootView() + .environment(\.client, jellyfinClient) + } + } else { + ApplicationWindow { _ in + OnboardWindow() + .environment(\.client, jellyfinClient) + } + .defaultSize(width: 800, height: 600) } } } diff --git a/Sources/LuminateCore/Errors/JellyfinClientError.swift b/Sources/LuminateCore/Errors/JellyfinClientError.swift index 32449e2..79542c8 100644 --- a/Sources/LuminateCore/Errors/JellyfinClientError.swift +++ b/Sources/LuminateCore/Errors/JellyfinClientError.swift @@ -58,4 +58,7 @@ package enum JellyfinClientError: Error, Hashable, Sendable { /// /// - Parameter operation: The Jellyfin operation identifier, such as `AuthenticateUserByName`. case missingPayload(operation: String) + /// ``JellyfinQuickConnect/connect(pollInterval:maxPolls:)`` polled past its `maxPolls` limit + /// without the request being approved or expired by the server. + case quickConnectTimedOut } diff --git a/Sources/LuminateCore/Errors/ServerAddressError.swift b/Sources/LuminateCore/Errors/ServerAddressError.swift new file mode 100644 index 0000000..f7ddc98 --- /dev/null +++ b/Sources/LuminateCore/Errors/ServerAddressError.swift @@ -0,0 +1,62 @@ +// +// ServerAddressError.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +/// A validation failure while converting onboarding input into a server URL. +package enum ServerAddressError: Error, Hashable, Sendable { + /// The address field contained no non-whitespace characters. + case emptyAddress + /// The address used a URL scheme other than HTTP or HTTPS. + case unsupportedScheme(String) + /// Foundation could not parse the supplied address as URL components. + case malformedAddress + /// The parsed address did not contain a host name or IP address. + case missingHost + /// The address included credentials, which belong on the sign-in screen. + case credentialsNotAllowed + /// The address included a query string or fragment, which is not part of a server base URL. + case queryOrFragmentNotAllowed + /// The supplied port text was not an integer from 1 through 65535. + case invalidPort(String) + /// Both the address and the separate port field supplied a port. + case conflictingPorts + + /// The user-facing explanation for this validation failure. + package var message: String { + switch self { + case .emptyAddress: + "Enter a server address" + case .unsupportedScheme(let scheme): + "\(scheme) is not supported. Use http or https" + case .malformedAddress: + "That server address could not be understood" + case .missingHost: + "The server address must contain a host name or IP address" + case .credentialsNotAllowed: + "Remove the username and password from the server address; sign in on the next screen instead" + case .queryOrFragmentNotAllowed: + "The server address cannot contain a query string or fragment" + case .invalidPort(let text): + "\(text) is not a valid port. Enter a number from 1 to 65535" + case .conflictingPorts: + "Specify the port in the address or in the port field, not both" + } + } +} diff --git a/Sources/LuminateCore/Errors/ServerConnectionError.swift b/Sources/LuminateCore/Errors/ServerConnectionError.swift new file mode 100644 index 0000000..8cec20f --- /dev/null +++ b/Sources/LuminateCore/Errors/ServerConnectionError.swift @@ -0,0 +1,43 @@ +// +// ServerConnectionError.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// +import Foundation + +/// A failure while probing a normalized server address. +package enum ServerConnectionError: Error, Hashable, Sendable { + /// Every candidate failed at the transport layer. + case unreachable(attempted: [URL]) + /// A candidate answered, but its response was not accepted as a Jellyfin response. + case rejected(url: URL, detail: String) + /// A reachable server identified itself as a different product. + case notJellyfin(url: URL, productName: String?) + + /// The user-facing explanation for this connection failure. + package var message: String { + switch self { + case .unreachable(let attempted): + "Could not reach a server at \(attempted.map(\.absoluteString).joined(separator: " or "))." + case .rejected(let url, let detail): + "\(url.absoluteString) answered, but not as a Jellyfin server: \(detail)" + case .notJellyfin(let url, let productName): + "\(url.absoluteString) is running \(productName ?? "an unknown product"), not Jellyfin." + } + } +} diff --git a/Sources/LuminateCore/Models/JellyfinQuickConnect.swift b/Sources/LuminateCore/Models/JellyfinQuickConnect.swift new file mode 100644 index 0000000..7e32d32 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinQuickConnect.swift @@ -0,0 +1,129 @@ +// +// JellyfinQuickConnect.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +/// Drives one Quick Connect pairing attempt as an async event stream. +/// +/// Reach it through ``JellyfinService/quickConnect``. Iterating ``connect(pollInterval:maxPolls:)`` +/// initiates the request, yields the code to show the user, then polls the server on the caller's +/// behalf until the request is approved from another signed-in client: +/// +/// ```swift +/// for try await event in client.quickConnect.connect() { +/// switch event { +/// case let .polling(code: code): +/// showCode(code) +/// case let .authenticated(secret: secret): +/// let authentication = try await client.authenticateWithQuickConnect(secret: secret) +/// accessToken = authentication.accessToken +/// } +/// } +/// ``` +/// +/// The polling loop is a plain `Task` owned by the stream, cancelled from +/// `Continuation.onTermination` -- ending iteration (breaking out of the loop, or the enclosing +/// `.task` being cancelled on unmount) stops the polling task with it. Nothing keeps requesting +/// once nobody is listening. +package struct JellyfinQuickConnect: Sendable { + /// One step of a Quick Connect pairing attempt. + package enum Event: Equatable, Sendable { + /// The request was created; show the associated code to the user. + case polling(code: String) + /// The request was approved from another client; redeem the secret for a token via + /// ``JellyfinService/authenticateWithQuickConnect(secret:)``. + case authenticated(secret: String) + } + + private let service: any JellyfinService + + /// Wraps a service for Quick Connect. + /// + /// - Parameter service: The service to initiate and poll through. Use + /// ``JellyfinService/quickConnect`` instead of calling this directly. + init(service: any JellyfinService) { + self.service = service + } + + /// Starts a Quick Connect pairing attempt when iterated. + /// + /// - Parameters: + /// - pollInterval: Time between polls. Defaults to five seconds, matching Jellyfin's web + /// client. + /// - maxPolls: The maximum number of polls before giving up. Defaults to 200, about sixteen + /// minutes at the default interval. + /// - Returns: A stream of one ``Event/polling(code:)`` followed by at most one + /// ``Event/authenticated(secret:)``. The stream throws ``JellyfinClientError/notFound`` if + /// the server expires the request first, or ``JellyfinClientError/quickConnectTimedOut`` if + /// `maxPolls` is exhausted without an answer either way. + package func connect( + pollInterval: Duration = .seconds(5), + maxPolls: Int = 200 + ) -> AsyncThrowingStream { + precondition(pollInterval > .zero, "Poll interval must be positive") + precondition(maxPolls > 0, "Maximum poll count must be positive") + + return AsyncThrowingStream { continuation in + let task = Task { + do { + try await run(pollInterval: pollInterval, maxPolls: maxPolls, into: continuation) + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Initiates the request, yields its code, then polls until it is approved or exhausted. + /// + /// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the + /// secret or code, whatever ``JellyfinService/quickConnectState(secret:)`` throws (notably + /// ``JellyfinClientError/notFound`` once the server expires the request), or + /// ``JellyfinClientError/quickConnectTimedOut`` after `maxPolls` unanswered polls. + private func run( + pollInterval: Duration, + maxPolls: Int, + into continuation: AsyncThrowingStream.Continuation + ) async throws { + let state = try await service.initiateQuickConnect() + guard let secret = state.secret, let code = state.code else { + throw JellyfinClientError.missingPayload(operation: "InitiateQuickConnect") + } + + continuation.yield(.polling(code: code)) + + for _ in 0.. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +/// The paths that can be appended to `OnboardWindow`'s `onboardPath` +/// +/// Appending a specific enum type will present its cooresponding page +/// as the top-most view in the navigation stack. +package enum OnboardDestination: Hashable, Sendable { + case login + case userLoginPage(user: JellyfinUser) + case manualLoginPage + case quickConnectPage + case forgotPasswordPage +} \ No newline at end of file diff --git a/Sources/LuminateCore/Models/ServerAddress.swift b/Sources/LuminateCore/Models/ServerAddress.swift new file mode 100644 index 0000000..2dc292c --- /dev/null +++ b/Sources/LuminateCore/Models/ServerAddress.swift @@ -0,0 +1,139 @@ +// +// ServerAddress.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +/// Controls whether server discovery may try plaintext HTTP after HTTPS fails. +package enum ServerSchemePolicy: Sendable, Hashable { + /// Try HTTPS first and permit HTTP fallback only for an unprefixed address. + case automatic + /// Use HTTPS only, preserving any port supplied by the user. + case httpsOnly +} + +/// A validated Jellyfin server base URL and its scheme provenance. +package struct ServerAddress: Sendable, Hashable { + /// The normalized base URL, including a trailing slash. + package let baseURL: URL + /// Whether the user supplied a scheme in the original address. + package let schemeWasExplicit: Bool + + private let allowsInsecureFallback: Bool + + /// The candidates to probe, ordered from preferred to fallback URL. + package var probeCandidates: [URL] { + allowsInsecureFallback ? [baseURL, baseURL.withScheme("http")] : [baseURL] + } + + /// Normalizes free-text onboarding fields into a validated server address. + /// + /// - Parameters: + /// - address: A host name, IP address, or HTTP(S) URL, optionally with a path and port. + /// - port: An optional separate TCP port field. + /// - policy: The scheme policy selected by the user. + /// - Returns: A validated server address whose base URL is ready for probing. + /// - Throws: `ServerAddressError` when the input is empty, malformed, unsupported, or conflicting. + package static func normalize( + address rawAddress: String, + port rawPort: String, + policy: ServerSchemePolicy + ) throws(ServerAddressError) -> ServerAddress { + let address = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines) + let portText = rawPort.trimmingCharacters(in: .whitespacesAndNewlines) + guard !address.isEmpty else { + throw .emptyAddress + } + + let schemeWasExplicit = + address.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression + ) != nil + let parseable = schemeWasExplicit ? address : "https://" + address + guard var components = URLComponents(string: parseable) else { + throw .malformedAddress + } + guard let parsedScheme = components.scheme?.lowercased() else { + throw .malformedAddress + } + guard parsedScheme == "http" || parsedScheme == "https" else { + throw .unsupportedScheme(parsedScheme) + } + guard let host = components.host, !host.isEmpty else { + throw .missingHost + } + guard components.user == nil, components.password == nil else { + throw .credentialsNotAllowed + } + guard components.query == nil, components.fragment == nil else { + throw .queryOrFragmentNotAllowed + } + + let addressPort = components.port + let enteredPort: Int? + if portText.isEmpty { + enteredPort = nil + } else { + guard let value = Int(portText), (1...65_535).contains(value) else { + throw .invalidPort(portText) + } + enteredPort = value + } + if addressPort != nil && enteredPort != nil { + throw .conflictingPorts + } + + var chosenPort = enteredPort ?? addressPort + if enteredPort == nil, + (parsedScheme == "http" && chosenPort == 80) || (parsedScheme == "https" && chosenPort == 443) + { + chosenPort = nil + } + components.scheme = policy == .httpsOnly ? "https" : parsedScheme + components.port = chosenPort + var path = components.path + if path.isEmpty { + path = "/" + } else if !path.hasSuffix("/") { + path += "/" + } + components.path = path + guard let baseURL = components.url else { + throw .malformedAddress + } + + return ServerAddress( + baseURL: baseURL, + schemeWasExplicit: schemeWasExplicit, + allowsInsecureFallback: !schemeWasExplicit && policy == .automatic + ) + } +} + +private extension URL { + func withScheme(_ scheme: String) -> URL { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return self + } + components.scheme = scheme + return components.url ?? self + } +} diff --git a/Sources/LuminateCore/Models/ServerConnection.swift b/Sources/LuminateCore/Models/ServerConnection.swift new file mode 100644 index 0000000..ffe9292 --- /dev/null +++ b/Sources/LuminateCore/Models/ServerConnection.swift @@ -0,0 +1,50 @@ +// +// ServerConnection.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// +import Foundation + +/// A reachable Jellyfin server together with the URL that answered the probe. +package struct ServerConnection: Sendable, Hashable { + /// The validated base URL that successfully answered. + package let url: URL + /// The identity information returned by the server. + package let serverInfo: JellyfinServerInfo + + /// Creates a connection from a successful probe result. + /// + /// - Parameters: + /// - url: The validated base URL that answered. + /// - serverInfo: The identity information returned by the server. + package init(url: URL, serverInfo: JellyfinServerInfo) { + self.url = url + self.serverInfo = serverInfo + } + /// Whether the successful connection used HTTPS. + package var isEncrypted: Bool { url.scheme?.lowercased() == "https" } + + /// A concise success message suitable for the onboarding toast. + package var summary: String { + let name = serverInfo.serverName ?? "the server" + if isEncrypted { + return "Connected to \(name) over HTTPS." + } + return "Connected to \(name) over HTTP - this connection is not encrypted." + } +} diff --git a/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift b/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift new file mode 100644 index 0000000..9ee3715 --- /dev/null +++ b/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift @@ -0,0 +1,28 @@ +// +// JellyfinService+QuickConnect.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +/// The Quick Connect entry point for a ``JellyfinService``. +extension JellyfinService { + /// Starts a Quick Connect pairing attempt. See ``JellyfinQuickConnect`` for usage. + package var quickConnect: JellyfinQuickConnect { + JellyfinQuickConnect(service: self) + } +} diff --git a/Sources/LuminateOnboarding/ForgotPasswordPage.swift b/Sources/LuminateOnboarding/ForgotPasswordPage.swift new file mode 100644 index 0000000..e8b0693 --- /dev/null +++ b/Sources/LuminateOnboarding/ForgotPasswordPage.swift @@ -0,0 +1,76 @@ +// +// ForgotPasswordPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The password-reset entry page. +/// +/// The reset flow is not implemented yet: submitting the form shows an explanatory toast instead of +/// resetting a password. See ``signIn()``. +package struct ForgotPasswordPage: View { + @State private var isLoading = false + @State private var username: String = "" + + private let toastManager = ToastManager() + + package init() {} + + package var body: some View { + ToastOverlay { + ToolbarView { + Clamp { + StatusPage(description: "Enter your username, if you remember it", title: "Reset Your Password") { + PreferencesGroup { + EntryRow("Username", text: $username) + ButtonRow("Continue") { + Task { await signIn() } + } + .suggestedAction() + .sensitive({ !isLoading }) + } + .separateRows(true) + } + } + .vexpand(true) + .valign(.center) + } top: { + HeaderBar() + } + .navigationTitle("Forgot Password") + } + .toastManager(toastManager) + } + + private func signIn() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + + logger.notice("Forgot password functionality is currently unimplemented and will be added at a later date") + toastManager.addToast(.init(title: "Password reset isn't available yet")) + } +} diff --git a/Sources/LuminateOnboarding/IntroPage.swift b/Sources/LuminateOnboarding/IntroPage.swift new file mode 100644 index 0000000..d9efb7c --- /dev/null +++ b/Sources/LuminateOnboarding/IntroPage.swift @@ -0,0 +1,38 @@ +// +// IntroPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Portico + +/// The first carousel page in `OnboardWindow`: a brief welcome screen before server setup. +package struct IntroPage: View { + package init() {} + + package var body: some View { + Clamp { + StatusPage() + .title("Welcome to Luminate!") + .description("Luminate is a modern Jellyfin client for the Linux desktop. Continue to the next page to connect to a server") + .iconName("person-symbolic") + } + .hexpand(true) + .vexpand(true) + } +} diff --git a/Sources/LuminateOnboarding/LoginPage.swift b/Sources/LuminateOnboarding/LoginPage.swift new file mode 100644 index 0000000..4e9f0d1 --- /dev/null +++ b/Sources/LuminateOnboarding/LoginPage.swift @@ -0,0 +1,135 @@ +// +// LoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateServices +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The sign-in landing page: lists the server's public users, or offers manual/Quick +/// Connect/forgot-password entry points when none are public or none are shown. +package struct LoginPage: View { + @State private var isLoading: Bool = false + @State private var showPublicUsers: Bool = false + @State private var users: [JellyfinUser] = [] + @State private var isMobile: Bool = false + + @Environment(\.client) private var client + + private var onboardPath: Binding<[OnboardDestination]> + + package init(_ onboardPath: Binding<[OnboardDestination]>) { + self.onboardPath = onboardPath + } + + package var body: some View { + ToolbarView { + BreakpointBin { + Clamp { + EitherView($isLoading) { + Spinner() + } second: { + EitherView($showPublicUsers) { + publicUsersView + } second: { + Label("No users... :-(") + } + } + .margin(12) + } + } + .breakpoint(.maxWidth(400, .sp), isActive: $isMobile) + .preferredWidth(200) + .preferredHeight(200) + } top: { + HeaderBar() + } + .navigationTitle("Sign In") + .task { + await getPublicUsers() + } + } + + private var publicUsersView: some View { + VStack(spacing: 32) { + WrapBox { + ForEach($users, id: \.id) { user in + userView(user) + } + } + .childSpacing(16) + .lineSpacing(16) + .align(0.5) + + PreferencesGroup { + ButtonRow("Manual Login", endIconName: "right-symbolic") { + onboardPath.wrappedValue.append(.manualLoginPage) + } + ButtonRow("Quick Connect", endIconName: "right-symbolic") { + onboardPath.wrappedValue.append(.quickConnectPage) + } + ButtonRow("Forgot Password", endIconName: "right-symbolic") { + onboardPath.wrappedValue.append(.forgotPasswordPage) + } + } + .separateRows(true) + } + .vexpand(true) + .valign(.center) + } + + private func userView(_ user: JellyfinUser) -> some View { + Button { + VStack(spacing: 8) { + Avatar(size: 0, text: user.name, showInitials: true) + .size({ isMobile ? 96 : 128 }) + Label(str: user.name) + } + .margin(8) + } + .onClicked { + onboardPath.wrappedValue.append(.userLoginPage(user: user)) + } + .flat() + } + + private func getPublicUsers() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let users = try await Task.detached(priority: .userInitiated) { + try await client.publicUsers() + }.value + + self.users = users + self.showPublicUsers = users.count > 0 + } catch let error as JellyfinClientError { + logger.error("Server connection failure: \(error)") + } catch { + logger.error("Unexpected error occurred: \(error)") + } + } +} diff --git a/Sources/LuminateOnboarding/ManualLoginPage.swift b/Sources/LuminateOnboarding/ManualLoginPage.swift new file mode 100644 index 0000000..8b71be9 --- /dev/null +++ b/Sources/LuminateOnboarding/ManualLoginPage.swift @@ -0,0 +1,97 @@ +// +// ManualLoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateServices +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The manual sign-in page for a private user. +/// +/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the +/// only login-state mutation in the app: the application's scene conditional observes the same +/// slot and replaces the onboarding window with the main window. +package struct ManualLoginPage: View { + @Environment(\.client) private var client + + @State private var isLoading = false + @State private var username: String = "" + @State private var password: String = "" + + @Preference(.accessToken) private var accessToken: String? + + package init() {} + + package var body: some View { + ToolbarView { + Clamp { + EitherView($isLoading) { + Spinner() + } second: { + StatusPage("Let's get to know each other", description: "Enter Jellyfin account information to continue", iconName: "people-symbolic") { + VStack(spacing: 12) { + PreferencesGroup { + EntryRow("Username", text: $username) + PasswordEntryRow("Password", text: $password) + } + PreferencesGroup { + ButtonRow("Sign In") { + Task { await signIn() } + } + .suggestedAction() + } + } + } + } + } + .vexpand(true) + .valign(.center) + } top: { + HeaderBar() + } + .navigationTitle("Sign In") + } + + private func signIn() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let authentication = try await client.authenticate( + username: username, + password: password + ) + + let authenticatedUser = authentication.user?.name ?? username + logger.info("Authenticated as \(authenticatedUser)!") + + accessToken = authentication.accessToken + } catch let error as JellyfinClientError { + logger.error("Server connection failure: \(error)") + } catch { + logger.error("Unexpected error occurred: \(error)") + } + } +} diff --git a/Sources/LuminateOnboarding/OnboardWindow.swift b/Sources/LuminateOnboarding/OnboardWindow.swift new file mode 100644 index 0000000..3a36afc --- /dev/null +++ b/Sources/LuminateOnboarding/OnboardWindow.swift @@ -0,0 +1,65 @@ +// +// OnboardWindow.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Adw +import LuminateCore +import LuminateUI +import Portico + +/// The root view for the unauthenticated onboarding window. +/// +/// Hosts the intro/server-setup carousel and the `.login`-rooted navigation stack (public-user +/// picker, manual sign-in, Quick Connect, forgot password) that follows a successful server +/// connection. +package struct OnboardWindow: View { + @State private var onboardPath: [OnboardDestination] = [] + + @WidgetRef private var carousel: Adw.Carousel? + + package init() {} + + package var body: some View { + NavigationView(path: $onboardPath) { + ToolbarView { + Carousel { + IntroPage() + ServerConfigPage($onboardPath) + } + .ref(_carousel) + } top: { + HeaderBar { + CarouselIndicatorDots() + .carousel($carousel) + } + } + .navigationTitle("Server Setup") + .navigationDestination(for: OnboardDestination.self) { destination in + switch destination { + case .login: LoginPage($onboardPath) + case .userLoginPage(let user): UserLoginPage(user) + case .manualLoginPage: ManualLoginPage() + case .quickConnectPage: QuickConnectPage() + case .forgotPasswordPage: ForgotPasswordPage() + } + } + } + } +} diff --git a/Sources/LuminateOnboarding/QuickConnectPage.swift b/Sources/LuminateOnboarding/QuickConnectPage.swift new file mode 100644 index 0000000..18b6938 --- /dev/null +++ b/Sources/LuminateOnboarding/QuickConnectPage.swift @@ -0,0 +1,154 @@ +// +// QuickConnectPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateServices +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The Quick Connect pairing page: shows a one-time code and waits for another signed-in client to +/// approve it. +/// +/// A successful pairing stores the access token in the `.accessToken` preference. That write is the +/// only login-state mutation in the app: the application's scene conditional observes the same +/// slot and replaces the onboarding window with the main window. +package struct QuickConnectPage: View { + @Environment(\.client) private var client + @Environment(\.clipboard) private var clipboard + + @State private var isLoading: Bool = false + @State private var quickConnectCode: [CodeCharacter] = [] + + @Preference(.accessToken) private var accessToken: String? + + private let toastManager = ToastManager() + + package init() {} + + package var body: some View { + ToastOverlay { + ToolbarView { + Clamp { + StatusPage("Quick Connect", description: "Enter the code below in Jellyfin's Settings > Quick Connect") { + VStack(spacing: 12) { + EitherView($isLoading) { + Spinner() + } second: { + VStack(spacing: 48) { + HStack(spacing: 8) { + ForEach($quickConnectCode, id: \.id) { character in + Bin { + Label(str: character.value) + .numericStyle() + .title1() + .marginTop(8) + .marginBottom(8) + .marginStart(12) + .marginEnd(12) + } + .card() + } + } + .hexpand(true) + .halign(.center) + + Button { + ButtonContent(iconName: "edit-copy-symbolic", label: "Copy to Clipboard") + } onClicked: { + copyCodeToClipboard() + } + .suggestedAction() + .halign(.center) + } + } + } + } + } + .vexpand(true) + .valign(.center) + } top: { + HeaderBar() + } + .navigationTitle("Sign In") + } + .toastManager(toastManager) + .task { + await startQuickConnect() + } + } + + /// Runs the Quick Connect flow to completion: shows the code, then waits for approval. + /// + /// ``JellyfinService/quickConnect`` owns the initiate-then-poll exchange as an event stream; + /// this only reacts to what it yields. Cancelling this `.task` (on unmount) cancels the + /// stream's polling with it, so leaving the page stops the network chatter. + private func startQuickConnect() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + for try await event in client.quickConnect.connect() { + switch event { + case .polling(let code): + quickConnectCode = code.enumerated().map { CodeCharacter(id: $0.offset, value: String($0.element)) } + isLoading = false + case .authenticated(let secret): + let authentication = try await client.authenticateWithQuickConnect(secret: secret) + let authenticatedUser = authentication.user?.name ?? "unknown user" + logger.info("Authenticated via Quick Connect as \(authenticatedUser)!") + accessToken = authentication.accessToken + } + } + } catch JellyfinClientError.notFound, JellyfinClientError.quickConnectTimedOut { + logger.warning("Quick connect request expired before it was approved") + toastManager.addToast(.init(title: "Code expired, go back and try again")) + } catch let error as JellyfinClientError { + logger.error("Server connection failure: \(error)") + } catch { + logger.error("Unexpected error occurred: \(error)") + } + } + + /// Joins the displayed digits and hands the result to the system clipboard. + /// + /// The write runs off the main actor because it spawns a short-lived helper process; the + /// result is reported back on the main actor so failures reach the log. + private func copyCodeToClipboard() { + let code = quickConnectCode.map(\.value).joined() + guard !code.isEmpty else { return } + + clipboard.setText(code) + + toastManager.addToast(.init(title: "Code copied to clipboard")) + } +} + +/// One character of a Quick Connect code, keyed by its fixed position rather than its value so +/// repeated characters (Quick Connect codes are not guaranteed unique per digit, e.g. "AA12BB") +/// don't collide during ``ForEach`` diffing. +private struct CodeCharacter: Identifiable { + let id: Int + let value: String +} \ No newline at end of file diff --git a/Sources/LuminateOnboarding/ServerConfigPage.swift b/Sources/LuminateOnboarding/ServerConfigPage.swift new file mode 100644 index 0000000..e3f84a1 --- /dev/null +++ b/Sources/LuminateOnboarding/ServerConfigPage.swift @@ -0,0 +1,158 @@ +// +// ServerConfigPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateServices +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The server connection setup page: enter or paste a server address, test or connect to it. +/// +/// A successful connect stores the resolved URL in the `.serverURL` preference and pushes +/// `.login` onto the shared onboarding navigation path. +package struct ServerConfigPage: View { + @State private var serverAddress: String = "" + @State private var port: String = "" + @State private var forceHttps: Bool = false + @State private var isTesting: Bool = false + @State private var isConnecting: Bool = false + + @Preference(.serverURL) private var serverURL: URL? + + private var onboardPath: Binding<[OnboardDestination]> + private let probe = ServerConnectionProbe() + private let toasts = ToastManager() + + package init(_ onboardPath: Binding<[OnboardDestination]>) { + self.onboardPath = onboardPath + } + + package var body: some View { + ToastOverlay { + Clamp { + StatusPage { + VStack(spacing: 8) { + PreferencesGroup { + EntryRow() + .title("Server URL") + .text($serverAddress) + ExpanderRow() + .title("Advanced Options") + .addRow { + EntryRow() + .title("Port") + .text($port) + } + .addRow { + SwitchRow() + .title("Force HTTPS") + .subtitle("Enforces connection over HTTPS") + .active($forceHttps) + } + } headerSuffix: { + Button { + EitherView($isTesting) { + Spinner() + } second: { + Label("Test Connection") + } + } onClicked: { + connect(persisting: false) + } + .sensitive { !isTesting } + } + + Button { + EitherView($isConnecting) { + Spinner() + } second: { + Label("Connect") + } + } onClicked: { + connect(persisting: true) + } + .sensitive { !isConnecting } + .suggestedAction() + } + } + .title("Let's get started") + .description("Enter your Jellyfin server information") + } + } + .toastManager(toasts) + .hexpand(true) + .vexpand(true) + } + + /// Normalizes the form, probes the server, and optionally persists the successful URL. + /// + /// Normalization is pure and stays on the main actor; the network probe runs in a detached + /// task so the reachability request never occupies main-actor isolation, and its result is + /// applied to view state after the `await` resumes back on the main actor. + /// + /// - Parameter persisting: Whether a successful probe should save the URL and open sign-in. + private func connect(persisting: Bool) { + guard !isConnecting && !isTesting else { return } + if persisting { + isConnecting = true + } else { + isTesting = true + } + let policy: ServerSchemePolicy = forceHttps ? .httpsOnly : .automatic + let probe = probe + Task { @MainActor in + defer { + if persisting { + isConnecting = false + } else { + isTesting = false + } + } + do { + let address = try ServerAddress.normalize(address: serverAddress, port: port, policy: policy) + let connection = try await Task.detached(priority: .userInitiated) { + try await probe.connect(to: address) + }.value + if persisting { + serverURL = connection.url + onboardPath.wrappedValue.append(.login) + } else { + toasts.addToast(.init(title: connection.summary)) + } + } catch let error as ServerAddressError { + logger.debug("Rejected server address: \(error)") + toasts.addToast(.init(title: error.message)) + } catch let error as ServerConnectionError { + logger.debug("Server connection failed: \(error)") + toasts.addToast(.init(title: error.message)) + } catch is CancellationError { + logger.debug("Server connection cancelled") + } catch { + logger.error("Unexpected server connection failure: \(error)") + toasts.addToast(.init(title: String(describing: error))) + } + } + } +} diff --git a/Sources/LuminateOnboarding/UserLoginPage.swift b/Sources/LuminateOnboarding/UserLoginPage.swift new file mode 100644 index 0000000..ca20309 --- /dev/null +++ b/Sources/LuminateOnboarding/UserLoginPage.swift @@ -0,0 +1,105 @@ +// +// UserLoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Logging +import LuminateCore +import LuminateServices +import LuminateUI +import Portico + +private let logger = Logger(label: "\(AppInfo.identifier).onboarding") + +/// The password sign-in page for one public user. +/// +/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the +/// only login-state mutation in the app: the application's scene conditional observes the same +/// slot and replaces the onboarding window with the main window. +package struct UserLoginPage: View { + @Environment(\.client) private var client + + @State private var isLoading = false + @State private var password: String = "" + + @Preference(.accessToken) private var accessToken: String? + + private let user: JellyfinUser + + package init(_ user: JellyfinUser) { + self.user = user + } + + package var body: some View { + ToolbarView { + Clamp { + EitherView($isLoading) { + Spinner() + } second: { + VStack(spacing: 32) { + Avatar(size: 128, text: user.name, showInitials: true) + + PreferencesGroup { + PasswordEntryRow("Password", text: $password) + ButtonRow("Sign In") { + Task { await signIn() } + } + .suggestedAction() + } + .separateRows(true) + } + } + .margin(12) + } + .vexpand(true) + .valign(.center) + } top: { + HeaderBar() + } + .navigationTitle { + if let userName = user.name { + "Sign In as \(userName)" + } else { + "Sign In" + } + } + } + + private func signIn() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let authentication = try await client.authenticate( + username: user.name ?? "", + password: password + ) + + let authenticatedUser = authentication.user?.name ?? user.name ?? "unknown user" + logger.info("Authenticated as \(authenticatedUser)!") + + accessToken = authentication.accessToken + } catch let error as JellyfinClientError { + logger.error("Server connection failure: \(error)") + } catch { + logger.error("Unexpected error occurred: \(error)") + } + } +} diff --git a/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift b/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift new file mode 100644 index 0000000..8267dd7 --- /dev/null +++ b/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift @@ -0,0 +1,48 @@ +// +// HTTPSDowngradeGuard.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Prevents an HTTPS connection probe from silently following a redirect to HTTP. +final class HTTPSDowngradeGuard: NSObject, URLSessionTaskDelegate, Sendable { + /// Decides whether a redirect preserves transport security. + /// + /// - Parameters: + /// - session: The URL session handling the redirect. + /// - task: The task receiving the redirect. + /// - response: The response that requested the redirect. + /// - request: The proposed redirected request. + /// - completionHandler: Receives the request to follow, or `nil` to reject it. + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + let from = response.url?.scheme?.lowercased() + let to = request.url?.scheme?.lowercased() + completionHandler(from == "https" && to == "http" ? nil : request) + } +} diff --git a/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift b/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift new file mode 100644 index 0000000..3b40d6c --- /dev/null +++ b/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift @@ -0,0 +1,98 @@ +// +// ServerConnectionProbe.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import LuminateAPI +import LuminateCore +import OpenAPIRuntime +import OpenAPIURLSession + +/// Probes normalized server URLs and accepts only reachable Jellyfin identities. +package struct ServerConnectionProbe: Sendable { + /// Asks one candidate URL to identify itself. + package typealias Request = @Sendable (URL) async throws -> JellyfinServerInfo + + private let request: Request + + /// Creates a probe using the supplied request seam or the live HTTP implementation. + /// + /// - Parameter request: The request closure used for each candidate URL. + package init(request: @escaping Request = ServerConnectionProbe.liveRequest) { + self.request = request + } + + /// Tries the candidates in order and returns the first accepted Jellyfin connection. + /// + /// - Parameter address: The normalized address whose candidates should be probed. + /// - Returns: The candidate URL and server identity that answered successfully. + /// - Throws: ``ServerConnectionError`` for unreachable, rejected, or non-Jellyfin servers; + /// cancellation is propagated as `CancellationError`. + package func connect(to address: ServerAddress) async throws -> ServerConnection { + for url in address.probeCandidates { + if Task.isCancelled { + throw CancellationError() + } + do { + let info = try await request(url) + guard isJellyfin(info) else { + throw ServerConnectionError.notJellyfin(url: url, productName: info.productName) + } + return ServerConnection(url: url, serverInfo: info) + } catch let error as ServerConnectionError { + throw error + } catch { + let underlying = (error as? ClientError)?.underlyingError ?? error + if underlying is URLError { + continue + } + throw ServerConnectionError.rejected(url: url, detail: String(describing: underlying)) + } + } + throw ServerConnectionError.unreachable(attempted: address.probeCandidates) + } + + /// Performs the public Jellyfin system-info request against one candidate URL. + /// + /// - Parameter url: The normalized base URL to probe. + /// - Returns: The identifying information returned by Jellyfin. + /// - Throws: Transport, OpenAPI runtime, or mapped Jellyfin client errors from the request. + package static let liveRequest: Request = { url in + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 15 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: configuration, delegate: HTTPSDowngradeGuard(), delegateQueue: nil) + let client = JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: url), + transport: URLSessionTransport(configuration: .init(session: session)) + ) + return try await client.publicServerInfo() + } +} + +private func isJellyfin(_ info: JellyfinServerInfo) -> Bool { + guard let productName = info.productName else { + return true + } + return productName.range(of: "jellyfin", options: .caseInsensitive) != nil +} diff --git a/Sources/LuminateServices/Session/JellyfinClient.swift b/Sources/LuminateServices/Session/JellyfinClient.swift index 63f8eb5..3fb9744 100644 --- a/Sources/LuminateServices/Session/JellyfinClient.swift +++ b/Sources/LuminateServices/Session/JellyfinClient.swift @@ -77,6 +77,7 @@ package actor JellyfinClient: JellyfinService { guard let serverURL = configuration.serverURL else { return nil } return Client( serverURL: serverURL, + configuration: .init(dateTranscoder: .iso8601WithFractionalSeconds), transport: transport, middlewares: [ AuthenticationMiddleware( diff --git a/Sources/LuminateUI/Preferences/Preferences+Session.swift b/Sources/LuminateUI/Preferences/Preferences+Session.swift new file mode 100644 index 0000000..e8974c3 --- /dev/null +++ b/Sources/LuminateUI/Preferences/Preferences+Session.swift @@ -0,0 +1,32 @@ +// +// Preferences+Session.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import LuminateCore + +extension Preferences { + /// Whether a usable session token is stored. + /// + /// Reading this tracks the `.accessToken` slot, so any Portico `DependencyTracker` that + /// evaluates it - including the one `SceneHost` wraps around `App.body` - re-runs when the + /// token is written or cleared. An empty string counts as signed out, matching + /// `JellyfinClient.isAuthenticated`. + package var isAuthenticated: Bool { self[.accessToken]?.isEmpty == false } +} diff --git a/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift b/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift new file mode 100644 index 0000000..905a90c --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift @@ -0,0 +1,133 @@ +// +// JellyfinQuickConnectTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Testing + +@testable import LuminateCore + +/// Exercises ``JellyfinQuickConnect``'s initiate-then-poll stream against a scripted +/// ``JellyfinServiceMock``. +@Suite struct JellyfinQuickConnectTests { + @Test("Yields the code, then the secret once a poll reports authenticated") + func happyPath() async throws { + let polls = PolledResults([ + .success(JellyfinQuickConnectState(authenticated: false)), + .success(JellyfinQuickConnectState(authenticated: true, secret: "top-secret")), + ]) + let service = JellyfinServiceMock( + initiateQuickConnect: { JellyfinQuickConnectState(secret: "top-secret", code: "123456") }, + quickConnectState: { _ in try await polls.next() } + ) + + var events: [JellyfinQuickConnect.Event] = [] + for try await event in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) { + events.append(event) + } + + #expect(events == [.polling(code: "123456"), .authenticated(secret: "top-secret")]) + #expect(await polls.callCount == 2) + } + + @Test("A server omitting the secret or code fails without polling") + func missingPayloadThrows() async { + let service = JellyfinServiceMock( + initiateQuickConnect: { JellyfinQuickConnectState(secret: nil, code: "123456") } + ) + + await #expect(throws: JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")) { + for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {} + } + } + + @Test("The server expiring the request propagates from a poll") + func serverExpiryPropagates() async { + let polls = PolledResults([ + .success(JellyfinQuickConnectState(authenticated: false)), + .failure(JellyfinClientError.notFound), + ]) + let service = JellyfinServiceMock( + initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") }, + quickConnectState: { _ in try await polls.next() } + ) + + await #expect(throws: JellyfinClientError.notFound) { + for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {} + } + } + + @Test("Exhausting maxPolls without an answer gives up") + func maxPollsExhaustedThrows() async { + let polls = PolledResults(Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 3)) + let service = JellyfinServiceMock( + initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") }, + quickConnectState: { _ in try await polls.next() } + ) + + await #expect(throws: JellyfinClientError.quickConnectTimedOut) { + for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 3) {} + } + #expect(await polls.callCount == 3) + } + + @Test("Cancelling the consuming task stops polling instead of leaking it") + func cancellationStopsPolling() async throws { + let polls = PolledResults( + Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 1000) + ) + let service = JellyfinServiceMock( + initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") }, + quickConnectState: { _ in try await polls.next() } + ) + + let task = Task { + for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 1000) {} + } + + try await Task.sleep(for: .milliseconds(30)) + task.cancel() + try await Task.sleep(for: .milliseconds(20)) + let countAtCancel = await polls.callCount + + try await Task.sleep(for: .milliseconds(60)) + let countLater = await polls.callCount + + #expect(countLater == countAtCancel) + } +} + +/// Hands back queued Quick Connect poll results in call order and counts how many were consumed. +/// +/// A small actor rather than a plain array captured in a closure because ``JellyfinQuickConnect`` +/// polls from a task the stream owns internally, so consumption genuinely races the test. +private actor PolledResults { + private var queue: [Result] + private(set) var callCount = 0 + + init(_ queue: [Result]) { + self.queue = queue + } + + func next() throws -> JellyfinQuickConnectState { + callCount += 1 + guard !queue.isEmpty else { throw JellyfinClientError.notFound } + return try queue.removeFirst().get() + } +} diff --git a/Tests/LuminateCoreTests/JellyfinServiceMock.swift b/Tests/LuminateCoreTests/JellyfinServiceMock.swift new file mode 100644 index 0000000..9e48403 --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinServiceMock.swift @@ -0,0 +1,233 @@ +// +// JellyfinServiceMock.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Testing + +@testable import LuminateCore + +/// A ``JellyfinService`` double that answers each requirement with a test-supplied handler. +/// +/// Mirrors `MockJellyfinAPI` in `LuminateServicesTests`: every requirement is implemented exactly +/// once here, so a test configures only the handlers it needs through the initializer and every +/// other call fails the test with a clear message rather than a fabricated success. Handlers are +/// `let`, so the whole instance is provably `Sendable` with no isolation escape hatches -- a +/// handler that must vary its answer across calls closes over its own actor or queue instead of +/// mutating this type. +final class JellyfinServiceMock: JellyfinService, Sendable { + private let isAuthenticatedValue: Bool + + private let publicServerInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)? + private let serverInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)? + private let publicUsersHandler: (@Sendable () async throws -> [JellyfinUser])? + private let authenticateHandler: (@Sendable (String, String) async throws -> JellyfinAuthentication)? + private let quickConnectEnabledHandler: (@Sendable () async throws -> Bool)? + private let initiateQuickConnectHandler: (@Sendable () async throws -> JellyfinQuickConnectState)? + private let quickConnectStateHandler: (@Sendable (String) async throws -> JellyfinQuickConnectState)? + private let authenticateWithQuickConnectHandler: (@Sendable (String) async throws -> JellyfinAuthentication)? + private let updateUserPasswordHandler: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)? + private let librariesHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])? + private let itemsHandler: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)? + private let itemHandler: (@Sendable (String, String?) async throws -> JellyfinMediaItem)? + private let resumeItemsHandler: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)? + private let nextUpHandler: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)? + private let latestMediaHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])? + private let seasonsHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? + private let episodesHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? + private let searchHintsHandler: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])? + private let imageHandler: ( + @Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data + )? + private let markPlayedHandler: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)? + private let markUnplayedHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)? + private let markFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)? + private let unmarkFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)? + + /// Creates a mock; every parameter defaults to `nil`, so a call the test never stubbed fails + /// with a clear message instead of returning a fabricated value. + init( + isAuthenticated: Bool = false, + publicServerInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil, + serverInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil, + publicUsers: (@Sendable () async throws -> [JellyfinUser])? = nil, + authenticate: (@Sendable (String, String) async throws -> JellyfinAuthentication)? = nil, + quickConnectEnabled: (@Sendable () async throws -> Bool)? = nil, + initiateQuickConnect: (@Sendable () async throws -> JellyfinQuickConnectState)? = nil, + quickConnectState: (@Sendable (String) async throws -> JellyfinQuickConnectState)? = nil, + authenticateWithQuickConnect: (@Sendable (String) async throws -> JellyfinAuthentication)? = nil, + updateUserPassword: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)? = nil, + libraries: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])? = nil, + items: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)? = nil, + item: (@Sendable (String, String?) async throws -> JellyfinMediaItem)? = nil, + resumeItems: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil, + nextUp: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil, + latestMedia: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])? = nil, + seasons: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil, + episodes: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil, + searchHints: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])? = nil, + image: (@Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data)? = nil, + markPlayed: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)? = nil, + markUnplayed: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil, + markFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil, + unmarkFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil + ) { + self.isAuthenticatedValue = isAuthenticated + self.publicServerInfoHandler = publicServerInfo + self.serverInfoHandler = serverInfo + self.publicUsersHandler = publicUsers + self.authenticateHandler = authenticate + self.quickConnectEnabledHandler = quickConnectEnabled + self.initiateQuickConnectHandler = initiateQuickConnect + self.quickConnectStateHandler = quickConnectState + self.authenticateWithQuickConnectHandler = authenticateWithQuickConnect + self.updateUserPasswordHandler = updateUserPassword + self.librariesHandler = libraries + self.itemsHandler = items + self.itemHandler = item + self.resumeItemsHandler = resumeItems + self.nextUpHandler = nextUp + self.latestMediaHandler = latestMedia + self.seasonsHandler = seasons + self.episodesHandler = episodes + self.searchHintsHandler = searchHints + self.imageHandler = image + self.markPlayedHandler = markPlayed + self.markUnplayedHandler = markUnplayed + self.markFavoriteHandler = markFavorite + self.unmarkFavoriteHandler = unmarkFavorite + } + + var isAuthenticated: Bool { isAuthenticatedValue } + + /// Returns a stubbed handler, or fails the test naming the unstubbed operation. + private func unwrap(_ handler: Handler?, _ operation: String) throws -> Handler { + try #require(handler, "JellyfinServiceMock received an unstubbed call to \(operation)") + } + + func publicServerInfo() async throws -> JellyfinServerInfo { + try await unwrap(publicServerInfoHandler, "publicServerInfo")() + } + + func serverInfo() async throws -> JellyfinServerInfo { + try await unwrap(serverInfoHandler, "serverInfo")() + } + + func publicUsers() async throws -> [JellyfinUser] { + try await unwrap(publicUsersHandler, "publicUsers")() + } + + func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { + try await unwrap(authenticateHandler, "authenticate")(username, password) + } + + func quickConnectEnabled() async throws -> Bool { + try await unwrap(quickConnectEnabledHandler, "quickConnectEnabled")() + } + + func initiateQuickConnect() async throws -> JellyfinQuickConnectState { + try await unwrap(initiateQuickConnectHandler, "initiateQuickConnect")() + } + + func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState { + try await unwrap(quickConnectStateHandler, "quickConnectState")(secret) + } + + func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication { + try await unwrap(authenticateWithQuickConnectHandler, "authenticateWithQuickConnect")(secret) + } + + func updateUserPassword( + userID: String?, + currentPassword: String?, + currentPIN: String?, + newPassword: String?, + resetPassword: Bool? + ) async throws { + try await unwrap(updateUserPasswordHandler, "updateUserPassword")( + userID, + currentPassword, + currentPIN, + newPassword, + resetPassword + ) + } + + func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] { + try await unwrap(librariesHandler, "libraries")(options) + } + + func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage { + try await unwrap(itemsHandler, "items")(query) + } + + func item(id: String, userID: String?) async throws -> JellyfinMediaItem { + try await unwrap(itemHandler, "item")(id, userID) + } + + func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage { + try await unwrap(resumeItemsHandler, "resumeItems")(options) + } + + func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + try await unwrap(nextUpHandler, "nextUp")(seriesID, options) + } + + func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] { + try await unwrap(latestMediaHandler, "latestMedia")(options) + } + + func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + try await unwrap(seasonsHandler, "seasons")(seriesID, options) + } + + func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage { + try await unwrap(episodesHandler, "episodes")(seriesID, options) + } + + func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] { + try await unwrap(searchHintsHandler, "searchHints")(term, options) + } + + func image( + itemID: String, + type: JellyfinImageType, + index: Int32?, + request: JellyfinImageRequest + ) async throws -> Data { + try await unwrap(imageHandler, "image")(itemID, type, index, request) + } + + func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData { + try await unwrap(markPlayedHandler, "markPlayed")(itemID, userID, datePlayed) + } + + func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData { + try await unwrap(markUnplayedHandler, "markUnplayed")(itemID, userID) + } + + func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + try await unwrap(markFavoriteHandler, "markFavorite")(itemID, userID) + } + + func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData { + try await unwrap(unmarkFavoriteHandler, "unmarkFavorite")(itemID, userID) + } +} diff --git a/Tests/LuminateCoreTests/ServerAddressTests.swift b/Tests/LuminateCoreTests/ServerAddressTests.swift new file mode 100644 index 0000000..6cc9dd0 --- /dev/null +++ b/Tests/LuminateCoreTests/ServerAddressTests.swift @@ -0,0 +1,169 @@ +// +// ServerAddressTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Testing + +@testable import LuminateCore + +/// Covers normalization, validation, and candidate selection for onboarding server addresses. +@Suite struct ServerAddressTests { + @Test("Normalizes a bare host and permits HTTPS fallback") + func bareHost() throws { + let address = try ServerAddress.normalize(address: " jellyfin.example.com ", port: "", policy: .automatic) + #expect(address.baseURL.absoluteString == "https://jellyfin.example.com/") + #expect( + address.probeCandidates.map(\.absoluteString) == [ + "https://jellyfin.example.com/", + "http://jellyfin.example.com/", + ]) + } + + @Test("Preserves a custom port on a bare host") + func customPort() throws { + let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "8096", policy: .automatic) + #expect(address.baseURL.absoluteString == "https://jellyfin.example.com:8096/") + } + + @Test("Honors an explicit HTTP scheme without fallback") + func explicitHTTP() throws { + let address = try ServerAddress.normalize( + address: "http://192.168.1.20:8096", + port: "", + policy: .automatic + ) + #expect(address.baseURL.absoluteString == "http://192.168.1.20:8096/") + #expect(address.probeCandidates.map(\.absoluteString) == ["http://192.168.1.20:8096/"]) + } + + @Test("Force HTTPS preserves an explicit nonstandard port") + func forceHTTPSPreservesPort() throws { + let address = try ServerAddress.normalize( + address: "http://192.168.1.20:8096", + port: "", + policy: .httpsOnly + ) + #expect(address.baseURL.absoluteString == "https://192.168.1.20:8096/") + #expect(address.probeCandidates.map(\.absoluteString) == ["https://192.168.1.20:8096/"]) + } + + @Test("Lowercases an explicit HTTPS scheme without changing its host") + func lowercasesScheme() throws { + let address = try ServerAddress.normalize(address: "HTTPS://Host.Example", port: "", policy: .automatic) + #expect(address.baseURL.absoluteString == "https://Host.Example/") + } + + @Test("Preserves a reverse proxy path prefix") + func preservesPathPrefix() throws { + let address = try ServerAddress.normalize( + address: "https://example.com/jellyfin", + port: "", + policy: .automatic + ) + #expect(address.baseURL.absoluteString == "https://example.com/jellyfin/") + } + + @Test("Strips a redundant HTTPS default port") + func stripsDefaultPort() throws { + let address = try ServerAddress.normalize(address: "https://example.com:443", port: "", policy: .automatic) + #expect(address.baseURL.absoluteString == "https://example.com/") + } + + @Test("Preserves IPv6 literal brackets") + func preservesIPv6() throws { + let address = try ServerAddress.normalize( + address: "http://[2001:db8::10]:8096", + port: "", + policy: .automatic + ) + #expect(address.baseURL.absoluteString == "http://[2001:db8::10]:8096/") + } + + @Test("Force HTTPS disables fallback for a bare host") + func forceHTTPSDisablesFallback() throws { + let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "", policy: .httpsOnly) + #expect(address.probeCandidates.map(\.absoluteString) == ["https://jellyfin.example.com/"]) + } + + @Test("Rejects an empty address") + func emptyAddress() { + #expect(throws: ServerAddressError.emptyAddress) { + _ = try ServerAddress.normalize(address: "", port: "", policy: .automatic) + } + } + + @Test("Rejects an unsupported scheme") + func unsupportedScheme() { + #expect(throws: ServerAddressError.unsupportedScheme("ftp")) { + _ = try ServerAddress.normalize(address: "ftp://host", port: "", policy: .automatic) + } + } + + @Test("Rejects conflicting port fields") + func conflictingPorts() { + #expect(throws: ServerAddressError.conflictingPorts) { + _ = try ServerAddress.normalize(address: "https://host:8920", port: "8096", policy: .automatic) + } + } + + @Test("Rejects ports outside the TCP range") + func invalidPortRange() { + #expect(throws: ServerAddressError.invalidPort("70000")) { + _ = try ServerAddress.normalize(address: "host", port: "70000", policy: .automatic) + } + #expect(throws: ServerAddressError.invalidPort("0")) { + _ = try ServerAddress.normalize(address: "host", port: "0", policy: .automatic) + } + } + + @Test("Rejects nonnumeric port text") + func invalidPortText() { + #expect(throws: ServerAddressError.invalidPort("80abc")) { + _ = try ServerAddress.normalize(address: "host", port: "80abc", policy: .automatic) + } + } + + @Test("Rejects credentials in the server address") + func rejectsCredentials() { + #expect(throws: ServerAddressError.credentialsNotAllowed) { + _ = try ServerAddress.normalize(address: "https://user:pw@host", port: "", policy: .automatic) + } + } + + @Test("Rejects query strings and fragments") + func rejectsQueryAndFragment() { + #expect(throws: ServerAddressError.queryOrFragmentNotAllowed) { + _ = try ServerAddress.normalize(address: "https://host/?token=x", port: "", policy: .automatic) + } + #expect(throws: ServerAddressError.queryOrFragmentNotAllowed) { + _ = try ServerAddress.normalize(address: "https://host/#login", port: "", policy: .automatic) + } + } + + @Test("Explains invalid address input") + func errorMessages() { + #expect(ServerAddressError.emptyAddress.message == "Enter a server address.") + #expect(ServerAddressError.unsupportedScheme("ftp").message == "ftp is not supported. Use http or https.") + #expect( + ServerAddressError.invalidPort("70000").message + == "70000 is not a valid port. Enter a number from 1 to 65535.") + } +} diff --git a/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift b/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift new file mode 100644 index 0000000..0bfbf81 --- /dev/null +++ b/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift @@ -0,0 +1,46 @@ +// +// ServerConnectionSummaryTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Testing + +@testable import LuminateCore + +/// Verifies the onboarding success copy for encrypted and plaintext connections. +@Suite struct ServerConnectionSummaryTests { + @Test("Reports an HTTPS connection by server name") + func reportsHTTPS() { + let connection = ServerConnection( + url: URL(string: "https://jellyfin.test/")!, + serverInfo: JellyfinServerInfo(serverName: "Basement") + ) + #expect(connection.summary == "Connected to Basement over HTTPS.") + } + + @Test("Warns that an HTTP connection is unencrypted") + func warnsForHTTP() { + let connection = ServerConnection( + url: URL(string: "http://jellyfin.test/")!, + serverInfo: JellyfinServerInfo() + ) + #expect(connection.summary == "Connected to the server over HTTP - this connection is not encrypted.") + } +} diff --git a/Tests/LuminateOnboardingTests/OnboardWindowTests.swift b/Tests/LuminateOnboardingTests/OnboardWindowTests.swift new file mode 100644 index 0000000..8fa6597 --- /dev/null +++ b/Tests/LuminateOnboardingTests/OnboardWindowTests.swift @@ -0,0 +1,33 @@ +// +// OnboardWindowTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Testing + +@testable import LuminateOnboarding + +/// Verifies the onboarding target exposes its root view. +@MainActor +struct OnboardWindowTests { + @Test("Creates the onboarding root view") + func createsRootView() { + _ = OnboardWindow() + } +} diff --git a/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift b/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift new file mode 100644 index 0000000..d38d86e --- /dev/null +++ b/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift @@ -0,0 +1,41 @@ +// +// ServerConfigPageTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +@_spi(SGTKInternal) import Gtk +import LuminateCore +@_spi(Portico) import Portico +import Testing + +@testable import LuminateOnboarding +@testable import LuminateUI + +/// Verifies that the server configuration page mounts with its live button modifiers. +@Suite(.serialized) @MainActor struct ServerConfigPageTests { + @Test("Mounts the server configuration page") + func mounts() { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let context = MountContext() + @State var onboardPath: [OnboardDestination] = [] + _ = AnyView(ServerConfigPage($onboardPath).environment(\.preferences, preferences)).makeWidget(context) + context.registry.teardown() + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift index 4a83355..b9279c4 100644 --- a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift +++ b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift @@ -20,12 +20,31 @@ // import Foundation +import HTTPTypes import LuminateAPI import LuminateCore +import OpenAPIRuntime import Testing @testable import LuminateServices +private struct PublicUsersTransport: ClientTransport { + let responseBody: String + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPResponse, HTTPBody?) { + let response = HTTPResponse( + status: .ok, + headerFields: [.contentType: "application/json"] + ) + return (response, HTTPBody(responseBody)) + } +} + /// Covers sign-in: argument validation, payload mapping, and token adoption. @Suite struct JellyfinClientAuthTests { /// Builds a client wired to a double, pointed at a URL that is never dialled. @@ -42,6 +61,67 @@ import Testing api: api ) } + /// Builds a client that decodes a raw public-user response without opening a network connection. + /// + /// - Parameter responseBody: The JSON body returned by the transport double. + /// - Returns: A client configured with the production OpenAPI transport and date transcoder. + private func makeDecodingClient(responseBody: String) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration( + serverURL: URL(string: "http://localhost")!, + deviceName: "test-device", + deviceID: "test-device-id" + ), + transport: PublicUsersTransport(responseBody: responseBody) + ) + } + + @Test("Public users decode Jellyfin dates with seven fractional digits") + func publicUsersDecodeFractionalDates() async throws { + let lastLoginDate = "2025-05-07T17:36:46.1130892Z" + let responseBody = """ + [ + { + "Name": "ashley", + "Id": "user-1", + "LastLoginDate": "\(lastLoginDate)", + "LastActivityDate": "2026-07-16T05:04:36.1878329Z" + } + ] + """ + let client = makeDecodingClient(responseBody: responseBody) + + let users = try await client.publicUsers() + let expectedDate = try ISO8601DateTranscoder.iso8601WithFractionalSeconds.decode(lastLoginDate) + + #expect(users.count == 1) + #expect(users[0].id == "user-1") + #expect(users[0].name == "ashley") + #expect(users[0].lastLoginDate == expectedDate) + } + + @Test("Public users reject malformed date strings") + func publicUsersRejectMalformedDates() async { + let responseBody = """ + [ + { + "Name": "ashley", + "Id": "user-1", + "LastLoginDate": "not-a-date" + } + ] + """ + let client = makeDecodingClient(responseBody: responseBody) + + do { + _ = try await client.publicUsers() + Issue.record("Expected malformed public-user date to fail decoding") + } catch let error as ClientError { + #expect(error.underlyingError is DecodingError) + } catch { + Issue.record("Expected OpenAPI ClientError, got \(error)") + } + } @Test("A successful sign-in maps the result and adopts the token") func signInAdoptsToken() async throws { diff --git a/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift b/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift new file mode 100644 index 0000000..8fa75c4 --- /dev/null +++ b/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift @@ -0,0 +1,162 @@ +// +// ServerConnectionProbeTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import LuminateCore +import OpenAPIRuntime +import Testing + +@testable import LuminateServices + +/// Records probe candidates without introducing shared mutable state into test closures. +private actor ProbeRecorder { + private(set) var urls: [URL] = [] + + func record(_ url: URL) { + urls.append(url) + } +} + +/// Exercises HTTPS preference, transport fallback, and server identity classification. +@Suite struct ServerConnectionProbeTests { + private let httpsURL = URL(string: "https://jellyfin.test/")! + private let httpURL = URL(string: "http://jellyfin.test/")! + + @Test("Uses the HTTPS candidate when it answers") + func usesHTTPS() async throws { + let address = try makeAddress() + let probe = ServerConnectionProbe { url in + #expect(url == self.httpsURL) + return JellyfinServerInfo(serverName: "Home", productName: "Jellyfin Server") + } + + let connection = try await probe.connect(to: address) + #expect(connection.url == httpsURL) + #expect(connection.isEncrypted) + } + + @Test("Falls back to HTTP after an HTTPS transport failure") + func fallsBackAfterTransportFailure() async throws { + let address = try makeAddress() + let recorder = ProbeRecorder() + let probe = ServerConnectionProbe { url in + await recorder.record(url) + if url.scheme == "https" { + throw URLError(.cannotConnectToHost) + } + return JellyfinServerInfo(productName: "Jellyfin Server") + } + + let connection = try await probe.connect(to: address) + #expect(connection.url == httpURL) + #expect(!connection.isEncrypted) + #expect(await recorder.urls == [httpsURL, httpURL]) + } + + @Test("Does not fall back when HTTPS answers with an error status") + func doesNotFallbackAfterAnswer() async throws { + let address = try makeAddress() + let recorder = ProbeRecorder() + let probe = ServerConnectionProbe { url in + await recorder.record(url) + throw JellyfinClientError.unauthorized + } + + do { + _ = try await probe.connect(to: address) + Issue.record("Expected the answered HTTPS candidate to be rejected") + } catch let error as ServerConnectionError { + guard case .rejected(let url, let detail) = error else { + Issue.record("Expected a rejected connection, got \(error)") + return + } + #expect(url == httpsURL) + #expect(!detail.isEmpty) + } + #expect(await recorder.urls == [httpsURL]) + } + + @Test("Unwraps an OpenAPI ClientError to reach the URLError") + func unwrapsClientError() async throws { + let address = try makeAddress() + let recorder = ProbeRecorder() + let probe = ServerConnectionProbe { url in + await recorder.record(url) + if url.scheme == "https" { + throw ClientError( + operationID: "GetPublicSystemInfo", + operationInput: "test", + causeDescription: "transport failed", + underlyingError: URLError(.timedOut) + ) + } + return JellyfinServerInfo(productName: "Jellyfin Server") + } + + let connection = try await probe.connect(to: address) + #expect(connection.url == httpURL) + #expect(await recorder.urls == [httpsURL, httpURL]) + } + + @Test("Rejects a reachable server that is not Jellyfin") + func rejectsNonJellyfin() async throws { + let address = try makeAddress() + do { + let probe = ServerConnectionProbe { _ in + JellyfinServerInfo(productName: "Emby Server") + } + _ = try await probe.connect(to: address) + Issue.record("Expected a non-Jellyfin identity to be rejected") + } catch let error as ServerConnectionError { + #expect(error == .notJellyfin(url: httpsURL, productName: "Emby Server")) + } + } + + @Test("Accepts a server that omits its product name") + func acceptsMissingProductName() async throws { + let address = try makeAddress() + let probe = ServerConnectionProbe { _ in JellyfinServerInfo(serverName: "Proxy") } + let connection = try await probe.connect(to: address) + #expect(connection.serverInfo.serverName == "Proxy") + } + + @Test("Reports every attempted URL when nothing answers") + func reportsUnreachableCandidates() async throws { + let address = try makeAddress() + let recorder = ProbeRecorder() + let probe = ServerConnectionProbe { url in + await recorder.record(url) + throw URLError(.cannotConnectToHost) + } + + do { + _ = try await probe.connect(to: address) + Issue.record("Expected all candidates to be reported as unreachable") + } catch let error as ServerConnectionError { + #expect(error == .unreachable(attempted: [httpsURL, httpURL])) + } + #expect(await recorder.urls == [httpsURL, httpURL]) + } + + private func makeAddress() throws -> ServerAddress { + try ServerAddress.normalize(address: "jellyfin.test", port: "", policy: .automatic) + } +} diff --git a/Tests/LuminateUITests/PreferenceTests.swift b/Tests/LuminateUITests/PreferenceTests.swift index 13664ab..7e4fcbb 100644 --- a/Tests/LuminateUITests/PreferenceTests.swift +++ b/Tests/LuminateUITests/PreferenceTests.swift @@ -36,6 +36,11 @@ private struct PreferenceLabel: View { } } +/// Records each value a tracked closure observes, so a test can assert re-evaluation. +private final class SessionRecorder { + var values: [Bool] = [] +} + /// Exercises preference mounting, observation, and persistence wiring. @Suite(.serialized) @MainActor struct PreferenceTests { @Test("An unset preference reads nil at mount") @@ -122,6 +127,35 @@ private struct PreferenceLabel: View { #expect(await store.storedValues["server.url"] == nil) } + @Test("A stored token reports an authenticated coordinator") + func authenticatedWithStoredToken() { + let store = EphemeralPreferenceStore(initialSnapshot: ["auth.accessToken": .text("tok")]) + #expect(Preferences(store: store).isAuthenticated) + } + + @Test("A missing or empty token reports an unauthenticated coordinator") + func unauthenticatedWithoutToken() { + let preferences = Preferences(store: EphemeralPreferenceStore()) + #expect(preferences.isAuthenticated == false) + preferences.slot(for: .accessToken).value = "" + #expect(preferences.isAuthenticated == false) + } + + @Test("Writing the access token re-evaluates a tracker that read the session state") + func authenticationDrivesTrackedReevaluation() { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let recorder = SessionRecorder() + let tracker = DependencyTracker { recorder.values.append(preferences.isAuthenticated) } + tracker.run() + defer { tracker.teardown() } + + preferences.slot(for: .accessToken).value = "tok" + pump { recorder.values.count > 1 } + + #expect(recorder.values == [false, true]) + } + private func pump(until condition: () -> Bool = { false }, turns: Int = 200) { for _ in 0.. Date: Wed, 12 Aug 2026 15:44:08 -0400 Subject: [PATCH 2/2] Fix unit tests + expand coverage --- .gitignore | 3 + Package.swift | 2 +- Tests/LuminateCoreTests/AppPathsTests.swift | 16 ++ .../JellyfinServiceConvenienceTests.swift | 253 ++++++++++++++++++ .../PreferenceValueTests.swift | 43 +++ .../ServerAddressTests.swift | 13 +- .../ServerConnectionErrorTests.swift | 58 ++++ .../UnconfiguredJellyfinServiceTests.swift | 62 +++++ .../HTTPSDowngradeGuardTests.swift | 80 ++++++ .../JellyfinClientAuthTests.swift | 118 ++++++++ .../JellyfinClientLibraryTests.swift | 167 ++++++++++++ .../JellyfinClientSystemTests.swift | 79 ++++++ .../JellyfinClientUserStateTests.swift | 146 ++++++++++ .../MockJellyfinAPI.swift | 95 +++++-- .../SQLitePreferenceStoreTests.swift | 30 +++ .../SQLiteStatementTests.swift | 96 +++++++ .../ClientSessionBinderTests.swift | 113 ++++++++ Tests/LuminateUITests/PreferenceTests.swift | 78 +++++- 18 files changed, 1418 insertions(+), 34 deletions(-) create mode 100644 Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift create mode 100644 Tests/LuminateCoreTests/ServerConnectionErrorTests.swift create mode 100644 Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift create mode 100644 Tests/LuminateServicesTests/JellyfinClientSystemTests.swift create mode 100644 Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift create mode 100644 Tests/LuminateStoreTests/SQLiteStatementTests.swift create mode 100644 Tests/LuminateTests/ClientSessionBinderTests.swift diff --git a/.gitignore b/.gitignore index 09467aa..01cf2af 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ Package.resolved *.xcodeproj *.xcworkspace xcuserdata/ + +# Code coverage files +*.profraw diff --git a/Package.swift b/Package.swift index d2d852a..234ea45 100644 --- a/Package.swift +++ b/Package.swift @@ -151,7 +151,7 @@ let package = Package( ), .testTarget( name: "LuminateTests", - dependencies: ["LuminateCore", "LuminateStore", "LuminateUI"], + dependencies: ["Luminate", "LuminateCore", "LuminateServices", "LuminateStore", "LuminateUI"], swiftSettings: uiSwiftSettings ), ], diff --git a/Tests/LuminateCoreTests/AppPathsTests.swift b/Tests/LuminateCoreTests/AppPathsTests.swift index 0d013bd..ed948b6 100644 --- a/Tests/LuminateCoreTests/AppPathsTests.swift +++ b/Tests/LuminateCoreTests/AppPathsTests.swift @@ -86,4 +86,20 @@ import Testing ) #expect(path.path == "/tmp/luminate-test/luminate") } + + @Test("The preferences database sits inside the data directory") + func preferencesDatabaseLocation() { + let dataPath = AppPaths.dataDirectory( + environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"], + home: URL(filePath: "/home/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + let dbPath = AppPaths.preferencesDatabaseURL( + environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"], + home: URL(filePath: "/home/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(dbPath == dataPath.appending(path: "preferences.sqlite", directoryHint: .notDirectory)) + #expect(dbPath.path == "/override/luminate/preferences.sqlite") + } } diff --git a/Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift b/Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift new file mode 100644 index 0000000..307be6d --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift @@ -0,0 +1,253 @@ +// +// JellyfinServiceConvenienceTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Synchronization +import Testing + +@testable import LuminateCore + +/// Pins the default arguments the reduced-arity overloads in `JellyfinService+Convenience.swift` +/// forward to their full-arity requirements. A protocol requirement cannot declare default +/// arguments, so a wrong default here (e.g. forwarding `userID: ""` instead of `nil`) would be +/// silent without these tests. +@Suite struct JellyfinServiceConvenienceTests { + @Test("libraries() forwards an empty options set") + func librariesDefaults() async throws { + let recorded = Mutex(nil) + let mock = JellyfinServiceMock(libraries: { options in + recorded.withLock { $0 = options } + return [] + }) + _ = try await mock.libraries() + #expect(recorded.withLock { $0 } == JellyfinListOptions()) + } + + @Test("items() forwards an empty media query") + func itemsDefaults() async throws { + let recorded = Mutex(nil) + let mock = JellyfinServiceMock(items: { query in + recorded.withLock { $0 = query } + return JellyfinMediaPage() + }) + _ = try await mock.items() + #expect(recorded.withLock { $0 } == JellyfinMediaQuery()) + } + + @Test("item(id:) forwards a nil userID") + func itemDefaultsUserID() async throws { + let recorded = Mutex<(id: String, userID: String?)?>(nil) + let mock = JellyfinServiceMock(item: { id, userID in + recorded.withLock { $0 = (id, userID) } + return JellyfinMediaItem(id: id) + }) + _ = try await mock.item(id: "item-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.id == "item-1") + #expect(captured.userID == nil) + } + + @Test("resumeItems() forwards an empty options set") + func resumeItemsDefaults() async throws { + let recorded = Mutex(nil) + let mock = JellyfinServiceMock(resumeItems: { options in + recorded.withLock { $0 = options } + return JellyfinMediaPage() + }) + _ = try await mock.resumeItems() + #expect(recorded.withLock { $0 } == JellyfinListOptions()) + } + + @Test("nextUp() forwards a nil series and empty options") + func nextUpDefaults() async throws { + let recorded = Mutex<(seriesID: String?, options: JellyfinListOptions)?>(nil) + let mock = JellyfinServiceMock(nextUp: { seriesID, options in + recorded.withLock { $0 = (seriesID, options) } + return JellyfinMediaPage() + }) + _ = try await mock.nextUp() + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.seriesID == nil) + #expect(captured.options == JellyfinListOptions()) + } + + @Test("nextUp(options:) passes options through unchanged") + func nextUpOptionsPassthrough() async throws { + let recorded = Mutex<(seriesID: String?, options: JellyfinListOptions)?>(nil) + let mock = JellyfinServiceMock(nextUp: { seriesID, options in + recorded.withLock { $0 = (seriesID, options) } + return JellyfinMediaPage() + }) + let nonDefault = JellyfinListOptions(limit: 5) + _ = try await mock.nextUp(options: nonDefault) + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.seriesID == nil) + #expect(captured.options == nonDefault) + } + + @Test("latestMedia() forwards an empty options set") + func latestMediaDefaults() async throws { + let recorded = Mutex(nil) + let mock = JellyfinServiceMock(latestMedia: { options in + recorded.withLock { $0 = options } + return [] + }) + _ = try await mock.latestMedia() + #expect(recorded.withLock { $0 } == JellyfinListOptions()) + } + + @Test("seasons(seriesID:) forwards an empty options set") + func seasonsDefaults() async throws { + let recorded = Mutex<(seriesID: String, options: JellyfinListOptions)?>(nil) + let mock = JellyfinServiceMock(seasons: { seriesID, options in + recorded.withLock { $0 = (seriesID, options) } + return JellyfinMediaPage() + }) + _ = try await mock.seasons(seriesID: "series-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.seriesID == "series-1") + #expect(captured.options == JellyfinListOptions()) + } + + @Test("episodes(seriesID:) forwards an empty options set") + func episodesDefaults() async throws { + let recorded = Mutex<(seriesID: String, options: JellyfinListOptions)?>(nil) + let mock = JellyfinServiceMock(episodes: { seriesID, options in + recorded.withLock { $0 = (seriesID, options) } + return JellyfinMediaPage() + }) + _ = try await mock.episodes(seriesID: "series-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.seriesID == "series-1") + #expect(captured.options == JellyfinListOptions()) + } + + @Test("searchHints(term:) forwards an empty options set") + func searchHintsDefaults() async throws { + let recorded = Mutex<(term: String, options: JellyfinListOptions)?>(nil) + let mock = JellyfinServiceMock(searchHints: { term, options in + recorded.withLock { $0 = (term, options) } + return [] + }) + _ = try await mock.searchHints(term: "batman") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.term == "batman") + #expect(captured.options == JellyfinListOptions()) + } + + @Test("image(itemID:type:) forwards a nil index and default request") + func imageDefaults() async throws { + let recorded = Mutex<(index: Int32?, request: JellyfinImageRequest)?>(nil) + let mock = JellyfinServiceMock(image: { _, _, index, request in + recorded.withLock { $0 = (index, request) } + return Data() + }) + _ = try await mock.image(itemID: "item-1", type: .primary) + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.index == nil) + #expect(captured.request == JellyfinImageRequest()) + } + + @Test("image(itemID:type:request:) passes the request through unchanged") + func imageRequestPassthrough() async throws { + let recorded = Mutex<(index: Int32?, request: JellyfinImageRequest)?>(nil) + let mock = JellyfinServiceMock(image: { _, _, index, request in + recorded.withLock { $0 = (index, request) } + return Data() + }) + let nonDefault = JellyfinImageRequest(tag: "abc", fillWidth: 300, fillHeight: 450) + _ = try await mock.image(itemID: "item-1", type: .primary, request: nonDefault) + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.index == nil) + #expect(captured.request == nonDefault) + } + + @Test("markPlayed(itemID:) forwards nil userID and datePlayed") + func markPlayedDefaults() async throws { + let recorded = Mutex<(itemID: String, userID: String?, datePlayed: Date?)?>(nil) + let mock = JellyfinServiceMock(markPlayed: { itemID, userID, datePlayed in + recorded.withLock { $0 = (itemID, userID, datePlayed) } + return JellyfinUserData(itemID: itemID) + }) + _ = try await mock.markPlayed(itemID: "item-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.itemID == "item-1") + #expect(captured.userID == nil) + #expect(captured.datePlayed == nil) + } + + @Test("markUnplayed(itemID:) forwards a nil userID") + func markUnplayedDefaults() async throws { + let recorded = Mutex<(itemID: String, userID: String?)?>(nil) + let mock = JellyfinServiceMock(markUnplayed: { itemID, userID in + recorded.withLock { $0 = (itemID, userID) } + return JellyfinUserData(itemID: itemID) + }) + _ = try await mock.markUnplayed(itemID: "item-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.itemID == "item-1") + #expect(captured.userID == nil) + } + + @Test("markFavorite(itemID:) forwards a nil userID") + func markFavoriteDefaults() async throws { + let recorded = Mutex<(itemID: String, userID: String?)?>(nil) + let mock = JellyfinServiceMock(markFavorite: { itemID, userID in + recorded.withLock { $0 = (itemID, userID) } + return JellyfinUserData(itemID: itemID) + }) + _ = try await mock.markFavorite(itemID: "item-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.itemID == "item-1") + #expect(captured.userID == nil) + } + + @Test("unmarkFavorite(itemID:) forwards a nil userID") + func unmarkFavoriteDefaults() async throws { + let recorded = Mutex<(itemID: String, userID: String?)?>(nil) + let mock = JellyfinServiceMock(unmarkFavorite: { itemID, userID in + recorded.withLock { $0 = (itemID, userID) } + return JellyfinUserData(itemID: itemID) + }) + _ = try await mock.unmarkFavorite(itemID: "item-1") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.itemID == "item-1") + #expect(captured.userID == nil) + } + + @Test("updateUserPassword(currentPassword:newPassword:) forwards nil userID, currentPIN, and resetPassword") + func updateUserPasswordDefaults() async throws { + let recorded = Mutex< + (userID: String?, currentPassword: String?, currentPIN: String?, newPassword: String?, resetPassword: Bool?)? + >(nil) + let mock = JellyfinServiceMock( + updateUserPassword: { userID, currentPassword, currentPIN, newPassword, resetPassword in + recorded.withLock { $0 = (userID, currentPassword, currentPIN, newPassword, resetPassword) } + }) + try await mock.updateUserPassword(currentPassword: "old-password", newPassword: "new-password") + let captured = try #require(recorded.withLock { $0 }) + #expect(captured.userID == nil) + #expect(captured.currentPassword == "old-password") + #expect(captured.currentPIN == nil) + #expect(captured.newPassword == "new-password") + #expect(captured.resetPassword == nil) + } +} diff --git a/Tests/LuminateCoreTests/PreferenceValueTests.swift b/Tests/LuminateCoreTests/PreferenceValueTests.swift index b7dcb9b..03bb77d 100644 --- a/Tests/LuminateCoreTests/PreferenceValueTests.swift +++ b/Tests/LuminateCoreTests/PreferenceValueTests.swift @@ -19,6 +19,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // +import Foundation import Testing @testable import LuminateCore @@ -42,4 +43,46 @@ import Testing #expect(Double.nan.preferenceValue == .real(0)) #expect(Double.infinity.preferenceValue == .real(0)) } + + @Test("Boolean encoding maps to zero and one") + func booleanEncoding() { + #expect(true.preferenceValue == .integer(1)) + #expect(false.preferenceValue == .integer(0)) + } + + @Test("Integer decoding and encoding round trip") + func integerRoundTrip() { + #expect(Int(preference: .integer(7)) == 7) + #expect(Int(preference: .text("7")) == nil) + #expect(7.preferenceValue == .integer(7)) + } + + @Test("UUID round trips through text storage") + func uuidRoundTrip() { + let uuid = UUID() + #expect(uuid.preferenceValue == .text(uuid.uuidString)) + #expect(UUID(preference: .text(uuid.uuidString)) == uuid) + #expect(UUID(preference: .text("not-a-uuid")) == nil) + #expect(UUID(preference: .integer(1)) == nil) + } + + @Test("Data round trips through blob storage, including empty data") + func dataRoundTrip() { + #expect(Data([1, 2, 3]).preferenceValue == .blob(Data([1, 2, 3]))) + #expect(Data(preference: .blob(Data([1, 2, 3]))) == Data([1, 2, 3])) + #expect(Data().preferenceValue == .blob(Data())) + #expect(Data(preference: .blob(Data())) == Data()) + #expect(Data(preference: .text("x")) == nil) + } + + @Test("A RawRepresentable conformance encodes and decodes through its raw value") + func rawRepresentableConformance() { + #expect(Theme.dark.preferenceValue == .text("dark")) + #expect(Theme(preference: .text("light")) == .light) + #expect(Theme(preference: .text("plaid")) == nil) + } +} + +private enum Theme: String, PreferenceValue { + case light, dark } diff --git a/Tests/LuminateCoreTests/ServerAddressTests.swift b/Tests/LuminateCoreTests/ServerAddressTests.swift index 6cc9dd0..70d5408 100644 --- a/Tests/LuminateCoreTests/ServerAddressTests.swift +++ b/Tests/LuminateCoreTests/ServerAddressTests.swift @@ -158,12 +158,19 @@ import Testing } } + @Test("Rejects an address with no host") + func missingHost() { + #expect(throws: ServerAddressError.missingHost) { + _ = try ServerAddress.normalize(address: "https://", port: "", policy: .automatic) + } + } + @Test("Explains invalid address input") func errorMessages() { - #expect(ServerAddressError.emptyAddress.message == "Enter a server address.") - #expect(ServerAddressError.unsupportedScheme("ftp").message == "ftp is not supported. Use http or https.") + #expect(ServerAddressError.emptyAddress.message == "Enter a server address") + #expect(ServerAddressError.unsupportedScheme("ftp").message == "ftp is not supported. Use http or https") #expect( ServerAddressError.invalidPort("70000").message - == "70000 is not a valid port. Enter a number from 1 to 65535.") + == "70000 is not a valid port. Enter a number from 1 to 65535") } } diff --git a/Tests/LuminateCoreTests/ServerConnectionErrorTests.swift b/Tests/LuminateCoreTests/ServerConnectionErrorTests.swift new file mode 100644 index 0000000..e26ebf9 --- /dev/null +++ b/Tests/LuminateCoreTests/ServerConnectionErrorTests.swift @@ -0,0 +1,58 @@ +// +// ServerConnectionErrorTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Testing + +@testable import LuminateCore + +/// Covers the human-readable messages for each `ServerConnectionError` case. +@Suite struct ServerConnectionErrorTests { + @Test("Unreachable joins every attempted URL with 'or'") + func unreachableJoinsAttemptedURLs() { + let a = URL(string: "http://a.test")! + let b = URL(string: "http://b.test")! + #expect( + ServerConnectionError.unreachable(attempted: [a, b]).message + == "Could not reach a server at http://a.test or http://b.test.") + } + + @Test("Rejected includes the URL and the detail text") + func rejectedIncludesURLAndDetail() { + #expect( + ServerConnectionError.rejected(url: URL(string: "http://host")!, detail: "not JSON").message + == "http://host answered, but not as a Jellyfin server: not JSON") + } + + @Test("A missing product name renders as an unknown product") + func missingProductNameRendersUnknown() { + #expect( + ServerConnectionError.notJellyfin(url: URL(string: "http://host")!, productName: nil).message + == "http://host is running an unknown product, not Jellyfin.") + } + + @Test("A named product renders its own name") + func namedProductRendersOwnName() { + #expect( + ServerConnectionError.notJellyfin(url: URL(string: "http://host")!, productName: "Emby").message + == "http://host is running Emby, not Jellyfin.") + } +} diff --git a/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift b/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift index eb8b2cb..9b34b8d 100644 --- a/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift +++ b/Tests/LuminateCoreTests/UnconfiguredJellyfinServiceTests.swift @@ -54,4 +54,66 @@ import Testing _ = try await service.libraries() } } + + @Test("Every remaining operation on the unconfigured service throws notConfigured") + func everyOperationThrows() async { + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.publicServerInfo() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.serverInfo() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.authenticate(username: "u", password: "p") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.quickConnectEnabled() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.initiateQuickConnect() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.quickConnectState(secret: "s") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.authenticateWithQuickConnect(secret: "s") + } + await #expect(throws: JellyfinClientError.notConfigured) { + try await service.updateUserPassword( + userID: nil, currentPassword: "a", currentPIN: nil, newPassword: "b", resetPassword: nil) + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.item(id: "i") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.resumeItems() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.nextUp() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.latestMedia() + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.seasons(seriesID: "s") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.episodes(seriesID: "s") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.searchHints(term: "t") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.markPlayed(itemID: "i") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.markUnplayed(itemID: "i") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.markFavorite(itemID: "i") + } + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await service.unmarkFavorite(itemID: "i") + } + } } diff --git a/Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift b/Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift new file mode 100644 index 0000000..112c04a --- /dev/null +++ b/Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift @@ -0,0 +1,80 @@ +// +// HTTPSDowngradeGuardTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import Testing + +@testable import LuminateServices + +/// Covers the redirect policy that stops an HTTPS probe from being silently downgraded. +@Suite struct HTTPSDowngradeGuardTests { + /// Invokes the guard synchronously and captures whatever request it decides to follow. + /// + /// - Parameters: + /// - responseURL: The URL of the response that requested the redirect. + /// - requestURL: The URL of the proposed redirected request. + /// - Returns: The request the guard passed to its completion handler, or `nil` if rejected. + private func redirect(from responseURL: URL, to requestURL: URL) -> URLRequest? { + let guardian = HTTPSDowngradeGuard() + let response = HTTPURLResponse( + url: responseURL, statusCode: 302, httpVersion: nil, headerFields: nil)! + let request = URLRequest(url: requestURL) + let task = URLSession.shared.dataTask(with: URLRequest(url: responseURL)) + var captured: URLRequest?! + guardian.urlSession( + .shared, task: task, willPerformHTTPRedirection: response, newRequest: request + ) { result in + captured = result + } + return captured + } + + @Test("An HTTPS to HTTP redirect is rejected") + func httpsToHTTPRejected() { + let result = redirect( + from: URL(string: "https://host/a")!, to: URL(string: "http://host/b")!) + #expect(result == nil) + } + + @Test("An HTTPS to HTTPS redirect passes through unchanged") + func httpsToHTTPSPassesThrough() { + let requestURL = URL(string: "https://host/b")! + let result = redirect(from: URL(string: "https://host/a")!, to: requestURL) + #expect(result?.url == requestURL) + } + + @Test("An HTTP to HTTP redirect passes through") + func httpToHTTPPassesThrough() { + let requestURL = URL(string: "http://host/b")! + let result = redirect(from: URL(string: "http://host/a")!, to: requestURL) + #expect(result?.url == requestURL) + } + + @Test("Uppercase scheme spellings still trigger the downgrade check") + func uppercaseSchemesNormalized() { + let result = redirect( + from: URL(string: "HTTPS://host/a")!, to: URL(string: "HTTP://host/b")!) + #expect(result == nil) + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift index b9279c4..8624509 100644 --- a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift +++ b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift @@ -181,6 +181,124 @@ private struct PublicUsersTransport: ClientTransport { } } + @Test("Quick Connect enabled reports the stubbed flag") + func quickConnectEnabledReportsFlag() async throws { + var api = MockJellyfinAPI() + api.getQuickConnectEnabledOutput = .ok(.init(body: .json(true))) + let client = makeClient(api) + + let enabled = try await client.quickConnectEnabled() + + #expect(enabled == true) + } + + @Test("Initiating Quick Connect maps the pairing state") + func initiateQuickConnectMapsState() async throws { + var api = MockJellyfinAPI() + api.initiateQuickConnectOutput = .ok( + .init( + body: .json( + Components.Schemas.QuickConnectResult( + authenticated: false, + secret: "sec1", + code: "ABC123" + ) + ) + ) + ) + let client = makeClient(api) + + let state = try await client.initiateQuickConnect() + + #expect(state.code == "ABC123") + #expect(state.secret == "sec1") + #expect(state.authenticated == false) + } + + @Test("Polling an approved Quick Connect request reports success") + func quickConnectStateReportsApproval() async throws { + var api = MockJellyfinAPI() + api.getQuickConnectStateOutput = .ok( + .init( + body: .json( + Components.Schemas.QuickConnectResult(authenticated: true, secret: "sec1") + ) + ) + ) + let client = makeClient(api) + + let state = try await client.quickConnectState(secret: "sec1") + + #expect(state.authenticated == true) + } + + @Test("Redeeming Quick Connect adopts the issued token") + func authenticateWithQuickConnectAdoptsToken() async throws { + var api = MockJellyfinAPI() + api.authenticateWithQuickConnectOutput = .ok( + .init( + body: .json( + Components.Schemas.AuthenticationResult( + user: .init(value1: Components.Schemas.UserDto(name: "echo", id: "u1", hasPassword: true)), + accessToken: "qc-tok", + serverId: "srv" + ) + ) + ) + ) + let client = makeClient(api) + + let result = try await client.authenticateWithQuickConnect(secret: "sec1") + + #expect(result.accessToken == "qc-tok") + #expect(await client.accessToken == "qc-tok") + #expect(await client.isAuthenticated) + } + + @Test("Updating a password records every supplied field") + func updatePasswordRecordsFields() async throws { + var api = MockJellyfinAPI() + api.updateUserPasswordOutput = .noContent + let client = makeClient(api) + + try await client.updateUserPassword( + userID: "u1", + currentPassword: "old", + currentPIN: "1234", + newPassword: "new", + resetPassword: false + ) + + let input = try #require(api.inputs.last("UpdateUserPassword", as: Operations.UpdateUserPassword.Input.self)) + #expect(input.query.userId == "u1") + guard case .json(let payload) = input.body else { + Issue.record("Expected a JSON update-password body") + return + } + #expect(payload.value1.currentPassword == "old") + #expect(payload.value1.currentPw == "1234") + #expect(payload.value1.newPw == "new") + #expect(payload.value1.resetPassword == false) + } + + @Test("The reduced-arity password overload sends nil PIN, userID, and reset flag") + func reducedArityPasswordOverloadSendsNils() async throws { + var api = MockJellyfinAPI() + api.updateUserPasswordOutput = .noContent + let client = makeClient(api) + + try await client.updateUserPassword(currentPassword: "old", newPassword: "new") + + let input = try #require(api.inputs.last("UpdateUserPassword", as: Operations.UpdateUserPassword.Input.self)) + #expect(input.query.userId == nil) + guard case .json(let payload) = input.body else { + Issue.record("Expected a JSON update-password body") + return + } + #expect(payload.value1.currentPw == nil) + #expect(payload.value1.resetPassword == nil) + } + @Test("Clearing the token signs the client out") func clearingTokenSignsOut() async { let client = makeClient(MockJellyfinAPI()) diff --git a/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift b/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift index 2100d55..6fd83df 100644 --- a/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift +++ b/Tests/LuminateServicesTests/JellyfinClientLibraryTests.swift @@ -146,4 +146,171 @@ import Testing _ = try await client.seasons(seriesID: "") } } + + @Test("A fully populated query maps every field onto the generated request") + func fullyPopulatedQueryMapsEveryField() async throws { + var api = MockJellyfinAPI() + api.getItemsOutput = .ok(.init(body: .json(movieResult))) + let client = makeClient(api) + + let query = JellyfinMediaQuery( + userID: "u1", + parentID: "p1", + includeItemKinds: [.movie], + searchTerm: "arrival", + sortBy: [.sortName], + sortOrder: [.ascending], + fields: [.overview], + startIndex: 20, + limit: 40, + recursive: true, + isFavorite: true, + isPlayed: false, + genres: ["Drama"], + years: [2020], + nameStartsWith: "A" + ) + + _ = try await client.items(query) + + let input = try #require(api.inputs.last("GetItems", as: Operations.GetItems.Input.self)) + #expect(input.query.userId == "u1") + #expect(input.query.parentId == "p1") + #expect(input.query.searchTerm == "arrival") + #expect(input.query.recursive == true) + #expect(input.query.isFavorite == true) + #expect(input.query.isPlayed == false) + #expect(input.query.genres == ["Drama"]) + #expect(input.query.years == [2020]) + #expect(input.query.nameStartsWith == "A") + #expect(input.query.startIndex == 20) + #expect(input.query.limit == 40) + #expect(input.query.includeItemTypes == [.movie]) + #expect(input.query.sortBy == [.sortName]) + #expect(input.query.sortOrder == [.ascending]) + #expect(input.query.fields == [.overview]) + } + + @Test("Fetching one item maps the DTO and forwards the item id and user id") + func fetchesOneItem() async throws { + var api = MockJellyfinAPI() + api.getItemOutput = .ok(.init(body: .json(Components.Schemas.BaseItemDto(name: "Arrival", id: "i1")))) + let client = makeClient(api) + + let item = try await client.item(id: "i1", userID: "u9") + + #expect(item.id == "i1") + #expect(item.name == "Arrival") + let input = try #require(api.inputs.last("GetItem", as: Operations.GetItem.Input.self)) + #expect(input.path.itemId == "i1") + #expect(input.query.userId == "u9") + } + + @Test("An empty item identifier is rejected before any request is sent") + func emptyItemIdentifierRejected() async { + let client = makeClient(MockJellyfinAPI()) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "id")) { + _ = try await client.item(id: "", userID: nil) + } + } + + @Test("resumeItems forwards paging and defaults the item-kind filter to movies and episodes") + func resumeItemsDefaultsItemKinds() async throws { + var api = MockJellyfinAPI() + api.getResumeItemsOutput = .ok(.init(body: .json(movieResult))) + let client = makeClient(api) + + _ = try await client.resumeItems(JellyfinListOptions(startIndex: 5, limit: 10)) + + let input = try #require(api.inputs.last("GetResumeItems", as: Operations.GetResumeItems.Input.self)) + #expect(input.query.startIndex == 5) + #expect(input.query.limit == 10) + #expect(input.query.includeItemTypes == [.movie, .episode]) + } + + @Test("nextUp records an explicit series id, or omits it for every series") + func nextUpRecordsSeriesID() async throws { + var api = MockJellyfinAPI() + api.getNextUpOutput = .ok(.init(body: .json(movieResult))) + let client = makeClient(api) + + _ = try await client.nextUp(seriesID: "s1", options: JellyfinListOptions()) + let withSeries = try #require(api.inputs.last("GetNextUp", as: Operations.GetNextUp.Input.self)) + #expect(withSeries.query.seriesId == "s1") + + _ = try await client.nextUp(seriesID: nil, options: JellyfinListOptions()) + let withoutSeries = try #require(api.inputs.last("GetNextUp", as: Operations.GetNextUp.Input.self)) + #expect(withoutSeries.query.seriesId == nil) + } + + @Test("latestMedia maps a plain item list and forwards its options") + func latestMediaMapsPlainList() async throws { + var api = MockJellyfinAPI() + api.getLatestMediaOutput = .ok( + .init( + body: .json([ + Components.Schemas.BaseItemDto(name: "Arrival", id: "i1"), + Components.Schemas.BaseItemDto(name: "Dune", id: "i2"), + ]) + ) + ) + let client = makeClient(api) + + let items = try await client.latestMedia(JellyfinListOptions(parentID: "lib1", limit: 2)) + + #expect(items.map(\.id) == ["i1", "i2"]) + #expect(items.map(\.name) == ["Arrival", "Dune"]) + let input = try #require(api.inputs.last("GetLatestMedia", as: Operations.GetLatestMedia.Input.self)) + #expect(input.query.parentId == "lib1") + #expect(input.query.limit == 2) + } + + @Test("episodes forwards season and season-id filters") + func episodesForwardsSeasonFilters() async throws { + var api = MockJellyfinAPI() + api.getEpisodesOutput = .ok(.init(body: .json(movieResult))) + let client = makeClient(api) + + _ = try await client.episodes( + seriesID: "series1", + options: JellyfinListOptions(seasonID: "season-1", season: 2) + ) + + let input = try #require(api.inputs.last("GetEpisodes", as: Operations.GetEpisodes.Input.self)) + #expect(input.path.seriesId == "series1") + #expect(input.query.season == 2) + #expect(input.query.seasonId == "season-1") + } + + @Test("searchHints maps hints and forwards paging") + func searchHintsMapsAndForwardsPaging() async throws { + var api = MockJellyfinAPI() + api.getSearchHintsOutput = .ok( + .init( + body: .json( + Components.Schemas.SearchHintResult( + searchHints: [ + Components.Schemas.SearchHint(id: "h1", name: "Arrival", matchedTerm: "arrival"), + Components.Schemas.SearchHint(id: "h2", name: "Arrival 2", matchedTerm: "arrival"), + ] + ) + ) + ) + ) + let client = makeClient(api) + + let hints = try await client.searchHints( + term: "arrival", + options: JellyfinListOptions(startIndex: 10, limit: 5) + ) + + #expect(hints.map(\.id) == ["h1", "h2"]) + #expect(hints.map(\.name) == ["Arrival", "Arrival 2"]) + #expect(hints.map(\.matchedTerm) == ["arrival", "arrival"]) + let input = try #require(api.inputs.last("GetSearchHints", as: Operations.GetSearchHints.Input.self)) + #expect(input.query.startIndex == 10) + #expect(input.query.limit == 5) + #expect(input.query.searchTerm == "arrival") + } } diff --git a/Tests/LuminateServicesTests/JellyfinClientSystemTests.swift b/Tests/LuminateServicesTests/JellyfinClientSystemTests.swift new file mode 100644 index 0000000..dde2dee --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientSystemTests.swift @@ -0,0 +1,79 @@ +// +// JellyfinClientSystemTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import LuminateAPI +import LuminateCore +import Testing + +@testable import LuminateServices + +/// Covers the authenticated server-identification call and its DTO-to-domain mapping. +/// +/// `publicServerInfo()` is exercised elsewhere; this suite is only `serverInfo()`. +@Suite struct JellyfinClientSystemTests { + /// Builds a client wired to a double. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!), + api: api + ) + } + + @Test("Maps the signed-in server's system information") + func mapsServerInfo() async throws { + var api = MockJellyfinAPI() + api.getSystemInfoOutput = .ok( + .init( + body: .json( + Components.Schemas.SystemInfo( + serverName: "Echo's Server", + version: "10.11.10", + productName: "Jellyfin Server", + id: "srv-1" + ) + ) + ) + ) + let client = makeClient(api) + + let info = try await client.serverInfo() + + #expect(info.serverName == "Echo's Server") + #expect(info.version == "10.11.10") + #expect(info.id == "srv-1") + #expect(info.productName == "Jellyfin Server") + } + + @Test("A forbidden response throws before returning any information") + func forbiddenThrows() async { + var api = MockJellyfinAPI() + api.getSystemInfoOutput = .forbidden(.init(body: .json(Components.Schemas.ProblemDetails()))) + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.forbidden) { + _ = try await client.serverInfo() + } + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift b/Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift new file mode 100644 index 0000000..6c8a177 --- /dev/null +++ b/Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift @@ -0,0 +1,146 @@ +// +// JellyfinClientUserStateTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import LuminateAPI +import LuminateCore +import Testing + +@testable import LuminateServices + +/// Covers the watched-state and favourite mutations, and their itemID validation. +@Suite struct JellyfinClientUserStateTests { + /// Builds a client wired to a double. + /// + /// - Parameter api: The stubbed API. + /// - Returns: A client that routes every call to `api`. + private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient { + JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!), + api: api + ) + } + + @Test("Marking an item played returns the server's recomputed state and forwards the user and date") + func markPlayedSucceeds() async throws { + var api = MockJellyfinAPI() + api.markPlayedItemOutput = .ok( + .init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: true, played: true))) + ) + let client = makeClient(api) + let datePlayed = Date(timeIntervalSince1970: 1_700_000_000) + + let userData = try await client.markPlayed(itemID: "i1", userID: "u1", datePlayed: datePlayed) + + #expect(userData.isFavorite == true) + #expect(userData.played == true) + + let input = try #require(api.inputs.last("MarkPlayedItem", as: Operations.MarkPlayedItem.Input.self)) + #expect(input.query.userId == "u1") + #expect(input.query.datePlayed == datePlayed) + } + + @Test("An empty item identifier rejects markPlayed before any request is sent") + func markPlayedEmptyItemIDRejected() async { + let api = MockJellyfinAPI() + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) { + _ = try await client.markPlayed(itemID: "", userID: nil, datePlayed: nil) + } + #expect(api.inputs.last("MarkPlayedItem", as: Operations.MarkPlayedItem.Input.self) == nil) + } + + @Test("Marking an item unplayed returns the server's recomputed state") + func markUnplayedSucceeds() async throws { + var api = MockJellyfinAPI() + api.markUnplayedItemOutput = .ok( + .init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: false, played: false))) + ) + let client = makeClient(api) + + let userData = try await client.markUnplayed(itemID: "i1", userID: nil) + + #expect(userData.isFavorite == false) + #expect(userData.played == false) + } + + @Test("An empty item identifier rejects markUnplayed before any request is sent") + func markUnplayedEmptyItemIDRejected() async { + let api = MockJellyfinAPI() + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) { + _ = try await client.markUnplayed(itemID: "", userID: nil) + } + #expect(api.inputs.last("MarkUnplayedItem", as: Operations.MarkUnplayedItem.Input.self) == nil) + } + + @Test("Marking an item a favourite returns the server's recomputed state") + func markFavoriteSucceeds() async throws { + var api = MockJellyfinAPI() + api.markFavoriteItemOutput = .ok( + .init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: true, played: false))) + ) + let client = makeClient(api) + + let userData = try await client.markFavorite(itemID: "i1", userID: nil) + + #expect(userData.isFavorite == true) + #expect(userData.played == false) + } + + @Test("An empty item identifier rejects markFavorite before any request is sent") + func markFavoriteEmptyItemIDRejected() async { + let api = MockJellyfinAPI() + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) { + _ = try await client.markFavorite(itemID: "", userID: nil) + } + #expect(api.inputs.last("MarkFavoriteItem", as: Operations.MarkFavoriteItem.Input.self) == nil) + } + + @Test("Unmarking an item a favourite returns the server's recomputed state") + func unmarkFavoriteSucceeds() async throws { + var api = MockJellyfinAPI() + api.unmarkFavoriteItemOutput = .ok( + .init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: false, played: true))) + ) + let client = makeClient(api) + + let userData = try await client.unmarkFavorite(itemID: "i1", userID: nil) + + #expect(userData.isFavorite == false) + #expect(userData.played == true) + } + + @Test("An empty item identifier rejects unmarkFavorite before any request is sent") + func unmarkFavoriteEmptyItemIDRejected() async { + let api = MockJellyfinAPI() + let client = makeClient(api) + + await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) { + _ = try await client.unmarkFavorite(itemID: "", userID: nil) + } + #expect(api.inputs.last("UnmarkFavoriteItem", as: Operations.UnmarkFavoriteItem.Input.self) == nil) + } +} diff --git a/Tests/LuminateServicesTests/MockJellyfinAPI.swift b/Tests/LuminateServicesTests/MockJellyfinAPI.swift index 7e4a63a..e979874 100644 --- a/Tests/LuminateServicesTests/MockJellyfinAPI.swift +++ b/Tests/LuminateServicesTests/MockJellyfinAPI.swift @@ -21,14 +21,37 @@ import LuminateAPI import LuminateServices +import Synchronization import Testing +/// Records the last input each operation received, so a test can assert parameter mapping. +/// +/// A reference type with a `Mutex` rather than a mutable struct field: `MockJellyfinAPI` is +/// copied into the actor-isolated `JellyfinClient`, so a value-typed log would record into a +/// copy the test cannot see. +final class OperationInputLog: Sendable { + private let entries = Mutex<[String: any Sendable]>([:]) + + /// Stores `input` as the most recent call to `operation`. + func record(_ input: any Sendable, for operation: String) { + entries.withLock { $0[operation] = input } + } + + /// The most recent input recorded for `operation`, or `nil` when it was never called. + func last(_ operation: String, as type: Input.Type) -> Input? { + entries.withLock { $0[operation] as? Input } + } +} + /// A ``JellyfinAPI`` double that answers with pre-baked generated outputs. /// /// Only the operations a test actually stubs are usable; every other call fails the test with a /// clear message rather than returning a fabricated success. Outputs are plain `Sendable` values, /// so the whole double is a `Sendable` struct with no isolation escape hatches. struct MockJellyfinAPI: JellyfinAPI { + /// Records the input each operation method received, keyed by operation identifier. + let inputs = OperationInputLog() + /// The output returned by ``authenticateUserByName(_:)``. var authenticateUserByNameOutput: Operations.AuthenticateUserByName.Output? @@ -115,116 +138,140 @@ struct MockJellyfinAPI: JellyfinAPI { func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws -> Operations.AuthenticateUserByName.Output { - try unwrap(authenticateUserByNameOutput, "AuthenticateUserByName") + inputs.record(input, for: "AuthenticateUserByName") + return try unwrap(authenticateUserByNameOutput, "AuthenticateUserByName") } func authenticateWithQuickConnect(_ input: Operations.AuthenticateWithQuickConnect.Input) async throws -> Operations.AuthenticateWithQuickConnect.Output { - try unwrap(authenticateWithQuickConnectOutput, "AuthenticateWithQuickConnect") + inputs.record(input, for: "AuthenticateWithQuickConnect") + return try unwrap(authenticateWithQuickConnectOutput, "AuthenticateWithQuickConnect") } func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output { - try unwrap(getPublicUsersOutput, "GetPublicUsers") + inputs.record(input, for: "GetPublicUsers") + return try unwrap(getPublicUsersOutput, "GetPublicUsers") } func getPublicSystemInfo(_ input: Operations.GetPublicSystemInfo.Input) async throws -> Operations.GetPublicSystemInfo.Output { - try unwrap(getPublicSystemInfoOutput, "GetPublicSystemInfo") + inputs.record(input, for: "GetPublicSystemInfo") + return try unwrap(getPublicSystemInfoOutput, "GetPublicSystemInfo") } func getSystemInfo(_ input: Operations.GetSystemInfo.Input) async throws -> Operations.GetSystemInfo.Output { - try unwrap(getSystemInfoOutput, "GetSystemInfo") + inputs.record(input, for: "GetSystemInfo") + return try unwrap(getSystemInfoOutput, "GetSystemInfo") } func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws -> Operations.GetQuickConnectEnabled.Output { - try unwrap(getQuickConnectEnabledOutput, "GetQuickConnectEnabled") + inputs.record(input, for: "GetQuickConnectEnabled") + return try unwrap(getQuickConnectEnabledOutput, "GetQuickConnectEnabled") } func initiateQuickConnect(_ input: Operations.InitiateQuickConnect.Input) async throws -> Operations.InitiateQuickConnect.Output { - try unwrap(initiateQuickConnectOutput, "InitiateQuickConnect") + inputs.record(input, for: "InitiateQuickConnect") + return try unwrap(initiateQuickConnectOutput, "InitiateQuickConnect") } func getQuickConnectState(_ input: Operations.GetQuickConnectState.Input) async throws -> Operations.GetQuickConnectState.Output { - try unwrap(getQuickConnectStateOutput, "GetQuickConnectState") + inputs.record(input, for: "GetQuickConnectState") + return try unwrap(getQuickConnectStateOutput, "GetQuickConnectState") } func updateUserPassword(_ input: Operations.UpdateUserPassword.Input) async throws -> Operations.UpdateUserPassword.Output { - try unwrap(updateUserPasswordOutput, "UpdateUserPassword") + inputs.record(input, for: "UpdateUserPassword") + return try unwrap(updateUserPasswordOutput, "UpdateUserPassword") } func getUserViews(_ input: Operations.GetUserViews.Input) async throws -> Operations.GetUserViews.Output { - try unwrap(getUserViewsOutput, "GetUserViews") + inputs.record(input, for: "GetUserViews") + return try unwrap(getUserViewsOutput, "GetUserViews") } func getItems(_ input: Operations.GetItems.Input) async throws -> Operations.GetItems.Output { - try unwrap(getItemsOutput, "GetItems") + inputs.record(input, for: "GetItems") + return try unwrap(getItemsOutput, "GetItems") } func getItem(_ input: Operations.GetItem.Input) async throws -> Operations.GetItem.Output { - try unwrap(getItemOutput, "GetItem") + inputs.record(input, for: "GetItem") + return try unwrap(getItemOutput, "GetItem") } func getResumeItems(_ input: Operations.GetResumeItems.Input) async throws -> Operations.GetResumeItems.Output { - try unwrap(getResumeItemsOutput, "GetResumeItems") + inputs.record(input, for: "GetResumeItems") + return try unwrap(getResumeItemsOutput, "GetResumeItems") } func getNextUp(_ input: Operations.GetNextUp.Input) async throws -> Operations.GetNextUp.Output { - try unwrap(getNextUpOutput, "GetNextUp") + inputs.record(input, for: "GetNextUp") + return try unwrap(getNextUpOutput, "GetNextUp") } func getLatestMedia(_ input: Operations.GetLatestMedia.Input) async throws -> Operations.GetLatestMedia.Output { - try unwrap(getLatestMediaOutput, "GetLatestMedia") + inputs.record(input, for: "GetLatestMedia") + return try unwrap(getLatestMediaOutput, "GetLatestMedia") } func getSeasons(_ input: Operations.GetSeasons.Input) async throws -> Operations.GetSeasons.Output { - try unwrap(getSeasonsOutput, "GetSeasons") + inputs.record(input, for: "GetSeasons") + return try unwrap(getSeasonsOutput, "GetSeasons") } func getEpisodes(_ input: Operations.GetEpisodes.Input) async throws -> Operations.GetEpisodes.Output { - try unwrap(getEpisodesOutput, "GetEpisodes") + inputs.record(input, for: "GetEpisodes") + return try unwrap(getEpisodesOutput, "GetEpisodes") } func getSearchHints(_ input: Operations.GetSearchHints.Input) async throws -> Operations.GetSearchHints.Output { - try unwrap(getSearchHintsOutput, "GetSearchHints") + inputs.record(input, for: "GetSearchHints") + return try unwrap(getSearchHintsOutput, "GetSearchHints") } func getItemImage(_ input: Operations.GetItemImage.Input) async throws -> Operations.GetItemImage.Output { - try unwrap(getItemImageOutput, "GetItemImage") + inputs.record(input, for: "GetItemImage") + return try unwrap(getItemImageOutput, "GetItemImage") } func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws -> Operations.GetItemImageByIndex.Output { - try unwrap(getItemImageByIndexOutput, "GetItemImageByIndex") + inputs.record(input, for: "GetItemImageByIndex") + return try unwrap(getItemImageByIndexOutput, "GetItemImageByIndex") } func markPlayedItem(_ input: Operations.MarkPlayedItem.Input) async throws -> Operations.MarkPlayedItem.Output { - try unwrap(markPlayedItemOutput, "MarkPlayedItem") + inputs.record(input, for: "MarkPlayedItem") + return try unwrap(markPlayedItemOutput, "MarkPlayedItem") } func markUnplayedItem(_ input: Operations.MarkUnplayedItem.Input) async throws -> Operations.MarkUnplayedItem.Output { - try unwrap(markUnplayedItemOutput, "MarkUnplayedItem") + inputs.record(input, for: "MarkUnplayedItem") + return try unwrap(markUnplayedItemOutput, "MarkUnplayedItem") } func markFavoriteItem(_ input: Operations.MarkFavoriteItem.Input) async throws -> Operations.MarkFavoriteItem.Output { - try unwrap(markFavoriteItemOutput, "MarkFavoriteItem") + inputs.record(input, for: "MarkFavoriteItem") + return try unwrap(markFavoriteItemOutput, "MarkFavoriteItem") } func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws -> Operations.UnmarkFavoriteItem.Output { - try unwrap(unmarkFavoriteItemOutput, "UnmarkFavoriteItem") + inputs.record(input, for: "UnmarkFavoriteItem") + return try unwrap(unmarkFavoriteItemOutput, "UnmarkFavoriteItem") } } diff --git a/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift b/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift index e3c6ce0..88bfdc4 100644 --- a/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift +++ b/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift @@ -101,6 +101,36 @@ import Testing #expect(reopened.initialSnapshot["server.url"] == .text("second")) } + @Test("A write failure surfaces once and does not repeat on the next flush") + func writeFailureIsRetainedThenCleared() async throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + let saboteur = try SQLiteDatabase(url: url) + try saboteur.exec("DROP TABLE preference;") + store.enqueue("server.url", .text("http://x")) + await #expect(throws: PreferenceStoreError.self) { try await store.flush() } + try await store.flush() + } + + @Test("Opening a database at a directory path fails to open") + func opensDirectoryFails() throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-store-dir-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + do { + _ = try SQLiteDatabase(url: directory) + Issue.record("Expected SQLiteDatabase(url:) to throw for a directory path") + } catch PreferenceStoreError.cannotOpen(let path, _) { + #expect(path == directory.path) + } catch { + Issue.record("Expected PreferenceStoreError.cannotOpen, got \(error)") + } + } + private func makeDatabaseURL() -> (URL, URL) { let directory = FileManager.default.temporaryDirectory .appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory) diff --git a/Tests/LuminateStoreTests/SQLiteStatementTests.swift b/Tests/LuminateStoreTests/SQLiteStatementTests.swift new file mode 100644 index 0000000..b1531e8 --- /dev/null +++ b/Tests/LuminateStoreTests/SQLiteStatementTests.swift @@ -0,0 +1,96 @@ +// +// SQLiteStatementTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Testing + +@testable import LuminateCore +@testable import LuminateStore + +@Suite struct SQLiteStatementTests { + @Test("Preparing malformed SQL fails with a non-empty diagnostic") + func prepareFailureReportsDiagnostic() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + + #expect(throws: PreferenceStoreError.self) { + _ = try SQLiteStatement(database: database, sql: "NOT SQL") + } + + do { + _ = try SQLiteStatement(database: database, sql: "NOT SQL") + Issue.record("Expected malformed SQL to throw") + } catch let error as PreferenceStoreError { + if case .sqlite(let message) = error { + #expect(!message.isEmpty) + } else { + Issue.record("Expected .sqlite, got \(error)") + } + } + } + + @Test("Binding past the parameter count fails") + func bindOutOfRangeFails() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + let statement = try SQLiteStatement(database: database, sql: "SELECT ?1;") + + #expect(throws: PreferenceStoreError.self) { + try statement.bind(99, text: "x") + } + } + + @Test("A constraint violation on step fails") + func stepConstraintViolationFails() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL);") + let statement = try SQLiteStatement(database: database, sql: "INSERT INTO t (v) VALUES (NULL);") + + #expect(throws: PreferenceStoreError.self) { + _ = try statement.step() + } + } + + @Test("A NULL result column decodes as nil") + func nullColumnDecodesAsNil() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + let statement = try SQLiteStatement(database: database, sql: "SELECT NULL;") + _ = try statement.step() + + #expect(statement.preference(at: 0) == nil) + } + + private func makeDatabaseURL() -> (URL, URL) { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory) + return (directory.appending(path: "preferences.sqlite"), directory) + } +} diff --git a/Tests/LuminateTests/ClientSessionBinderTests.swift b/Tests/LuminateTests/ClientSessionBinderTests.swift new file mode 100644 index 0000000..e269022 --- /dev/null +++ b/Tests/LuminateTests/ClientSessionBinderTests.swift @@ -0,0 +1,113 @@ +// +// ClientSessionBinderTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +@_spi(SGTKInternal) import Gtk +import LuminateCore +import LuminateServices +import LuminateUI +import Testing + +@testable import Luminate + +/// Exercises the wiring between session preferences and the live Jellyfin client. +@Suite(.serialized) @MainActor struct ClientSessionBinderTests { + @Test("Writing the server URL preference reconfigures the client") + func serverURLPreferenceReconfiguresClient() async { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil)) + let binder = ClientSessionBinder(client: client, preferences: preferences) + let expected = URL(string: "http://jellyfin.test:8096")! + + preferences[.serverURL] = expected + await asyncPump { await client.serverURL == expected } + + #expect(await client.serverURL == expected) + withExtendedLifetime(binder) {} + } + + @Test("Writing and clearing the access token updates authentication state") + func accessTokenPreferenceUpdatesAuthenticationState() async { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil)) + let binder = ClientSessionBinder(client: client, preferences: preferences) + + preferences[.accessToken] = "tok" + await asyncPump { await client.accessToken == "tok" } + #expect(await client.accessToken == "tok") + #expect(await client.isAuthenticated) + + preferences[.accessToken] = nil + await asyncPump { await client.accessToken == nil } + #expect(await client.accessToken == nil) + #expect(await client.isAuthenticated == false) + + withExtendedLifetime(binder) {} + } + + @Test("A nil server URL write is ignored") + func nilServerURLWriteIsIgnored() async { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil)) + let binder = ClientSessionBinder(client: client, preferences: preferences) + let established = URL(string: "http://jellyfin.test:8096")! + + preferences[.serverURL] = established + await asyncPump { await client.serverURL == established } + #expect(await client.serverURL == established) + + preferences[.serverURL] = nil + await asyncPump(turns: 50) { false } + + #expect(await client.serverURL == established) + withExtendedLifetime(binder) {} + } + + /// Drives the GLib default main context, yielding the task each turn, until `condition` + /// reports true or `turns` is exhausted. + /// + /// Two independent async hops must both complete before the client actor reflects a + /// preference write: Portico's idle-deferred Observation flush (which invokes the binder's + /// subscription closure) and the `Task { await client.set... }` that closure spawns. A single + /// main-context pump only satisfies the first hop, so this loops - repeating both the GLib + /// pump and `Task.yield()` - and lets `condition` `await` the actor's state directly, since a + /// synchronous condition cannot observe it. Mirrors + /// `Tests/LuminateUITests/PreferenceTests.swift`'s `asyncPump`, which exists for the same + /// swift-testing-does-not-run-@MainActor-bodies-on-the-initial-thread reason. + private func asyncPump(turns: Int = 500, until condition: () async -> Bool) async { + for _ in 0.. Int32 diff --git a/Tests/LuminateUITests/PreferenceTests.swift b/Tests/LuminateUITests/PreferenceTests.swift index 7e4fcbb..e449c33 100644 --- a/Tests/LuminateUITests/PreferenceTests.swift +++ b/Tests/LuminateUITests/PreferenceTests.swift @@ -41,6 +41,21 @@ private final class SessionRecorder { var values: [Bool] = [] } +/// Captures the projected binding of a mounted `@Preference`, so a test can act on it after mount. +@MainActor private final class BindingCapture { + var binding: Portico.Binding? +} + +private struct BindingProbeView: View { + @Preference(.serverURL) private var serverURL: URL? + let capture: BindingCapture + + var body: some View { + capture.binding = $serverURL + return Label(str: "").label { serverURL?.absoluteString ?? "" } + } +} + /// Exercises preference mounting, observation, and persistence wiring. @Suite(.serialized) @MainActor struct PreferenceTests { @Test("An unset preference reads nil at mount") @@ -70,7 +85,7 @@ private final class SessionRecorder { } @Test("Writing a preference updates the same mounted label") - func liveUpdate() { + func liveUpdate() async { guard Gtk.initCheck() else { return } let preferences = Preferences(store: EphemeralPreferenceStore()) let context = MountContext() @@ -81,7 +96,7 @@ private final class SessionRecorder { let pointer = label.pointer preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096") - pump { + await asyncPump { label.getText() == "http://jellyfin.test:8096" } @@ -142,7 +157,7 @@ private final class SessionRecorder { } @Test("Writing the access token re-evaluates a tracker that read the session state") - func authenticationDrivesTrackedReevaluation() { + func authenticationDrivesTrackedReevaluation() async { guard Gtk.initCheck() else { return } let preferences = Preferences(store: EphemeralPreferenceStore()) let recorder = SessionRecorder() @@ -151,20 +166,71 @@ private final class SessionRecorder { defer { tracker.teardown() } preferences.slot(for: .accessToken).value = "tok" - pump { recorder.values.count > 1 } + await asyncPump { recorder.values.count > 1 } #expect(recorder.values == [false, true]) } - private func pump(until condition: () -> Bool = { false }, turns: Int = 200) { + @Test("A binding writes through to the preference slot") + func bindingWritesThrough() async throws { + let store = EphemeralPreferenceStore() + let preferences = Preferences(store: store) + let binding = preferences.binding(for: .serverURL) + + binding.wrappedValue = URL(string: "http://jellyfin.test:8096") + + #expect(preferences[.serverURL] == URL(string: "http://jellyfin.test:8096")) + try await store.flush() + #expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096")) + } + + @Test("A stored value of the wrong type is treated as unset") + func restoredValueTypeMismatchIsIgnored() { + guard Gtk.initCheck() else { return } + let store = EphemeralPreferenceStore(initialSnapshot: ["server.url": .integer(1)]) + let preferences = Preferences(store: store) + let context = MountContext() + let label = + AnyView(PreferenceLabel().environment(\.preferences, preferences)) + .makeWidget(context) as! Gtk.Label + defer { context.registry.teardown() } + + #expect(label.getText() == "No server configured") + } + + @Test("The projected binding writes back through wrappedValue") + func projectedValueWritesThrough() throws { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let capture = BindingCapture() + let context = MountContext() + _ = AnyView(BindingProbeView(capture: capture).environment(\.preferences, preferences)) + .makeWidget(context) + defer { context.registry.teardown() } + + let binding = try #require(capture.binding) + binding.wrappedValue = URL(string: "http://projected.test") + + #expect(preferences[.serverURL] == URL(string: "http://projected.test")) + } + + /// Drives the GLib default main context, yielding each turn so the Observation bridge's + /// main-actor hop can run. swift-testing does not run `@MainActor` bodies on the process + /// initial thread, so `_porticoObservationDidChange` defers through a `Task` that only + /// runs at a suspension point; a non-suspending pump would starve it forever. + private func asyncPump(until condition: () -> Bool = { false }, turns: Int = 500) async { for _ in 0..