- 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
268 lines
8.7 KiB
Swift
268 lines
8.7 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?
|
|
|
|
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)
|
|
}
|
|
}
|