Compare commits
2 commits
main
...
wip/onboar
| Author | SHA1 | Date | |
|---|---|---|---|
| a96d43a603 | |||
| b852979215 |
45 changed files with 3875 additions and 36 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@ Package.resolved
|
|||
*.xcodeproj
|
||||
*.xcworkspace
|
||||
xcuserdata/
|
||||
|
||||
# Code coverage files
|
||||
*.profraw
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
@ -130,7 +151,7 @@ let package = Package(
|
|||
),
|
||||
.testTarget(
|
||||
name: "LuminateTests",
|
||||
dependencies: ["LuminateCore", "LuminateStore", "LuminateUI"],
|
||||
dependencies: ["Luminate", "LuminateCore", "LuminateServices", "LuminateStore", "LuminateUI"],
|
||||
swiftSettings: uiSwiftSettings
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
62
Sources/LuminateCore/Errors/ServerAddressError.swift
Normal file
62
Sources/LuminateCore/Errors/ServerAddressError.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// ServerAddressError.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Sources/LuminateCore/Errors/ServerConnectionError.swift
Normal file
43
Sources/LuminateCore/Errors/ServerConnectionError.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// ServerConnectionError.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Sources/LuminateCore/Models/JellyfinQuickConnect.swift
Normal file
129
Sources/LuminateCore/Models/JellyfinQuickConnect.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//
|
||||
// JellyfinQuickConnect.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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<Event, Error> {
|
||||
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<Event, Error>.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..<maxPolls {
|
||||
try Task.checkCancellation()
|
||||
|
||||
if try await service.quickConnectState(secret: secret).authenticated == true {
|
||||
continuation.yield(.authenticated(secret: secret))
|
||||
return
|
||||
}
|
||||
|
||||
try await Task.sleep(for: pollInterval)
|
||||
}
|
||||
|
||||
throw JellyfinClientError.quickConnectTimedOut
|
||||
}
|
||||
}
|
||||
32
Sources/LuminateCore/Models/OnboardDestination.swift
Normal file
32
Sources/LuminateCore/Models/OnboardDestination.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// OnboardDestination.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
139
Sources/LuminateCore/Models/ServerAddress.swift
Normal file
139
Sources/LuminateCore/Models/ServerAddress.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//
|
||||
// ServerAddress.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
50
Sources/LuminateCore/Models/ServerConnection.swift
Normal file
50
Sources/LuminateCore/Models/ServerConnection.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// ServerConnection.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// JellyfinService+QuickConnect.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
76
Sources/LuminateOnboarding/ForgotPasswordPage.swift
Normal file
76
Sources/LuminateOnboarding/ForgotPasswordPage.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// ForgotPasswordPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
38
Sources/LuminateOnboarding/IntroPage.swift
Normal file
38
Sources/LuminateOnboarding/IntroPage.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// IntroPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
135
Sources/LuminateOnboarding/LoginPage.swift
Normal file
135
Sources/LuminateOnboarding/LoginPage.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//
|
||||
// LoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
97
Sources/LuminateOnboarding/ManualLoginPage.swift
Normal file
97
Sources/LuminateOnboarding/ManualLoginPage.swift
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
// ManualLoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Sources/LuminateOnboarding/OnboardWindow.swift
Normal file
65
Sources/LuminateOnboarding/OnboardWindow.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// OnboardWindow.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
Sources/LuminateOnboarding/QuickConnectPage.swift
Normal file
154
Sources/LuminateOnboarding/QuickConnectPage.swift
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//
|
||||
// QuickConnectPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
158
Sources/LuminateOnboarding/ServerConfigPage.swift
Normal file
158
Sources/LuminateOnboarding/ServerConfigPage.swift
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//
|
||||
// ServerConfigPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
Sources/LuminateOnboarding/UserLoginPage.swift
Normal file
105
Sources/LuminateOnboarding/UserLoginPage.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// UserLoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift
Normal file
48
Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// HTTPSDowngradeGuard.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// ServerConnectionProbe.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
32
Sources/LuminateUI/Preferences/Preferences+Session.swift
Normal file
32
Sources/LuminateUI/Preferences/Preferences+Session.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Preferences+Session.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift
Normal file
133
Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//
|
||||
// JellyfinQuickConnectTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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<JellyfinQuickConnectState, Error>]
|
||||
private(set) var callCount = 0
|
||||
|
||||
init(_ queue: [Result<JellyfinQuickConnectState, Error>]) {
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
func next() throws -> JellyfinQuickConnectState {
|
||||
callCount += 1
|
||||
guard !queue.isEmpty else { throw JellyfinClientError.notFound }
|
||||
return try queue.removeFirst().get()
|
||||
}
|
||||
}
|
||||
253
Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift
Normal file
253
Tests/LuminateCoreTests/JellyfinServiceConvenienceTests.swift
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
//
|
||||
// JellyfinServiceConvenienceTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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<JellyfinListOptions?>(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<JellyfinMediaQuery?>(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<JellyfinListOptions?>(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<JellyfinListOptions?>(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)
|
||||
}
|
||||
}
|
||||
233
Tests/LuminateCoreTests/JellyfinServiceMock.swift
Normal file
233
Tests/LuminateCoreTests/JellyfinServiceMock.swift
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//
|
||||
// JellyfinServiceMock.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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: 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
176
Tests/LuminateCoreTests/ServerAddressTests.swift
Normal file
176
Tests/LuminateCoreTests/ServerAddressTests.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//
|
||||
// ServerAddressTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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("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.invalidPort("70000").message
|
||||
== "70000 is not a valid port. Enter a number from 1 to 65535")
|
||||
}
|
||||
}
|
||||
58
Tests/LuminateCoreTests/ServerConnectionErrorTests.swift
Normal file
58
Tests/LuminateCoreTests/ServerConnectionErrorTests.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// ServerConnectionErrorTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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.")
|
||||
}
|
||||
}
|
||||
46
Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift
Normal file
46
Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// ServerConnectionSummaryTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
Tests/LuminateOnboardingTests/OnboardWindowTests.swift
Normal file
33
Tests/LuminateOnboardingTests/OnboardWindowTests.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// OnboardWindowTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
41
Tests/LuminateOnboardingTests/ServerConfigPageTests.swift
Normal file
41
Tests/LuminateOnboardingTests/ServerConfigPageTests.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// ServerConfigPageTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
80
Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift
Normal file
80
Tests/LuminateServicesTests/HTTPSDowngradeGuardTests.swift
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//
|
||||
// HTTPSDowngradeGuardTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -101,6 +181,124 @@ import Testing
|
|||
}
|
||||
}
|
||||
|
||||
@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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
Tests/LuminateServicesTests/JellyfinClientSystemTests.swift
Normal file
79
Tests/LuminateServicesTests/JellyfinClientSystemTests.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
// JellyfinClientSystemTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
146
Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift
Normal file
146
Tests/LuminateServicesTests/JellyfinClientUserStateTests.swift
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//
|
||||
// JellyfinClientUserStateTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Input: Sendable>(_ 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
162
Tests/LuminateServicesTests/ServerConnectionProbeTests.swift
Normal file
162
Tests/LuminateServicesTests/ServerConnectionProbeTests.swift
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//
|
||||
// ServerConnectionProbeTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
96
Tests/LuminateStoreTests/SQLiteStatementTests.swift
Normal file
96
Tests/LuminateStoreTests/SQLiteStatementTests.swift
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//
|
||||
// SQLiteStatementTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
113
Tests/LuminateTests/ClientSessionBinderTests.swift
Normal file
113
Tests/LuminateTests/ClientSessionBinderTests.swift
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//
|
||||
// ClientSessionBinderTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// 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..<turns {
|
||||
if await condition() { return }
|
||||
_ = clientSessionBinder_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPSTREAM: see Tests/LuminateUITests/PreferenceTests.swift - Portico should expose an async
|
||||
// main-loop pump for tests so suites do not need @_silgen_name.
|
||||
@_silgen_name("g_main_context_iteration")
|
||||
private nonisolated func clientSessionBinder_g_main_context_iteration(
|
||||
_ context: UnsafeMutableRawPointer?,
|
||||
_ mayBlock: Int32
|
||||
) -> Int32
|
||||
|
|
@ -36,6 +36,26 @@ 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] = []
|
||||
}
|
||||
|
||||
/// 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<URL?>?
|
||||
}
|
||||
|
||||
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")
|
||||
|
|
@ -65,7 +85,7 @@ private struct PreferenceLabel: View {
|
|||
}
|
||||
|
||||
@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()
|
||||
|
|
@ -76,7 +96,7 @@ private struct PreferenceLabel: View {
|
|||
let pointer = label.pointer
|
||||
|
||||
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
|
||||
pump {
|
||||
await asyncPump {
|
||||
label.getText() == "http://jellyfin.test:8096"
|
||||
}
|
||||
|
||||
|
|
@ -122,15 +142,95 @@ private struct PreferenceLabel: View {
|
|||
#expect(await store.storedValues["server.url"] == nil)
|
||||
}
|
||||
|
||||
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) {
|
||||
@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() async {
|
||||
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"
|
||||
await asyncPump { recorder.values.count > 1 }
|
||||
|
||||
#expect(recorder.values == [false, true])
|
||||
}
|
||||
|
||||
@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..<turns {
|
||||
if condition() { return }
|
||||
_ = preference_g_main_context_iteration(nil, 0)
|
||||
await _Concurrency.Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPSTREAM: Portico should expose a main-loop pump for tests so suites do not need @_silgen_name.
|
||||
// UPSTREAM: Portico should expose an async main-loop pump for tests (e.g.
|
||||
// `_porticoPumpMainContext(until:turns:)`) so suites do not need @_silgen_name and do not
|
||||
// rediscover the sync-vs-async trap: a synchronous pump cannot observe Observation-driven
|
||||
// updates because swift-testing never runs @MainActor bodies on the process initial thread.
|
||||
@_silgen_name("g_main_context_iteration")
|
||||
private nonisolated func preference_g_main_context_iteration(
|
||||
_ context: UnsafeMutableRawPointer?,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue