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:
Senior Engineer
2026-05-03 12:17:15 -04:00
committed by Michael Freno
parent 4f1ff9dbb0
commit 57eb01f5af
5 changed files with 583 additions and 136 deletions

View 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
}

View File

@@ -6,59 +6,90 @@ SwiftUI implementation of the notifications feature for the Lendair iOS app.
## Architecture ## Architecture
### MVVM Pattern ### MVVM Pattern
- **View**: `NotificationsView` - Main container view - **View**: `Views/` - SwiftUI views for notification display
- **ViewModel**: `NotificationsViewModel` - Manages notification state and business logic - **ViewModel**: `ViewModels/` - State management and business logic
- **Service**: `NotificationsService` - Data layer for API communication - **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 - Main navigation container for the notifications screen
- Implements pull-to-refresh functionality - Pull-to-refresh via `.refreshable`
- Handles empty state display - Empty state when no notifications
- Provides "Mark All Read" action in toolbar - "Mark All Read" toolbar button when unread count > 0
- Integrates with navigation stack - 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 - Individual notification list item
- Displays notification icon, title, message, and timestamp - Type-specific SF Symbol icon with color coding
- Shows read/unread indicator - Read/unread indicator (blue dot)
- Supports tap-to-mark-as-read interaction - Relative timestamp display
#### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`) ### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`)
- Observable object managing notification state - `@Published notifications` — sorted by createdAt descending
- Fetches notifications from service layer - `@Published isLoading` — loading state for UI feedback
- Handles mark-as-read and mark-all-as-read operations - `@Published error` — typed error state (NotificationError)
- Calculates unread count for badge display - `fetchNotifications()` — loads from service
- Implements refresh logic - `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 ## Notification Types
The app supports the following notification types: | Type | Icon | Color |
- `LOAN_APPROVED` - Green checkmark icon |------|------|-------|
- `LOAN_REJECTED` - Red X icon | `LOAN_APPROVED` | checkmark.circle.fill | Green |
- `PAYMENT_RECEIVED` - Green down arrow icon | `LOAN_REJECTED` | xmark.circle.fill | Red |
- `PAYMENT_DUE` - Orange exclamation icon | `PAYMENT_RECEIVED` | arrow.down.circle.fill | Green |
- `NEW_LENDER` - Blue person icon | `PAYMENT_DUE` | exclamationmark.circle.fill | Orange |
- `SYSTEM_UPDATE` - Gray info icon | `NEW_LENDER` | person.circle.fill | Blue |
| `SYSTEM_UPDATE` | info.circle.fill | Gray |
## Integration Points ## API Endpoints
### tRPC Router (TODO) | Method | Endpoint | Description |
The service layer is designed to connect to the tRPC notifications router: |--------|----------|-------------|
```typescript | GET | `/api/notifications?limit=&offset=` | List notifications |
// web/src/server/api/routers/notifications.ts | PATCH | `/api/notifications/:id/read` | Mark single as read |
notifications: router({ | PATCH | `/api/notifications/read-all` | Mark all as read |
list: protectedQuery(...),
markAsRead: protectedMutation(...),
markAllAsRead: protectedMutation(...),
})
```
### API Endpoints (TODO) ## Testing
- `GET /api/notifications` - List notifications
- `PATCH /api/notifications/:id/read` - Mark single as read Tests are in `LendairTests/NotificationServiceTests.swift`:
- `PATCH /api/notifications/read-all` - Mark all as read - 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 ## 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 ## Future Enhancements
1. **Push Notifications**: Integrate with UNUserNotificationCenter 1. **Push Notifications**: Integrate with UNUserNotificationCenter

View 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"
}

View File

@@ -6,132 +6,64 @@ class NotificationsViewModel: ObservableObject {
@Published var notifications: [NotificationItem] = [] @Published var notifications: [NotificationItem] = []
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var lastRefreshDate: Date? @Published var lastRefreshDate: Date?
@Published var error: NotificationError?
private let notificationsService: NotificationsService
private let notificationsService: NotificationsServiceProtocol
init(notificationsService: NotificationsService = NotificationsService()) {
init(notificationsService: NotificationsServiceProtocol = NotificationsService()) {
self.notificationsService = notificationsService self.notificationsService = notificationsService
} }
func fetchNotifications() async { func fetchNotifications() async {
isLoading = true isLoading = true
error = nil
defer { defer {
isLoading = false isLoading = false
lastRefreshDate = Date() lastRefreshDate = Date()
} }
do { do {
let fetchedNotifications = try await notificationsService.list() let fetchedNotifications = try await notificationsService.list()
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt } notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
} catch let error as NotificationError {
self.error = error
} catch { } catch {
print("Failed to fetch notifications: \(error)") print("Failed to fetch notifications: \(error)")
} }
} }
func refresh() async { func refresh() async {
await fetchNotifications() await fetchNotifications()
} }
func markAsRead(id: String) async { func markAsRead(id: String) async {
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
do { do {
try await notificationsService.markAsRead(id: id) try await notificationsService.markAsRead(id: id)
notifications[index].isRead = true notifications[index].isRead = true
objectWillChange.send()
} catch { } catch {
print("Failed to mark notification as read: \(error)") print("Failed to mark notification as read: \(error)")
} }
} }
func markAllAsRead() async { func markAllAsRead() async {
let unreadIds = notifications.filter { !$0.isRead }.map { $0.id } let unreadIds = notifications.filter { !$0.isRead }.map { $0.id }
guard !unreadIds.isEmpty else { return } guard !unreadIds.isEmpty else { return }
do { do {
try await notificationsService.markAllAsRead() try await notificationsService.markAllAsRead()
for index in notifications.indices { for index in notifications.indices {
notifications[index].isRead = true notifications[index].isRead = true
} }
objectWillChange.send()
} catch { } catch {
print("Failed to mark all as read: \(error)") print("Failed to mark all as read: \(error)")
} }
} }
var unreadCount: Int { var unreadCount: Int {
notifications.filter { !$0.isRead }.count 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", {})
}
}

View 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)
}
}