import XCTest import SwiftUI @testable import Lendair // MARK: - Mock Service final class MockNotificationsService: NotificationsServiceProtocol { var notifications: [NotificationItem] = [] var markedReadIds: [String] = [] var markAllCalled = false var listCallCount = 0 var listError: Error? func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] { listCallCount += 1 if let error = listError { throw error } return notifications } func markAsRead(id: String) async throws { markedReadIds.append(id) } func markAllAsRead() async throws { markAllCalled = true } } // MARK: - Helper: Sample Notifications extension NotificationItem { static func sample( id: String = "test-1", type: NotificationType = .loanApproved, title: String = "Test", message: String = "Test message", isRead: Bool = false ) -> NotificationItem { NotificationItem( id: id, type: type, title: title, message: message, createdAt: Date(), isRead: isRead ) } } // MARK: - NotificationServiceTests final class NotificationServiceTests: XCTestCase { // MARK: - Fetch Notifications @MainActor func testFetchNotificationsLoadsData() async { let mock = MockNotificationsService() mock.notifications = [.sample(id: "1"), .sample(id: "2")] let viewModel = NotificationsViewModel(notificationsService: mock) await viewModel.fetchNotifications() XCTAssertEqual(viewModel.notifications.count, 2) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(mock.listCallCount, 1) } @MainActor func testFetchNotificationsSortsByCreatedAtDescending() async { let mock = MockNotificationsService() let older = NotificationItem.sample(id: "1", createdAt: Date().addingTimeInterval(-3600)) let newer = NotificationItem.sample(id: "2", createdAt: Date()) mock.notifications = [newer, older] let viewModel = NotificationsViewModel(notificationsService: mock) await viewModel.fetchNotifications() XCTAssertEqual(viewModel.notifications.first?.id, "2") XCTAssertEqual(viewModel.notifications.last?.id, "1") } @MainActor func testFetchNotificationsSetsLoadingState() async { let mock = MockNotificationsService() let viewModel = NotificationsViewModel(notificationsService: mock) await viewModel.fetchNotifications() XCTAssertFalse(viewModel.isLoading) XCTAssertNotNil(viewModel.lastRefreshDate) } @MainActor func testFetchNotificationsHandlesError() async { let mock = MockNotificationsService() mock.listError = NotificationError.unauthorized let viewModel = NotificationsViewModel(notificationsService: mock) await viewModel.fetchNotifications() XCTAssertTrue(viewModel.notifications.isEmpty) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(viewModel.error, .unauthorized) } // MARK: - Mark As Read @MainActor func testMarkAsReadUpdatesLocalState() async { let mock = MockNotificationsService() let unread = NotificationItem.sample(id: "1", isRead: false) mock.notifications = [unread] let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [unread] await viewModel.markAsRead(id: "1") XCTAssertTrue(viewModel.notifications.first?.isRead == true) XCTAssertEqual(mock.markedReadIds, ["1"]) } @MainActor func testMarkAsReadIgnoresUnknownId() async { let mock = MockNotificationsService() let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [.sample(id: "1")] await viewModel.markAsRead(id: "999") XCTAssertTrue(mock.markedReadIds.isEmpty) } @MainActor func testMarkAsReadReducesUnreadCount() async { let mock = MockNotificationsService() let read = NotificationItem.sample(id: "1", isRead: true) let unread = NotificationItem.sample(id: "2", isRead: false) let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [read, unread] XCTAssertEqual(viewModel.unreadCount, 1) await viewModel.markAsRead(id: "2") XCTAssertEqual(viewModel.unreadCount, 0) } // MARK: - Mark All As Read @MainActor func testMarkAllAsReadUpdatesAllNotifications() async { let mock = MockNotificationsService() let unread1 = NotificationItem.sample(id: "1", isRead: false) let unread2 = NotificationItem.sample(id: "2", isRead: false) let read = NotificationItem.sample(id: "3", isRead: true) let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [unread1, unread2, read] await viewModel.markAllAsRead() XCTAssertTrue(viewModel.notifications.allSatisfy { $0.isRead }) XCTAssertTrue(mock.markAllCalled) XCTAssertEqual(viewModel.unreadCount, 0) } @MainActor func testMarkAllAsReadNoOpWhenAllRead() async { let mock = MockNotificationsService() let read1 = NotificationItem.sample(id: "1", isRead: true) let read2 = NotificationItem.sample(id: "2", isRead: true) let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [read1, read2] await viewModel.markAllAsRead() XCTAssertFalse(mock.markAllCalled) } // MARK: - Unread Count @MainActor func testUnreadCountCalculatesCorrectly() async { let mock = MockNotificationsService() let viewModel = NotificationsViewModel(notificationsService: mock) viewModel.notifications = [ NotificationItem.sample(id: "1", isRead: false), NotificationItem.sample(id: "2", isRead: true), NotificationItem.sample(id: "3", isRead: false), ] XCTAssertEqual(viewModel.unreadCount, 2) } @MainActor func testUnreadCountIsEmptyWhenNoNotifications() async { let mock = MockNotificationsService() let viewModel = NotificationsViewModel(notificationsService: mock) XCTAssertEqual(viewModel.unreadCount, 0) } // MARK: - Refresh @MainActor func testRefreshReloadsData() async { let mock = MockNotificationsService() mock.notifications = [.sample(id: "1")] let viewModel = NotificationsViewModel(notificationsService: mock) await viewModel.refresh() XCTAssertEqual(mock.listCallCount, 1) XCTAssertEqual(viewModel.notifications.count, 1) } } // MARK: - NotificationModelTests final class NotificationModelTests: XCTestCase { func testNotificationTypeIcons() { XCTAssertEqual(NotificationType.loanApproved.icon, "checkmark.circle.fill") XCTAssertEqual(NotificationType.loanRejected.icon, "xmark.circle.fill") XCTAssertEqual(NotificationType.paymentReceived.icon, "arrow.down.circle.fill") XCTAssertEqual(NotificationType.paymentDue.icon, "exclamationmark.circle.fill") XCTAssertEqual(NotificationType.newLender.icon, "person.circle.fill") XCTAssertEqual(NotificationType.systemUpdate.icon, "info.circle.fill") } func testNotificationTypeColors() { XCTAssertEqual(NotificationType.loanApproved.color, .green) XCTAssertEqual(NotificationType.loanRejected.color, .red) XCTAssertEqual(NotificationType.paymentReceived.color, .green) XCTAssertEqual(NotificationType.paymentDue.color, .orange) XCTAssertEqual(NotificationType.newLender.color, .blue) XCTAssertEqual(NotificationType.systemUpdate.color, .gray) } func testNotificationItemEquality() { let a = NotificationItem.sample(id: "1", isRead: false) let b = NotificationItem.sample(id: "1", isRead: false) let c = NotificationItem.sample(id: "1", isRead: true) XCTAssertEqual(a, b) XCTAssertNotEqual(a, c) } func testNotificationTypeRawValue() { XCTAssertEqual(NotificationType.loanApproved.rawValue, "LOAN_APPROVED") XCTAssertEqual(NotificationType.paymentDue.rawValue, "PAYMENT_DUE") } func testNotificationListParamsDefaults() { let params = NotificationListParams() XCTAssertEqual(params.limit, 20) XCTAssertEqual(params.offset, 0) } func testNotificationListParamsCustom() { let params = NotificationListParams(limit: 50, offset: 100) XCTAssertEqual(params.limit, 50) XCTAssertEqual(params.offset, 100) } }