feat(ios): add cross-instance notification center
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class NotificationServiceTests: XCTestCase {
|
||||
func testAggregatesInstanceConfigLogsAndQueue() async throws {
|
||||
let client = NotificationTestClient()
|
||||
let service = NotificationService(client: client, sessions: InstanceSessionStore())
|
||||
let instance = Self.instance()
|
||||
|
||||
let overview = try await service.overview([instance])
|
||||
|
||||
XCTAssertEqual(client.routes, [
|
||||
.notificationConfig,
|
||||
.notificationLogs(limit: 50, offset: 0),
|
||||
.notificationQueue(limit: 100)
|
||||
])
|
||||
XCTAssertEqual(overview.config.channelCount, 2)
|
||||
XCTAssertEqual(overview.config.channelEnabled, 1)
|
||||
XCTAssertEqual(overview.config.ruleCount, 1)
|
||||
XCTAssertEqual(overview.config.ruleEnabled, 1)
|
||||
XCTAssertEqual(overview.config.channelTypes.first?.type, "webhook")
|
||||
XCTAssertEqual(overview.logs.total, 3)
|
||||
XCTAssertEqual(overview.logs.success, 2)
|
||||
XCTAssertEqual(overview.logs.failed, 1)
|
||||
XCTAssertEqual(overview.queue.total, 2)
|
||||
XCTAssertEqual(overview.queue.pending, 1)
|
||||
XCTAssertEqual(overview.queue.failed, 1)
|
||||
XCTAssertEqual(overview.queue.items.first?.id, "q-1")
|
||||
XCTAssertEqual(overview.queue.items.first?.instanceID, instance.id)
|
||||
XCTAssertEqual(overview.devices.first?.state, .ready)
|
||||
}
|
||||
|
||||
func testQueueActionsValidateIDsAndUseUpstreamRoutes() async throws {
|
||||
let client = NotificationTestClient()
|
||||
let service = NotificationService(client: client, sessions: InstanceSessionStore())
|
||||
let instance = Self.instance()
|
||||
|
||||
try await service.retryQueue(instance, id: "q_1-a.2")
|
||||
try await service.deleteQueueItem(instance, id: "q_1-a.2")
|
||||
|
||||
XCTAssertEqual(client.routes.suffix(2), [
|
||||
.notificationRetryQueue(id: "q_1-a.2"),
|
||||
.notificationDeleteQueue(id: "q_1-a.2")
|
||||
])
|
||||
|
||||
do {
|
||||
try await service.retryQueue(instance, id: "bad id")
|
||||
XCTFail("Invalid queue ID should fail.")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? NotificationServiceError, .invalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func testReturnsStatefulOverviewWhenEveryInstanceIsUnauthorized() async throws {
|
||||
let service = NotificationService(
|
||||
client: UnauthorizedNotificationClient(),
|
||||
sessions: InstanceSessionStore()
|
||||
)
|
||||
let instance = Self.instance()
|
||||
|
||||
let overview = try await service.overview([instance])
|
||||
|
||||
XCTAssertEqual(overview.devices, [NotificationDevice(
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
state: .unauthorized
|
||||
)])
|
||||
XCTAssertEqual(overview.config.channelCount, 0)
|
||||
XCTAssertEqual(overview.logs.total, 0)
|
||||
XCTAssertEqual(overview.queue.total, 0)
|
||||
}
|
||||
|
||||
func testAggregatesChannelTypeSummariesInFirstSeenOrder() {
|
||||
let first = NotificationConfigSummary(
|
||||
channelCount: 2,
|
||||
channelEnabled: 1,
|
||||
ruleCount: 0,
|
||||
ruleEnabled: 0,
|
||||
channelTypes: [
|
||||
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1),
|
||||
NotificationChannelTypeSummary(type: "bark", total: 1, enabled: 0)
|
||||
]
|
||||
)
|
||||
let second = NotificationConfigSummary(
|
||||
channelCount: 2,
|
||||
channelEnabled: 2,
|
||||
ruleCount: 0,
|
||||
ruleEnabled: 0,
|
||||
channelTypes: [
|
||||
NotificationChannelTypeSummary(type: "telegram", total: 1, enabled: 1),
|
||||
NotificationChannelTypeSummary(type: "webhook", total: 1, enabled: 1)
|
||||
]
|
||||
)
|
||||
|
||||
let result = NotificationService.aggregateConfigs([first, second])
|
||||
|
||||
XCTAssertEqual(result.channelTypes.map(\.type), ["webhook", "bark", "telegram"])
|
||||
let webhook = result.channelTypes.first { $0.type == "webhook" }
|
||||
XCTAssertEqual(webhook, NotificationChannelTypeSummary(type: "webhook", total: 2, enabled: 2))
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Notification Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UnauthorizedNotificationClient: SimAdminClientProtocol {
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
}
|
||||
|
||||
private final class NotificationTestClient: SimAdminClientProtocol, @unchecked Sendable {
|
||||
var routes: [UpstreamRoute] = []
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
routes.append(route)
|
||||
switch route {
|
||||
case .notificationConfig:
|
||||
let json = """
|
||||
{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true},
|
||||
{"type":"bark","enabled":false}],"rules":[{"enabled":true}]}}
|
||||
"""
|
||||
return Self.response(json)
|
||||
case .notificationLogs(let limit, let offset):
|
||||
XCTAssertEqual(limit, 50)
|
||||
XCTAssertEqual(offset, 0)
|
||||
let json = """
|
||||
{"status":"ok","data":{"total":3,"logs":[
|
||||
{"id":"l-1","event_type":"device_offline","status":"success","created_at":"2026-09-08T10:00:00Z"},
|
||||
{"id":"l-2","event_type":"low_signal","status":"success","created_at":"2026-09-08T10:01:00Z"},
|
||||
{"id":"l-3","event_type":"device_offline","status":"failed","created_at":"2026-09-08T10:02:00Z"}
|
||||
]}}
|
||||
"""
|
||||
return Self.response(json)
|
||||
case .notificationQueue(let limit):
|
||||
XCTAssertEqual(limit, 100)
|
||||
let json = """
|
||||
{"status":"ok","data":{"total":2,"items":[
|
||||
{"id":"q-1","event_type":"device_offline","status":"pending","created_at":"2026-09-08T10:00:00Z"},
|
||||
{"id":"q-2","event_type":"low_signal","status":"failed","created_at":"2026-09-08T10:01:00Z"}
|
||||
]}}
|
||||
"""
|
||||
return Self.response(json)
|
||||
case .notificationRetryQueue, .notificationDeleteQueue:
|
||||
return Self.response(#"{"status":"ok"}"#)
|
||||
default:
|
||||
return UpstreamResponse(status: 404, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private static func response(_ json: String) -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
@MainActor
|
||||
final class NotificationsViewModelTests: XCTestCase {
|
||||
func testRefreshLoadsInstancesAndAggregatedOverview() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let client = NotificationViewModelClient()
|
||||
let service = NotificationService(client: client, sessions: InstanceSessionStore())
|
||||
let model = NotificationsViewModel(repository: repository, service: service)
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Notification Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.instances.count, 1)
|
||||
XCTAssertEqual(model.overview?.devices.first?.state, .ready)
|
||||
XCTAssertEqual(model.overview?.queue.items.first?.id, "queue-1")
|
||||
XCTAssertNil(model.errorMessage)
|
||||
}
|
||||
|
||||
func testRetryQueueTargetsOwningInstance() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let client = NotificationViewModelClient()
|
||||
let service = NotificationService(client: client, sessions: InstanceSessionStore())
|
||||
let model = NotificationsViewModel(repository: repository, service: service)
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Notification Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
await model.refresh()
|
||||
let item = try XCTUnwrap(model.overview?.queue.items.first)
|
||||
|
||||
await model.retryQueue(item)
|
||||
|
||||
XCTAssertEqual(client.routes.last, .notificationRetryQueue(id: "queue-1"))
|
||||
}
|
||||
|
||||
func testRetryAllTargetsEveryInstance() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let client = NotificationViewModelClient()
|
||||
let service = NotificationService(client: client, sessions: InstanceSessionStore())
|
||||
let model = NotificationsViewModel(repository: repository, service: service)
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "First",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Second",
|
||||
origin: URL(string: "http://192.168.1.21:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
await model.refresh()
|
||||
|
||||
await model.retryAll()
|
||||
|
||||
XCTAssertEqual(client.routes.filter { $0 == .notificationRetryAllQueue }.count, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private final class NotificationViewModelClient: SimAdminClientProtocol, @unchecked Sendable {
|
||||
var routes: [UpstreamRoute] = []
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
routes.append(route)
|
||||
switch route {
|
||||
case .notificationConfig:
|
||||
return Self.response(#"{"status":"ok","data":{"channels":[{"type":"webhook","enabled":true}]}}"#)
|
||||
case .notificationLogs:
|
||||
return Self.response(#"{"status":"ok","data":{"total":1,"logs":[{"id":"log-1","event_type":"system","status":"success","created_at":"2026-09-08T10:00:00Z"}]}}"#)
|
||||
case .notificationQueue:
|
||||
return Self.response(#"{"status":"ok","data":{"total":1,"items":[{"id":"queue-1","event_type":"system","status":"failed","created_at":"2026-09-08T10:00:00Z"}]}}"#)
|
||||
default:
|
||||
return Self.response(#"{"status":"ok"}"#)
|
||||
}
|
||||
}
|
||||
|
||||
private static func response(_ json: String) -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user