Add persistence layer

This commit is contained in:
Brendan Szymanski 2026-08-05 12:12:32 -04:00
parent a5ee2edf5d
commit 0addde59d3
34 changed files with 2199 additions and 34 deletions

View file

@ -0,0 +1,89 @@
//
// AppPathsTests.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
@Suite struct AppPathsTests {
@Test("An absolute override is used verbatim")
func overrideWins() {
let path = AppPaths.dataDirectory(
environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"],
home: URL(filePath: "/home/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(path == URL(filePath: "/override/luminate"))
}
#if canImport(Darwin)
@Test("Injected home receives the Application Support application directory")
func applicationSupportHome() {
let path = AppPaths.dataDirectory(
environment: [:],
home: URL(filePath: "/Users/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(path.path == "/Users/test/Library/Application Support/luminate")
}
#else
@Test("Absolute XDG data home receives the lowercase application directory")
func xdgDataHome() {
let path = AppPaths.dataDirectory(
environment: ["XDG_DATA_HOME": "/data"],
home: URL(filePath: "/home/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(path.path == "/data/luminate")
}
@Test("Relative XDG data home is ignored")
func relativeXDGDataHome() {
let path = AppPaths.dataDirectory(
environment: ["XDG_DATA_HOME": "relative"],
home: URL(filePath: "/home/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(path.path == "/home/test/.local/share/luminate")
}
@Test("Root home is a valid Linux data location")
func rootHome() {
let path = AppPaths.dataDirectory(
environment: [:],
home: URL(filePath: "/"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(path.path == "/.local/share/luminate")
}
#endif
@Test("Missing home falls back to the temporary directory")
func missingHome() {
let path = AppPaths.dataDirectory(
environment: [:],
home: nil,
temporaryDirectory: URL(filePath: "/tmp/luminate-test")
)
#expect(path.path == "/tmp/luminate-test/luminate")
}
}

View file

@ -0,0 +1,45 @@
//
// PreferenceValueTests.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
/// Verifies typed conversions at the preference storage boundary.
@Suite struct PreferenceValueTests {
@Test("Boolean decoding accepts only zero and one")
func booleanDecoding() {
#expect(Bool(preference: .integer(0)) == false)
#expect(Bool(preference: .integer(1)) == true)
#expect(Bool(preference: .integer(2)) == nil)
}
@Test("Double decoding accepts integer storage")
func doubleIntegerDecoding() {
#expect(Double(preference: .integer(3)) == 3.0)
}
@Test("Non-finite doubles encode as zero")
func nonFiniteDoubleEncoding() {
#expect(Double.nan.preferenceValue == .real(0))
#expect(Double.infinity.preferenceValue == .real(0))
}
}

View file

@ -42,6 +42,18 @@ import Testing
)
}
@Test("An unconfigured client rejects operations before network access")
func unconfiguredClientThrows() async {
let client = JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: nil)
)
#expect(await client.serverURL == nil)
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await client.items()
}
}
@Test("A 401 maps to unauthorized")
func unauthorizedMaps() async {
var api = MockJellyfinAPI()

View file

@ -0,0 +1,109 @@
//
// SQLitePreferenceStoreTests.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 SQLitePreferenceStoreTests {
@Test("A fresh database has schema version one and no preferences")
func freshDatabase() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
#expect(store.initialSnapshot.isEmpty)
let database = try SQLiteDatabase(url: url)
#expect(try database.userVersion == 1)
}
@Test("Queued values survive flush and reopen")
func persistsValue() async throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
store.enqueue("server.url", .text("http://jellyfin.test:8096"))
try await store.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("A nil queued value deletes the stored row")
func deletesValue() async throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
store.enqueue("auth.accessToken", .text("secret"))
store.enqueue("auth.accessToken", nil)
try await store.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot["auth.accessToken"] == nil)
}
@Test("Every SQLite storage class round trips")
func storageClassesRoundTrip() async throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
let values: [String: StoredPreference] = [
"integer": .integer(42),
"real": .real(3.5),
"text": .text("value"),
"blob": .blob(Data([1, 2, 3])),
"emptyBlob": .blob(Data()),
"nulText": .text("a\0b"),
]
for (key, value) in values {
store.enqueue(key, value)
}
try await store.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot == values)
}
@Test("Writes to one key are applied in submission order")
func preservesOrder() async throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
store.enqueue("server.url", .text("first"))
store.enqueue("server.url", .text("second"))
try await store.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot["server.url"] == .text("second"))
}
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)
}
}

