FRE-4738: Implement mark-as-read and mark-all-read actions
- Extract NotificationItem/NotificationType to Models/Notification.swift - Create NotificationsServiceProtocol with testable service layer - Implement markAsRead(id:) and markAllAsRead() with HTTP calls - Add NotificationError enum with localized descriptions - Update NotificationsViewModel to use protocol-based service - Add 18 unit tests (12 ViewModel + 6 Model) with mock service - Update README with architecture documentation
This commit is contained in:
267
LendairTests/NotificationServiceTests.swift
Normal file
267
LendairTests/NotificationServiceTests.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user