import XCTest @testable import MultiSimAdmin final class LogCenterTests: XCTestCase { func testAggregatesDeliveryLogsConnectionsAndDiagnostics() async throws { let store = MemoryLogStore() let service = LogCenterService( client: LogCenterTestClient(), sessions: InstanceSessionStore(), store: store ) let instance = Self.instance() let snapshot = try await service.refresh([instance]) XCTAssertEqual(snapshot.runtime.count, 2) XCTAssertEqual(snapshot.runtime.first?.id, "delivery-\(instance.id.uuidString)-log-2") XCTAssertEqual(snapshot.runtime.first?.level, .error) XCTAssertEqual(snapshot.runtime.first?.instanceName, instance.name) XCTAssertEqual(snapshot.runtime.first?.source, .delivery) XCTAssertEqual(snapshot.counts[.delivery], 2) XCTAssertEqual(store.runtimeStorage.count, 2) XCTAssertEqual(snapshot.connections.count, 1) XCTAssertEqual(snapshot.connections.first?.outcome, .success) XCTAssertEqual(snapshot.connections.first?.state, "online") XCTAssertEqual(store.connectionStorage.count, 1) let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.id, instance.id) XCTAssertEqual(diagnostic.state, .online) XCTAssertEqual(diagnostic.featureStatus.map(\.state), [ .ready, .ready, .ready ]) XCTAssertEqual(diagnostic.connection?.total, 1) XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100) } func testRecordsUnauthorizedDeliveryLogsWithoutLosingConnectionHistory() async throws { let store = MemoryLogStore() let service = LogCenterService( client: LogCenterTestClient( failingRoutes: [.notificationLogs(limit: 50, offset: 0)] ), sessions: InstanceSessionStore(), store: store ) let instance = Self.instance() let snapshot = try await service.refresh([instance]) XCTAssertEqual(snapshot.runtime.count, 1) XCTAssertEqual(snapshot.runtime.first?.level, .warning) XCTAssertEqual(snapshot.runtime.first?.message, "通知日志未同步:需要登录") let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .online) XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .requiresLogin }.count, 1) XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 2) XCTAssertEqual(store.runtimeStorage.count, 1) XCTAssertEqual(store.connectionStorage.count, 1) } func testOfflineProbeBecomesFailedConnectionAndErrorLog() async throws { let store = MemoryLogStore() let service = LogCenterService( client: LogCenterTestClient(connectionUnavailable: true), sessions: InstanceSessionStore(), store: store ) let snapshot = try await service.refresh([Self.instance()]) XCTAssertEqual(snapshot.connections.first?.outcome, .failed) XCTAssertEqual(snapshot.connections.first?.errorCode, "TIMEOUT") XCTAssertTrue(snapshot.runtime.contains { $0.level == .error && $0.code == "TIMEOUT" }) let diagnostic = try XCTUnwrap(snapshot.diagnostics.first) XCTAssertEqual(diagnostic.state, .offline) XCTAssertEqual(diagnostic.healthStatus, "超时") XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 3) } @MainActor func testViewModelFiltersRuntimeLogsAndPrunesHistory() async throws { let repository = MemoryInstanceRepository() let logStore = MemoryLogStore() let service = LogCenterService( client: LogCenterTestClient(), sessions: InstanceSessionStore(), store: logStore ) let model = LogsViewModel( repository: repository, service: service, store: logStore ) let instance = try await repository.create(InstanceInput( name: "Log Node", origin: URL(string: "http://192.168.1.20:3000")!, password: nil )) await model.refresh() XCTAssertNotNil(model.snapshot) XCTAssertEqual(model.filteredRuntime.count, 2) XCTAssertEqual(model.filteredDiagnostics.count, 1) XCTAssertEqual(model.offlineCount, 0) model.selectedInstanceID = instance.id model.searchText = "offline" model.selectedLevel = .error XCTAssertEqual(model.filteredRuntime.count, 1) XCTAssertEqual(model.filteredRuntime.first?.message, "实例进入离线状态") let removed = await model.pruneConnections(olderThanDays: 7) XCTAssertEqual(removed, 0) } private static func instance() -> Instance { Instance( id: UUID(), name: "Log Node", origin: URL(string: "http://192.168.1.20:3000")!, configRevision: 1, createdAt: Date(), updatedAt: Date() ) } } private final class MemoryLogStore: LogStore, @unchecked Sendable { private let lock = NSLock() private var runtimeItems: [RuntimeLogEntry] = [] private var connectionItems: [ConnectionLogEntry] = [] var runtimeStorage: [RuntimeLogEntry] { lock.withLock { runtimeItems } } var connectionStorage: [ConnectionLogEntry] { lock.withLock { connectionItems } } func runtimeLogs() async throws -> [RuntimeLogEntry] { runtimeStorage } func connectionLogs() async throws -> [ConnectionLogEntry] { connectionStorage } func append( runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry] ) async throws { lock.withLock { var mergedRuntime = runtime for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) { mergedRuntime.append(existing) } runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt } var mergedConnections = connections for existing in connectionItems where !connections.contains(where: { $0.id == existing.id }) { mergedConnections.append(existing) } connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt } } } func pruneConnections(before _: Date) async throws -> Int { 0 } func clearRuntimeLogs() async throws -> Int { 0 } } private struct LogCenterTestClient: SimAdminClientProtocol { var failingRoutes: Set = [] var connectionUnavailable = false func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { if connectionUnavailable { throw UpstreamError.timeout } if failingRoutes.contains(route) { throw UpstreamError.unauthorized } switch route { case .health: return Self.response(#"{"status":"UP","version":"1.2.3","platform":"linux"}"#) case .device: return Self.response( #"{"status":"ok","data":{"model":"UFI-003","battery":82,"temperature":42}}"# ) case .connectivity: return Self.response(#"{"status":"ok","data":{"connected":true}}"#) case .notificationLogs: return Self.response( """ {"status":"ok","data":{"total":2,"logs":[ {"id":"log-1","event_type":"device_offline","status":"success", "message":"实例恢复在线","created_at":"2026-09-08T10:01:00Z"}, {"id":"log-2","event_type":"device_offline","status":"failed", "message":"实例进入离线状态","created_at":"2026-09-08T10:02:00Z"} ]}} """ ) default: return UpstreamResponse(status: 404, data: Data("{}".utf8)) } } private static func response(_ json: String) -> UpstreamResponse { UpstreamResponse(status: 200, data: Data(json.utf8)) } }