Files
Termind/TFMTests/ConnectionHistoryTests.swift

295 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite
@MainActor
struct ConnectionHistoryTests {
@Test
func desktopHistoryProjectionTruncatesItsInput() {
let input = Array(0...100)
let projected = DesktopHistoryPresentation.limited(input)
#expect(projected == Array(0..<100))
}
@Test
func schemaV5MigratesWithAnEmptyConnectionTimeline() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ConnectionHistoryMigration-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: directory) }
let storeURL = directory.appendingPathComponent("Termind.store")
let legacySchema = Schema(versionedSchema: TermindSchemaV5.self)
var legacyContainer: ModelContainer? = try ModelContainer(
for: legacySchema,
configurations: [
localModelConfiguration(
"ConnectionHistoryMigration", schema: legacySchema, url: storeURL
)
]
)
let legacyHost = TermindSchemaV7HostModels.PersistentHost(
Host(name: "Existing", host: "existing.example")
)
legacyHost.lastUsedAt = Date(timeIntervalSince1970: 123)
legacyContainer?.mainContext.insert(legacyHost)
try legacyContainer?.mainContext.save()
legacyContainer = nil
let migrated = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
configurations: [
localModelConfiguration(
"ConnectionHistoryMigration",
schema: HostPersistence.schema, url: storeURL
)
]
)
let migratedHosts = try migrated.mainContext.fetch(FetchDescriptor<PersistentHost>())
#expect(migratedHosts.count == 1)
#expect(
try migrated.mainContext.fetch(
FetchDescriptor<PersistentConnectionHistoryEntry>()
).isEmpty)
}
@Test
func repeatedHostsRemainDistinctAndSortedByAttemptTime() {
let hostID = UUID()
let model = ConnectionHistoryModel()
model.record(kind: .ssh, for: hostID, at: Date(timeIntervalSince1970: 1))
model.record(kind: .sftp, for: hostID, at: Date(timeIntervalSince1970: 3))
model.record(kind: .ssh, for: hostID, at: Date(timeIntervalSince1970: 2))
let entries = model.entries()
#expect(entries.map(\.kind) == [.sftp, .ssh, .ssh])
#expect(
entries.map(\.connectedAt) == [
Date(timeIntervalSince1970: 3),
Date(timeIntervalSince1970: 2),
Date(timeIntervalSince1970: 1),
])
#expect(Set(entries.map(\.id)).count == 3)
}
@Test
func recordsTheLocalDeviceAndEndsWhenItsPaneCloses() throws {
let host = Host(name: "Server", host: "server.example")
let deviceID = UUID()
let devices = DeviceDirectoryModel(
currentDeviceID: deviceID, currentDeviceName: "Test Mac")
let history = ConnectionHistoryModel(sourceDeviceID: deviceID)
let app = AppModel(
hosts: HostLibraryModel(hosts: [host]), connectionHistory: history,
devices: devices)
let window = WindowModel()
let workspace = try #require(app.open(host, as: .sshSession, in: window))
let opened = try #require(history.entries().first)
#expect(opened.sourceDeviceID == deviceID)
#expect(devices.name(for: opened.sourceDeviceID) == "Test Mac")
#expect(opened.endedAt == nil)
window.close(workspace.id)
let ended = try #require(history.entries().first)
#expect(ended.endedAt != nil)
#expect(ended.endedAt.map { $0 >= ended.connectedAt } == true)
}
@Test
func localProjectionUsesStableDeviceIDAndDeviceRenameUpdatesExistingEvents() throws {
let localID = UUID()
let remoteID = UUID()
let hostID = UUID()
let stableAfterRename = ConnectionHistoryEntry(
hostID: hostID, kind: .ssh, connectedAt: Date(timeIntervalSince1970: 3),
sourceDeviceID: localID)
let missingDeviceID = ConnectionHistoryEntry(
hostID: hostID, kind: .telnet, connectedAt: Date(timeIntervalSince1970: 2),
sourceDeviceID: nil)
let remote = ConnectionHistoryEntry(
hostID: hostID, kind: .sftp, connectedAt: Date(timeIntervalSince1970: 1),
sourceDeviceID: remoteID)
let historyRepository = InMemoryConnectionHistoryRepository(
entries: [stableAfterRename, missingDeviceID, remote])
let model = ConnectionHistoryModel(
repository: historyRepository, sourceDeviceID: localID)
let devices = DeviceDirectoryModel(
currentDeviceID: localID, currentDeviceName: "old-name")
#expect(model.localEntries().map(\.id) == [stableAfterRename.id])
#expect(model.isLocal(stableAfterRename))
#expect(!model.isLocal(missingDeviceID))
#expect(!model.isLocal(remote))
#expect(devices.name(for: stableAfterRename.sourceDeviceID) == "old-name")
devices.updateCurrentDeviceName("Renamed Test Mac")
#expect(devices.name(for: stableAfterRename.sourceDeviceID) == "Renamed Test Mac")
}
@Test
func globalLimitKeepsTheNewestAttempts() {
let model = ConnectionHistoryModel()
for index in 0...250 {
model.record(
kind: .ssh, for: UUID(),
at: Date(timeIntervalSince1970: TimeInterval(index))
)
}
let entries = model.entries()
#expect(entries.count == 250)
#expect(
entries.first?.connectedAt
== Date(timeIntervalSince1970: 250))
#expect(entries.last?.connectedAt == Date(timeIntervalSince1970: 1))
}
@Test
func loadedConnectionHistoryImmediatelyTrimsToTheSharedLimit() {
let model = ConnectionHistoryModel(
entries: (0...250).map { index in
ConnectionHistoryEntry(
hostID: UUID(), kind: .ssh,
connectedAt: Date(timeIntervalSince1970: TimeInterval(index))
)
})
let entries = model.entries()
#expect(entries.count == 250)
#expect(
entries.first?.connectedAt
== Date(timeIntervalSince1970: 250))
#expect(entries.last?.connectedAt == Date(timeIntervalSince1970: 1))
}
@Test
func swiftDataRepositoryPersistsDeletesAndClears() throws {
let configuration = localModelConfiguration(
"ConnectionHistoryTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
let container = try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
let hostID = UUID()
let otherHostID = UUID()
let model = ConnectionHistoryModel(modelContext: container.mainContext)
let first = try #require(model.record(kind: .ssh, for: hostID))
model.record(kind: .sftp, for: hostID)
model.record(kind: .ssh, for: otherHostID)
#expect(model.storageError == nil)
#expect(
try container.mainContext.fetch(
FetchDescriptor<PersistentConnectionHistoryEntry>()
).count == 3)
model.delete(first.id)
#expect(model.entries(for: hostID).count == 1)
model.clear(for: hostID)
#expect(model.entries(for: hostID).isEmpty)
#expect(model.entries(for: otherHostID).count == 1)
model.clearAll()
#expect(model.entries().isEmpty)
}
@Test
func openingSavedHostsRecordsKindsAndDrivesRecents() {
let first = Host(name: "First", host: "first.example")
let second = Host(name: "Second", host: "second.example")
let history = ConnectionHistoryModel()
let app = AppModel(
hosts: HostLibraryModel(hosts: [first, second]),
connectionHistory: history
)
let window = WindowModel()
app.open(first, as: .sshSession, in: window)
app.open(second, as: .sftpChannel, in: window)
app.open(first, as: .sftpChannel, in: window)
#expect(history.entries().map(\.hostID) == [first.id, second.id, first.id])
#expect(history.entries().map(\.kind) == [.sftp, .sftp, .ssh])
#expect(history.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [first.id, second.id])
#expect(app.lastConnectedAt(for: first.id) != nil)
}
@Test
func welcomeRecentsUseOnlyThisDevicesLatestEvents() {
let localID = UUID()
let local = Host(name: "Local", host: "local.example")
let remote = Host(name: "Remote", host: "remote.example")
let history = ConnectionHistoryModel(
entries: [
ConnectionHistoryEntry(
hostID: remote.id, kind: .ssh,
connectedAt: Date(timeIntervalSince1970: 20),
sourceDeviceID: UUID()),
ConnectionHistoryEntry(
hostID: local.id, kind: .ssh,
connectedAt: Date(timeIntervalSince1970: 10),
sourceDeviceID: localID),
],
sourceDeviceID: localID)
let app = AppModel(
hosts: HostLibraryModel(hosts: [local, remote]), connectionHistory: history)
#expect(history.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [local.id])
#expect(history.entries().map(\.hostID) == [remote.id, local.id])
#expect(app.lastConnectedLocallyAt(for: local.id) == Date(timeIntervalSince1970: 10))
#expect(app.lastConnectedLocallyAt(for: remote.id) == nil)
}
@Test
func clearAllHistoryForHostKeepsHostAndOtherHostHistory() {
let host = Host(name: "Host", host: "host.example")
let other = Host(name: "Other", host: "other.example")
let localID = UUID()
let connections = ConnectionHistoryModel(
entries: [
ConnectionHistoryEntry(hostID: host.id, kind: .ssh, sourceDeviceID: localID),
ConnectionHistoryEntry(hostID: other.id, kind: .ssh, sourceDeviceID: localID),
],
sourceDeviceID: localID)
let session = SSHSessionModel(host: host)
session.commandHistory.record("uptime")
let files = SFTPHistoryModel()
files.record(kind: .directory, path: "/srv")
let app = AppModel(
hosts: HostLibraryModel(hosts: [host, other]),
connectionHistory: connections
)
app.clearAllHistory(for: [host.id])
#expect(app.hostLibrary.host(host.id) != nil)
#expect(connections.entries(for: host.id).isEmpty)
#expect(session.commandHistory.entries().map(\.command) == ["uptime"])
#expect(files.entries().map(\.path) == ["/srv"])
#expect(connections.entries(for: other.id).count == 1)
#expect(connections.recentLocalHosts(in: app.hostLibrary, limit: 5).map(\.id) == [other.id])
}
}