Files
FrenoCorp/LendairTests/NotificationServiceTests.swift
Michael Freno cb55ad95e2 Add notification badge count and MainTabView with notification tab FRE-4740 FRE-4739
- Add getUnreadCount() endpoint to NotificationsServiceProtocol
- Add NotificationUnreadCountResponse model
- Add badgeCount and fetchUnreadCount() to NotificationsViewModel
- Update markAsRead/markAllAsRead to decrement badge count
- Create MainTabView with Home, Challenges, Clubs, Notifications tabs
- Add unread badge on notification tab using .badge() modifier
- Support injected ViewModel in NotificationsView for shared state
- Add badge count tests to NotificationServiceTests
- Fetch unread count on app launch and tab switch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 20:16:05 -04:00

360 lines
12 KiB
Swift

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?
var mockUnreadCount: Int = 0
var getUnreadCountCallCount = 0
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
}
func getUnreadCount() async throws -> Int {
getUnreadCountCallCount += 1
return mockUnreadCount
}
}
// 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: - Badge Count
@MainActor
func testFetchUnreadCountSetsBadgeCount() async {
let mock = MockNotificationsService()
mock.mockUnreadCount = 5
let viewModel = NotificationsViewModel(notificationsService: mock)
await viewModel.fetchUnreadCount()
XCTAssertEqual(viewModel.badgeCount, 5)
XCTAssertEqual(mock.getUnreadCountCallCount, 1)
}
@MainActor
func testFetchUnreadCountZero() async {
let mock = MockNotificationsService()
mock.mockUnreadCount = 0
let viewModel = NotificationsViewModel(notificationsService: mock)
await viewModel.fetchUnreadCount()
XCTAssertEqual(viewModel.badgeCount, 0)
}
@MainActor
func testMarkAsReadDecrementsBadgeCount() async {
let mock = MockNotificationsService()
let unread = NotificationItem.sample(id: "1", isRead: false)
let viewModel = NotificationsViewModel(notificationsService: mock)
viewModel.notifications = [unread]
viewModel.badgeCount = 3
await viewModel.markAsRead(id: "1")
XCTAssertEqual(viewModel.badgeCount, 2)
XCTAssertTrue(viewModel.notifications.first?.isRead == true)
}
@MainActor
func testMarkAsReadBadgeCountDoesNotGoBelowZero() async {
let mock = MockNotificationsService()
let unread = NotificationItem.sample(id: "1", isRead: false)
let viewModel = NotificationsViewModel(notificationsService: mock)
viewModel.notifications = [unread]
viewModel.badgeCount = 0
await viewModel.markAsRead(id: "1")
XCTAssertEqual(viewModel.badgeCount, 0)
}
@MainActor
func testMarkAllAsReadResetsBadgeCount() async {
let mock = MockNotificationsService()
let unread1 = NotificationItem.sample(id: "1", isRead: false)
let unread2 = NotificationItem.sample(id: "2", isRead: false)
let viewModel = NotificationsViewModel(notificationsService: mock)
viewModel.notifications = [unread1, unread2]
viewModel.badgeCount = 7
await viewModel.markAllAsRead()
XCTAssertEqual(viewModel.badgeCount, 0)
}
@MainActor
func testFetchNotificationsUpdatesBadgeCount() async {
let mock = MockNotificationsService()
mock.notifications = [
NotificationItem.sample(id: "1", isRead: false),
NotificationItem.sample(id: "2", isRead: true),
NotificationItem.sample(id: "3", isRead: false),
]
let viewModel = NotificationsViewModel(notificationsService: mock)
viewModel.badgeCount = 0
await viewModel.fetchNotifications()
XCTAssertEqual(viewModel.badgeCount, 2)
}
}
// 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)
}
}