- 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
135 lines
4.5 KiB
Swift
135 lines
4.5 KiB
Swift
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"
|
|
}
|