227 lines
9.8 KiB
Swift
227 lines
9.8 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import Testing
|
|
|
|
@testable import TermindInfrastructure
|
|
|
|
@Suite @MainActor
|
|
struct MetricsSyncTests {
|
|
@Test(arguments: [false, true])
|
|
func historicalV1CloudRecordsRemainReadable(isDeleted: Bool) throws {
|
|
let record = try historicalRecord(isDeleted: isDeleted)
|
|
let envelope = try MetricsSyncCoordinator.envelope(from: record)
|
|
#expect(envelope.formatVersion == 1)
|
|
#expect(envelope.isDeleted == isDeleted)
|
|
if !isDeleted {
|
|
guard case .selection(let value) = envelope.payload else {
|
|
Issue.record("Historical Metrics selection was lost")
|
|
return
|
|
}
|
|
#expect(!value.selection.enabled)
|
|
#expect(value.selection.disks == ["sda"])
|
|
#expect(value.selection.interfaces == [])
|
|
#expect(value.selection.tailscaleGroupID == nil)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func readerUpgradeRefetchesQuarantinedV1WithoutLosingPendingLocalEdits() async throws {
|
|
let fixture = try CloudSyncSafetyFixture(category: .metrics, seed: false)
|
|
defer { fixture.stop() }
|
|
try await fixture.start()
|
|
let record = try historicalRecord()
|
|
let old = try MetricsSyncCodec.decode(
|
|
CloudSyncRecordCoding.payloadData(from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)))
|
|
let persistence = MetricsSyncPersistence(context: fixture.container.mainContext, defaults: fixture.defaults)
|
|
try persistence.save([MetricsSelection(id: old.key.id, interfaces: ["en0"])])
|
|
let journal = persistence.journal
|
|
let local = try #require(try journal.envelope(for: old.key))
|
|
let safety = try fixture.safety()
|
|
try safety.quarantine(
|
|
recordName: record.recordID.recordName,
|
|
payloadData: CloudSyncRecordCoding.payloadData(
|
|
from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)),
|
|
reason: "Previous reader rejected V1", reasonCode: .invalidRecord)
|
|
try safety.capability().capabilityID = SyncRecordFormats.metrics.capabilityID
|
|
try safety.recovery().requiresRefetch = false
|
|
try fixture.container.mainContext.save()
|
|
#expect(fixture.batch([.saveRecord(record.recordID)]) == nil)
|
|
|
|
fixture.stop()
|
|
fixture.network.nextEngine = LifecycleEngine()
|
|
fixture.network.nextEngine.fetchBody = { try fixture.receive([record], []) }
|
|
try await fixture.start()
|
|
|
|
#expect(fixture.network.serializations.last! == nil)
|
|
#expect(try safety.blockedRecordNames().isEmpty)
|
|
#expect(try safety.capability().capabilityID == MetricsSyncCodec.capabilityID)
|
|
#expect(try journal.envelope(for: old.key) == local)
|
|
let batch = try #require(fixture.batch([.saveRecord(record.recordID)]))
|
|
#expect(try MetricsSyncCoordinator.envelope(from: #require(batch.recordsToSave.first)) == local)
|
|
}
|
|
|
|
@Test
|
|
func legacySelectionsMigrateOnceWithCompleteConfigurationAndOrder() throws {
|
|
let defaults = UserDefaults(suiteName: "MetricsSyncTests.\(UUID())")!
|
|
let first = MetricsSelection(id: UUID(), enabled: false, disks: ["sda"], interfaces: [])
|
|
let second = MetricsSelection(id: UUID(), interfaces: ["eth0"])
|
|
defaults.set(try JSONEncoder().encode([first]), forKey: "metrics.servers")
|
|
defaults.set(try JSONEncoder().encode([first, second]), forKey: "mobile.metrics.v1")
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let store = MetricsSyncPersistence(context: container.mainContext, defaults: defaults)
|
|
|
|
try store.prepare()
|
|
|
|
#expect(try store.load() == [first, second])
|
|
#expect(try store.journal.pendingEnvelopes().count == 2)
|
|
defaults.set(try JSONEncoder().encode([] as [MetricsSelection]), forKey: "metrics.servers")
|
|
try store.prepare()
|
|
#expect(try store.load() == [first, second])
|
|
}
|
|
|
|
@Test
|
|
func recordsRoundTripAndRemoteChangesMergePerHost() throws {
|
|
let (sourceContainer, source) = try fixture()
|
|
let (destinationContainer, destination) = try fixture()
|
|
defer { withExtendedLifetime((sourceContainer, destinationContainer)) {} }
|
|
let first = MetricsSelection(id: UUID(), enabled: false, disks: ["nvme0n1"])
|
|
let second = MetricsSelection(id: UUID(), interfaces: ["en0"])
|
|
try source.save([first, second])
|
|
|
|
let outgoing = try source.journal.pendingEnvelopes()
|
|
let applied = try destination.applyRemote(outgoing)
|
|
#expect(applied.snapshot.selections == [first, second])
|
|
|
|
let envelope = try #require(outgoing.first)
|
|
let data = try MetricsSyncCodec.encode(envelope)
|
|
#expect(try MetricsSyncCodec.decode(data) == envelope)
|
|
let record = try MetricsSyncCoordinator.makeRecord(
|
|
MetricsOutgoingEntry(envelope: envelope),
|
|
recordID: CKRecord.ID(
|
|
recordName: envelope.key.recordName,
|
|
zoneID: CKRecordZone.ID(zoneName: MetricsSyncCoordinator.zoneName)))
|
|
#expect(try MetricsSyncCoordinator.envelope(from: record) == envelope)
|
|
|
|
try source.save([second, first])
|
|
let reordered = try destination.applyRemote(source.journal.pendingEnvelopes())
|
|
#expect(reordered.snapshot.selections == [second, first])
|
|
|
|
try source.save([second])
|
|
let deleted = try destination.applyRemote(source.journal.pendingEnvelopes())
|
|
#expect(deleted.snapshot.selections == [second])
|
|
}
|
|
|
|
@Test
|
|
func missingHostsStayConfiguredAndMaterializeLater() {
|
|
let host = TermindDomain.Host(name: "Later", host: "example.invalid")
|
|
let preferences = TestMetricsPreferences(values: [MetricsSelection(id: host.id, enabled: false)])
|
|
let library = HostLibraryModel()
|
|
let model = ServerMetricsModel(
|
|
hosts: library, preferences: preferences,
|
|
makeSampler: { TestMetricsSampler() })
|
|
|
|
model.start()
|
|
model.suspend()
|
|
#expect(model.servers.isEmpty)
|
|
#expect(model.selections.map(\.id) == [host.id])
|
|
|
|
library.upsert(host)
|
|
model.reconcile()
|
|
#expect(model.servers.map(\.id) == [host.id])
|
|
#expect(!model.servers[0].selection.enabled)
|
|
}
|
|
|
|
@Test
|
|
func failedPersistenceDoesNotChangeRuntimeConfiguration() {
|
|
let host = TermindDomain.Host(name: "Stable", host: "example.invalid")
|
|
let preferences = TestMetricsPreferences(values: [MetricsSelection(id: host.id)])
|
|
let library = HostLibraryModel()
|
|
library.upsert(host)
|
|
let model = ServerMetricsModel(
|
|
hosts: library, preferences: preferences,
|
|
makeSampler: { TestMetricsSampler() })
|
|
model.start()
|
|
model.suspend()
|
|
preferences.failSaves = true
|
|
|
|
model.setEnabled(false, for: model.servers[0])
|
|
|
|
#expect(model.servers[0].selection.enabled)
|
|
#expect(model.storageError == TestMetricsError.failed.localizedDescription)
|
|
}
|
|
|
|
@Test
|
|
func metricsCategoryDefaultsOnWithoutChangingTheMasterPreference() {
|
|
let defaults = UserDefaults(suiteName: "MetricsSyncDefaults.\(UUID())")!
|
|
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
|
|
SettingsKey.migrateSyncPreferences(in: defaults)
|
|
#expect(SettingsKey.defaultMetricsSyncEnabled)
|
|
#expect(!defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
|
|
#expect(defaults.object(forKey: SettingsKey.metricsSyncEnabled) == nil)
|
|
}
|
|
|
|
private func fixture() throws -> (ModelContainer, MetricsSyncPersistence) {
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
let defaults = UserDefaults(suiteName: "MetricsSyncFixture.\(UUID())")!
|
|
let store = MetricsSyncPersistence(context: container.mainContext, defaults: defaults)
|
|
try store.prepare()
|
|
return (container, store)
|
|
}
|
|
|
|
private func historicalRecord(isDeleted: Bool = false) throws -> CKRecord {
|
|
let id = "30000000-0000-0000-0000-000000000001"
|
|
let payload =
|
|
isDeleted
|
|
? ""
|
|
: """
|
|
,"payload":{"selection":{"_0":{"selection":{
|
|
"id":"\(id)","enabled":false,"disks":["sda"],"interfaces":[]},"sortIndex":0}}}
|
|
"""
|
|
let data = Data(
|
|
"""
|
|
{"formatVersion":1,"key":{"kind":"selection","id":"\(id)"},
|
|
"version":{"modifiedAt":100,"counter":1,"writerID":"11111111-1111-1111-1111-111111111111"},
|
|
"isDeleted":\(isDeleted)\(payload)}
|
|
""".utf8)
|
|
let record = CKRecord(
|
|
recordType: MetricsSyncCoordinator.recordType,
|
|
recordID: CKRecord.ID(
|
|
recordName: "v1/selection/\(id)",
|
|
zoneID: CKRecordZone.ID(zoneName: MetricsSyncCoordinator.zoneName)))
|
|
try CloudSyncRecordCoding.setPayload(data, on: record, assetURL: FileManager.default.temporaryDirectory)
|
|
return record
|
|
}
|
|
}
|
|
|
|
private enum TestMetricsError: LocalizedError {
|
|
case failed
|
|
var errorDescription: String? { "Metrics persistence failed." }
|
|
}
|
|
|
|
@MainActor
|
|
private final class TestMetricsPreferences: MetricsConfigurationStoring {
|
|
var values: [MetricsSelection]
|
|
var failSaves = false
|
|
|
|
init(values: [MetricsSelection]) { self.values = values }
|
|
func load() -> [MetricsSelection] { values }
|
|
func save(_ selections: [MetricsSelection]) throws {
|
|
if failSaves { throw TestMetricsError.failed }
|
|
values = selections
|
|
}
|
|
}
|
|
|
|
nonisolated private struct TestMetricsSampler: MetricsSampling {
|
|
func connect(_ configuration: SSHConnectionConfiguration) async throws {}
|
|
func sample() async throws -> MetricsSample { throw CancellationError() }
|
|
func gpu() async throws -> NvidiaSnapshot? { nil }
|
|
func disconnect() async {}
|
|
}
|