Files
Termind/FrameworkTests/MetricsSyncTests.swift
T

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 {}
}