feat(ios): add cross-instance log center
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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.capabilities.total, 3)
|
||||
XCTAssertEqual(diagnostic.capabilities.supported, 3)
|
||||
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.capabilities.authRequired, 1)
|
||||
XCTAssertEqual(diagnostic.capabilities.supported, 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.capabilities.total, 3)
|
||||
XCTAssertEqual(diagnostic.capabilities.unsupported, 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
|
||||
}
|
||||
}
|
||||
|
||||
private struct LogCenterTestClient: SimAdminClientProtocol {
|
||||
var failingRoutes: Set<UpstreamRoute> = []
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user