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