View file

@ -0,0 +1,157 @@
//
// SchemaMigratorTests.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 SchemaMigratorTests {
@Test("Migrating a current database is a no-op")
func currentDatabase() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
try SchemaMigrator.migrate(database)
let snapshot = try SQLitePreferenceStore(url: url).initialSnapshot
try SchemaMigrator.migrate(database)
#expect(try database.userVersion == SchemaMigrator.latestVersion)
#expect(try SQLitePreferenceStore(url: url).initialSnapshot == snapshot)
}
@Test("A newer minimum reader version rejects the database")
func newerReaderVersion() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
try SchemaMigrator.migrate(database)
try database.exec(
"UPDATE schema_metadata SET minimum_reader_version = \(SchemaMigrator.latestVersion + 1);"
)
do {
_ = try SQLitePreferenceStore(url: url)
Issue.record("Expected a newer-reader migration error")
} catch let error as PreferenceStoreError {
#expect(
error
== .requiresNewerApplication(
minimumReaderVersion: SchemaMigrator.latestVersion + 1,
supportedVersion: SchemaMigrator.latestVersion
)
)
}
}
@Test("A non-Luminate application identifier is rejected")
func foreignApplication() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
try database.setApplicationID(1234)
do {
try SchemaMigrator.migrate(database)
Issue.record("Expected a foreign-database error")
} catch let error as PreferenceStoreError {
#expect(error == .notALuminateDatabase(applicationID: 1234))
}
}
@Test("A higher compatible schema version remains readable")
func compatibleHigherVersion() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
try SchemaMigrator.migrate(database)
try database.setUserVersion(SchemaMigrator.latestVersion + 1)
let statement = try SQLiteStatement(
database: database,
sql: "INSERT INTO preference (key, value) VALUES (?1, ?2);"
)
try statement.bind(1, text: "server.url")
try statement.bind(2, .text("http://compatible"))
_ = try statement.step()
let store = try SQLitePreferenceStore(url: url)
#expect(store.initialSnapshot["server.url"] == .text("http://compatible"))
}
@Test("A failed migration rolls back its statements and version")
func failedMigrationRollsBack() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
let failingMigration = StoreMigration(
version: 2,
name: "failing migration",
minimumReaderVersion: 1,
statements: [
"CREATE TABLE version_two_marker (id INTEGER);",
"CREATE TABLE preference (x);",
]
)
#expect(throws: PreferenceStoreError.self) {
try SchemaMigrator.migrate(database, migrations: SchemaMigrator.migrations + [failingMigration])
}
#expect(try database.userVersion == 1)
#expect(try tableExists("version_two_marker", in: database) == false)
}
@Test("Migration versions must be strictly increasing")
func rejectsMalformedMigrationOrder() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
let migrations = [
StoreMigration(version: 2, name: "second", minimumReaderVersion: 1, statements: []),
StoreMigration(version: 1, name: "first", minimumReaderVersion: 1, statements: []),
]
#expect(throws: PreferenceStoreError.malformedMigrationList(version: 1)) {
try SchemaMigrator.migrate(database, migrations: migrations)
}
}
/// Checks whether SQLite exposes a table with the requested name.
private func tableExists(_ name: String, in database: SQLiteDatabase) throws -> Bool {
let statement = try SQLiteStatement(
database: database,
sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1;"
)
try statement.bind(1, text: name)
return try statement.step()
}
private func makeDatabaseURL() -> (URL, URL) {
let directory = FileManager.default.temporaryDirectory
.appending(path: "luminate-schema-\(UUID().uuidString)", directoryHint: .isDirectory)
return (directory.appending(path: "preferences.sqlite"), directory)
}
}

