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:
92
Lendair/Models/Notification.swift
Normal file
92
Lendair/Models/Notification.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Notification Item
|
||||
|
||||
struct NotificationItem: Identifiable, Equatable, Codable {
|
||||
let id: String
|
||||
let type: NotificationType
|
||||
let title: String
|
||||
let message: String
|
||||
let createdAt: Date
|
||||
var isRead: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, type, title, message, createdAt, isRead
|
||||
}
|
||||
|
||||
init(id: String, type: NotificationType, title: String, message: String, createdAt: Date, isRead: Bool) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.createdAt = createdAt
|
||||
self.isRead = isRead
|
||||
}
|
||||
|
||||
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
|
||||
lhs.id == rhs.id && lhs.isRead == rhs.isRead
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Type
|
||||
|
||||
enum NotificationType: String, CaseIterable, Codable {
|
||||
case loanApproved = "LOAN_APPROVED"
|
||||
case loanRejected = "LOAN_REJECTED"
|
||||
case paymentReceived = "PAYMENT_RECEIVED"
|
||||
case paymentDue = "PAYMENT_DUE"
|
||||
case newLender = "NEW_LENDER"
|
||||
case systemUpdate = "SYSTEM_UPDATE"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .loanApproved: return "checkmark.circle.fill"
|
||||
case .loanRejected: return "xmark.circle.fill"
|
||||
case .paymentReceived: return "arrow.down.circle.fill"
|
||||
case .paymentDue: return "exclamationmark.circle.fill"
|
||||
case .newLender: return "person.circle.fill"
|
||||
case .systemUpdate: return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .loanApproved: return .green
|
||||
case .loanRejected: return .red
|
||||
case .paymentReceived: return .green
|
||||
case .paymentDue: return .orange
|
||||
case .newLender: return .blue
|
||||
case .systemUpdate: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Parameters
|
||||
|
||||
struct NotificationListParams: Encodable {
|
||||
var limit: Int
|
||||
var offset: Int
|
||||
|
||||
init(limit: Int = 20, offset: Int = 0) {
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Types
|
||||
|
||||
struct NotificationListResponse: Decodable {
|
||||
let notifications: [NotificationItem]
|
||||
let hasMore: Bool
|
||||
}
|
||||
|
||||
struct NotificationMarkAsReadResponse: Decodable {
|
||||
let success: Bool
|
||||
let notificationId: String
|
||||
}
|
||||
|
||||
struct NotificationMarkAllReadResponse: Decodable {
|
||||
let success: Bool
|
||||
let markedCount: Int
|
||||
}
|
||||
@@ -6,59 +6,90 @@ SwiftUI implementation of the notifications feature for the Lendair iOS app.
|
||||
## Architecture
|
||||
|
||||
### MVVM Pattern
|
||||
- **View**: `NotificationsView` - Main container view
|
||||
- **ViewModel**: `NotificationsViewModel` - Manages notification state and business logic
|
||||
- **Service**: `NotificationsService` - Data layer for API communication
|
||||
- **View**: `Views/` - SwiftUI views for notification display
|
||||
- **ViewModel**: `ViewModels/` - State management and business logic
|
||||
- **Service**: `Services/` - Data layer with API communication
|
||||
- **Model**: `Models/` - Data structures and type definitions
|
||||
|
||||
### Components
|
||||
### File Structure
|
||||
```
|
||||
Lendair/
|
||||
├── Models/
|
||||
│ └── Notification.swift # NotificationItem, NotificationType, API response types
|
||||
├── Services/
|
||||
│ └── NotificationService.swift # NotificationsServiceProtocol + implementation
|
||||
├── ViewModels/
|
||||
│ └── NotificationsViewModel.swift # State management, mark-as-read actions
|
||||
├── Views/
|
||||
│ ├── NotificationsView.swift # Main notifications list screen
|
||||
│ └── NotificationRowView.swift # Individual notification row
|
||||
└── README.md
|
||||
```
|
||||
|
||||
#### NotificationsView (`Views/NotificationsView.swift`)
|
||||
## Components
|
||||
|
||||
### NotificationsView (`Views/NotificationsView.swift`)
|
||||
- Main navigation container for the notifications screen
|
||||
- Implements pull-to-refresh functionality
|
||||
- Handles empty state display
|
||||
- Provides "Mark All Read" action in toolbar
|
||||
- Integrates with navigation stack
|
||||
- Pull-to-refresh via `.refreshable`
|
||||
- Empty state when no notifications
|
||||
- "Mark All Read" toolbar button when unread count > 0
|
||||
- Tap-to-mark-as-read on individual rows
|
||||
- Swipe-to-delete (TODO: backend integration)
|
||||
|
||||
#### NotificationRowView (`Views/NotificationRowView.swift`)
|
||||
### NotificationRowView (`Views/NotificationRowView.swift`)
|
||||
- Individual notification list item
|
||||
- Displays notification icon, title, message, and timestamp
|
||||
- Shows read/unread indicator
|
||||
- Supports tap-to-mark-as-read interaction
|
||||
- Type-specific SF Symbol icon with color coding
|
||||
- Read/unread indicator (blue dot)
|
||||
- Relative timestamp display
|
||||
|
||||
#### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`)
|
||||
- Observable object managing notification state
|
||||
- Fetches notifications from service layer
|
||||
- Handles mark-as-read and mark-all-as-read operations
|
||||
- Calculates unread count for badge display
|
||||
- Implements refresh logic
|
||||
### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`)
|
||||
- `@Published notifications` — sorted by createdAt descending
|
||||
- `@Published isLoading` — loading state for UI feedback
|
||||
- `@Published error` — typed error state (NotificationError)
|
||||
- `fetchNotifications()` — loads from service
|
||||
- `markAsRead(id:)` — marks single notification, updates local state
|
||||
- `markAllAsRead()` — marks all unread, updates local state
|
||||
- `unreadCount` — computed property for badge display
|
||||
|
||||
### NotificationsService (`Services/NotificationService.swift`)
|
||||
- Protocol: `NotificationsServiceProtocol` (Sendable, testable)
|
||||
- `list(params:)` — GET `/api/notifications?limit=&offset=`
|
||||
- `markAsRead(id:)` — PATCH `/api/notifications/:id/read`
|
||||
- `markAllAsRead()` — PATCH `/api/notifications/read-all`
|
||||
- Error handling: `NotificationError` enum with localized descriptions
|
||||
- Configurable: baseURL, URLSession, authToken
|
||||
|
||||
### Models (`Models/Notification.swift`)
|
||||
- `NotificationItem` — Identifiable, Equatable, Codable
|
||||
- `NotificationType` — 6 cases with icon/color mappings
|
||||
- `NotificationListParams` — pagination parameters
|
||||
- `NotificationListResponse`, `NotificationMarkAsReadResponse`, `NotificationMarkAllReadResponse` — API response types
|
||||
|
||||
## Notification Types
|
||||
|
||||
The app supports the following notification types:
|
||||
- `LOAN_APPROVED` - Green checkmark icon
|
||||
- `LOAN_REJECTED` - Red X icon
|
||||
- `PAYMENT_RECEIVED` - Green down arrow icon
|
||||
- `PAYMENT_DUE` - Orange exclamation icon
|
||||
- `NEW_LENDER` - Blue person icon
|
||||
- `SYSTEM_UPDATE` - Gray info icon
|
||||
| Type | Icon | Color |
|
||||
|------|------|-------|
|
||||
| `LOAN_APPROVED` | checkmark.circle.fill | Green |
|
||||
| `LOAN_REJECTED` | xmark.circle.fill | Red |
|
||||
| `PAYMENT_RECEIVED` | arrow.down.circle.fill | Green |
|
||||
| `PAYMENT_DUE` | exclamationmark.circle.fill | Orange |
|
||||
| `NEW_LENDER` | person.circle.fill | Blue |
|
||||
| `SYSTEM_UPDATE` | info.circle.fill | Gray |
|
||||
|
||||
## Integration Points
|
||||
## API Endpoints
|
||||
|
||||
### tRPC Router (TODO)
|
||||
The service layer is designed to connect to the tRPC notifications router:
|
||||
```typescript
|
||||
// web/src/server/api/routers/notifications.ts
|
||||
notifications: router({
|
||||
list: protectedQuery(...),
|
||||
markAsRead: protectedMutation(...),
|
||||
markAllAsRead: protectedMutation(...),
|
||||
})
|
||||
```
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/notifications?limit=&offset=` | List notifications |
|
||||
| PATCH | `/api/notifications/:id/read` | Mark single as read |
|
||||
| PATCH | `/api/notifications/read-all` | Mark all as read |
|
||||
|
||||
### API Endpoints (TODO)
|
||||
- `GET /api/notifications` - List notifications
|
||||
- `PATCH /api/notifications/:id/read` - Mark single as read
|
||||
- `PATCH /api/notifications/read-all` - Mark all as read
|
||||
## Testing
|
||||
|
||||
Tests are in `LendairTests/NotificationServiceTests.swift`:
|
||||
- 12 ViewModel tests (fetch, mark-as-read, mark-all-read, unread count, refresh, error handling)
|
||||
- 6 Model tests (icons, colors, equality, raw values, params)
|
||||
- Uses `MockNotificationsService` conforming to `NotificationsServiceProtocol`
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -69,15 +100,6 @@ NavigationStack {
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the preview in Xcode to see the notification row designs:
|
||||
```swift
|
||||
#Preview {
|
||||
NotificationRowView(notification: sampleNotification)
|
||||
}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Push Notifications**: Integrate with UNUserNotificationCenter
|
||||
|
||||
134
Lendair/Services/NotificationService.swift
Normal file
134
Lendair/Services/NotificationService.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Service Protocol
|
||||
|
||||
protocol NotificationsServiceProtocol: Sendable {
|
||||
func list(params: NotificationListParams) async throws -> [NotificationItem]
|
||||
func markAsRead(id: String) async throws
|
||||
func markAllAsRead() async throws
|
||||
}
|
||||
|
||||
// MARK: - Default Service
|
||||
|
||||
class NotificationsService: NotificationsServiceProtocol {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let authToken: String?
|
||||
|
||||
init(
|
||||
baseURL: URL = URL(string: "http://localhost:3000")!,
|
||||
session: URLSession = .shared,
|
||||
authToken: String? = nil
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.authToken = authToken
|
||||
}
|
||||
|
||||
func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("/api/notifications"), resolvingAgainstBaseURL: true)!
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "limit", value: String(params.limit)),
|
||||
URLQueryItem(name: "offset", value: String(params.offset))
|
||||
]
|
||||
components.queryItems = queryItems
|
||||
|
||||
let request = try buildRequest(url: components.url!)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(NotificationListResponse.self, from: data)
|
||||
return decoded.notifications
|
||||
}
|
||||
|
||||
func markAsRead(id: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/notifications/\(id)/read")
|
||||
let request = try buildRequest(url: url, method: .patch)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
_ = try JSONDecoder().decode(NotificationMarkAsReadResponse.self, from: data)
|
||||
}
|
||||
|
||||
func markAllAsRead() async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/notifications/read-all")
|
||||
let request = try buildRequest(url: url, method: .patch)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
_ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private func validateResponse(_ response: URLResponse) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NotificationError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
switch httpResponse.statusCode {
|
||||
case 401: throw NotificationError.unauthorized
|
||||
case 403: throw NotificationError.forbidden
|
||||
case 404: throw NotificationError.notFound
|
||||
case 429: throw NotificationError.rateLimited
|
||||
case 500...599: throw NotificationError.serverError(httpResponse.statusCode)
|
||||
default: throw NotificationError.httpError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum NotificationError: LocalizedError {
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case forbidden
|
||||
case notFound
|
||||
case rateLimited
|
||||
case serverError(Int)
|
||||
case httpError(Int)
|
||||
case decodingError(Error)
|
||||
|
||||
var errorDescription: String {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .unauthorized: return "Unauthorized — please log in again"
|
||||
case .forbidden: return "Forbidden — check permissions"
|
||||
case .notFound: return "Notification not found"
|
||||
case .rateLimited: return "Too many requests — try again shortly"
|
||||
case .serverError(let code): return "Server error (\(code))"
|
||||
case .httpError(let code): return "HTTP error (\(code))"
|
||||
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTP Method
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case patch = "PATCH"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
@@ -6,132 +6,64 @@ class NotificationsViewModel: ObservableObject {
|
||||
@Published var notifications: [NotificationItem] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var lastRefreshDate: Date?
|
||||
|
||||
private let notificationsService: NotificationsService
|
||||
|
||||
init(notificationsService: NotificationsService = NotificationsService()) {
|
||||
@Published var error: NotificationError?
|
||||
|
||||
private let notificationsService: NotificationsServiceProtocol
|
||||
|
||||
init(notificationsService: NotificationsServiceProtocol = NotificationsService()) {
|
||||
self.notificationsService = notificationsService
|
||||
}
|
||||
|
||||
|
||||
func fetchNotifications() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer {
|
||||
isLoading = false
|
||||
lastRefreshDate = Date()
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
let fetchedNotifications = try await notificationsService.list()
|
||||
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
|
||||
} catch let error as NotificationError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to fetch notifications: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func refresh() async {
|
||||
await fetchNotifications()
|
||||
}
|
||||
|
||||
|
||||
func markAsRead(id: String) async {
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
|
||||
|
||||
|
||||
do {
|
||||
try await notificationsService.markAsRead(id: id)
|
||||
notifications[index].isRead = true
|
||||
objectWillChange.send()
|
||||
} catch {
|
||||
print("Failed to mark notification as read: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func markAllAsRead() async {
|
||||
let unreadIds = notifications.filter { !$0.isRead }.map { $0.id }
|
||||
guard !unreadIds.isEmpty else { return }
|
||||
|
||||
|
||||
do {
|
||||
try await notificationsService.markAllAsRead()
|
||||
for index in notifications.indices {
|
||||
notifications[index].isRead = true
|
||||
}
|
||||
objectWillChange.send()
|
||||
} catch {
|
||||
print("Failed to mark all as read: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var unreadCount: Int {
|
||||
notifications.filter { !$0.isRead }.count
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let type: NotificationType
|
||||
let title: String
|
||||
let message: String
|
||||
let createdAt: Date
|
||||
var isRead: Bool
|
||||
|
||||
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
|
||||
lhs.id == rhs.id && lhs.isRead == rhs.isRead
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationType: String, CaseIterable {
|
||||
case loanApproved = "LOAN_APPROVED"
|
||||
case loanRejected = "LOAN_REJECTED"
|
||||
case paymentReceived = "PAYMENT_RECEIVED"
|
||||
case paymentDue = "PAYMENT_DUE"
|
||||
case newLender = "NEW_LENDER"
|
||||
case systemUpdate = "SYSTEM_UPDATE"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .loanApproved:
|
||||
return "checkmark.circle.fill"
|
||||
case .loanRejected:
|
||||
return "xmark.circle.fill"
|
||||
case .paymentReceived:
|
||||
return "arrow.down.circle.fill"
|
||||
case .paymentDue:
|
||||
return "exclamationmark.circle.fill"
|
||||
case .newLender:
|
||||
return "person.circle.fill"
|
||||
case .systemUpdate:
|
||||
return "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .loanApproved:
|
||||
return .green
|
||||
case .loanRejected:
|
||||
return .red
|
||||
case .paymentReceived:
|
||||
return .green
|
||||
case .paymentDue:
|
||||
return .orange
|
||||
case .newLender:
|
||||
return .blue
|
||||
case .systemUpdate:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsService {
|
||||
func list() async throws -> [NotificationItem] {
|
||||
// TODO: Connect to tRPC notifications router
|
||||
// let result = await APIClient.shared.query("notifications.list")
|
||||
return []
|
||||
}
|
||||
|
||||
func markAsRead(id: String) async throws {
|
||||
// TODO: Connect to tRPC notifications router
|
||||
// try await APIClient.shared.mutation("notifications.markAsRead", { id })
|
||||
}
|
||||
|
||||
func markAllAsRead() async throws {
|
||||
// TODO: Connect to tRPC notifications router
|
||||
// try await APIClient.shared.mutation("notifications.markAllAsRead", {})
|
||||
}
|
||||
}
|
||||
|
||||
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