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:
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"
|
||||
}
|
||||
Reference in New Issue
Block a user