View file

@ -0,0 +1,60 @@
//
// PreferencePersistenceTests.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
@testable import LuminateUI
/// Verifies preference persistence across the UI and SQLite layers.
@MainActor @Suite struct PreferencePersistenceTests {
@Test("A preference written through a slot survives flush and reopen without a main-loop turn")
func survivesFlushAndReopen() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "luminate-e2e-\(UUID().uuidString)", directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appending(path: "preferences.sqlite")
let preferences = Preferences(store: try SQLitePreferenceStore(url: url))
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
try await preferences.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("Bootstrap points the shared coordinator at the on-disk store")
func bootstrapInstallsSharedCoordinator() async throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "luminate-bootstrap-\(UUID().uuidString)", directoryHint: .isDirectory)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appending(path: "preferences.sqlite")
Preferences.bootstrap(PreferenceBackend.open(url: url))
Preferences.shared[.serverURL] = URL(string: "http://jellyfin.test:8096")
try await Preferences.shared.flush()
let reopened = try SQLitePreferenceStore(url: url)
#expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096"))
}
}

View file

@ -0,0 +1,138 @@
//
// PreferenceTests.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
@_spi(Portico) import Portico
import Testing
@testable import LuminateUI
/// Test view that renders the current server URL preference.
private struct PreferenceLabel: View {
@Preference(.serverURL) private var serverURL: URL?
var body: some View {
Label(str: "").label { serverURL?.absoluteString ?? "No server configured" }
}
}
/// Exercises preference mounting, observation, and persistence wiring.
@Suite(.serialized) @MainActor struct PreferenceTests {
@Test("An unset preference reads nil at mount")
func unsetAtMount() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
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("An un-injected preference resolves through the shared coordinator")
func resolvesWithoutInjection() {
guard Gtk.initCheck() else { return }
Preferences.shared[.serverURL] = URL(string: "http://shared.test")
defer { Preferences.shared[.serverURL] = nil }
let context = MountContext()
let label = AnyView(PreferenceLabel()).makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
#expect(label.getText() == "http://shared.test")
}
@Test("Writing a preference updates the same mounted label")
func liveUpdate() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
let label =
AnyView(PreferenceLabel().environment(\.preferences, preferences))
.makeWidget(context) as! Gtk.Label
defer { context.registry.teardown() }
let pointer = label.pointer
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
pump {
label.getText() == "http://jellyfin.test:8096"
}
#expect(label.pointer == pointer)
#expect(label.getText() == "http://jellyfin.test:8096")
}
@Test("A preference write reaches the in-memory backend after flush")
func persistsToBackend() async throws {
let store = EphemeralPreferenceStore()
let preferences = Preferences(store: store)
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
try await store.flush()
#expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("A seeded store presents its value at mount")
func restoresAtMount() {
guard Gtk.initCheck() else { return }
let store = EphemeralPreferenceStore(
initialSnapshot: ["server.url": .text("http://seeded.test")]
)
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() == "http://seeded.test")
}
@Test("Setting a preference to nil enqueues a deletion")
func deletesFromBackend() async throws {
let store = EphemeralPreferenceStore(
initialSnapshot: ["server.url": .text("http://seeded.test")]
)
let preferences = Preferences(store: store)
preferences.slot(for: .serverURL).value = nil
try await store.flush()
#expect(await store.storedValues["server.url"] == nil)
}
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }
_ = preference_g_main_context_iteration(nil, 0)
}
}
}
// UPSTREAM: Portico should expose a main-loop pump for tests so suites do not need @_silgen_name.
@_silgen_name("g_main_context_iteration")
private nonisolated func preference_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?,
_ mayBlock: Int32
) -> Int32