Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ios/KickWatch/Sources/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,21 @@ enum APIError: LocalizedError {
}
}

actor APIClient {
actor APIClient: APIClientProtocol {
static let shared = APIClient()

private let baseURL: String
private let session: URLSession

init(baseURL: String? = nil) {
init(baseURL: String? = nil, urlSession: URLSession? = nil) {
#if DEBUG
self.baseURL = baseURL ?? "https://api-dev.kickwatch.rescience.com"
#else
self.baseURL = baseURL ?? "https://api.kickwatch.rescience.com"
#endif
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
self.session = urlSession ?? URLSession(configuration: config)
}

func fetchCampaigns(sort: String = "trending", categoryID: String? = nil, cursor: String? = nil) async throws -> CampaignListResponse {
Expand Down
13 changes: 13 additions & 0 deletions ios/KickWatch/Sources/Services/APIClientProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

protocol APIClientProtocol: Sendable {
func fetchCampaigns(sort: String, categoryID: String?, cursor: String?) async throws -> CampaignListResponse
func searchCampaigns(query: String, categoryID: String?, cursor: String?) async throws -> SearchResponse
func fetchCategories() async throws -> [CategoryDTO]
func registerDevice(token: String) async throws -> RegisterDeviceResponse
func fetchAlerts(deviceID: String) async throws -> [AlertDTO]
func createAlert(_ req: CreateAlertRequest) async throws -> AlertDTO
func updateAlert(id: String, req: UpdateAlertRequest) async throws -> AlertDTO
func deleteAlert(id: String) async throws
func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO]
}
7 changes: 6 additions & 1 deletion ios/KickWatch/Sources/Services/ImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import SwiftUI
actor ImageCache {
static let shared = ImageCache()
private var cache: [URL: Image] = [:]
private let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

func image(for url: URL) async -> Image? {
if let cached = cache[url] { return cached }
guard let (data, _) = try? await URLSession.shared.data(from: url),
guard let (data, _) = try? await session.data(from: url),
let uiImage = UIImage(data: data) else { return nil }
let image = Image(uiImage: uiImage)
cache[url] = image
Expand Down
14 changes: 10 additions & 4 deletions ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import Foundation

@Observable
final class AlertsViewModel {
private let client: any APIClientProtocol

var alerts: [AlertDTO] = []
var isLoading = false
var error: String?

init(client: any APIClientProtocol = APIClient.shared) {
self.client = client
}

func load(deviceID: String) async {
isLoading = true
error = nil
do {
alerts = try await APIClient.shared.fetchAlerts(deviceID: deviceID)
alerts = try await client.fetchAlerts(deviceID: deviceID)
} catch {
self.error = error.localizedDescription
}
Expand All @@ -27,7 +33,7 @@ final class AlertsViewModel {
velocity_thresh: velocityThresh > 0 ? velocityThresh : nil
)
do {
let alert = try await APIClient.shared.createAlert(req)
let alert = try await client.createAlert(req)
alerts.insert(alert, at: 0)
} catch {
self.error = error.localizedDescription
Expand All @@ -37,7 +43,7 @@ final class AlertsViewModel {
func toggleAlert(_ alert: AlertDTO) async {
let req = UpdateAlertRequest(is_enabled: !alert.is_enabled, keyword: nil, category_id: nil, min_percent: nil)
do {
let updated = try await APIClient.shared.updateAlert(id: alert.id, req: req)
let updated = try await client.updateAlert(id: alert.id, req: req)
if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
alerts[idx] = updated
}
Expand All @@ -48,7 +54,7 @@ final class AlertsViewModel {

func deleteAlert(_ alert: AlertDTO) async {
do {
try await APIClient.shared.deleteAlert(id: alert.id)
try await client.deleteAlert(id: alert.id)
alerts.removeAll { $0.id == alert.id }
} catch {
self.error = error.localizedDescription
Expand Down
12 changes: 9 additions & 3 deletions ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import SwiftData

@Observable
final class DiscoverViewModel {
private let client: any APIClientProtocol

var campaigns: [CampaignDTO] = []
var categories: [CategoryDTO] = []
var isLoading = false
Expand All @@ -14,11 +16,15 @@ final class DiscoverViewModel {
var selectedSort = "trending"
var selectedCategoryID: String?

init(client: any APIClientProtocol = APIClient.shared) {
self.client = client
}

func load() async {
isLoading = true
error = nil
do {
let resp = try await APIClient.shared.fetchCampaigns(
let resp = try await client.fetchCampaigns(
sort: selectedSort, categoryID: selectedCategoryID, cursor: nil
)
campaigns = resp.campaigns
Expand All @@ -34,7 +40,7 @@ final class DiscoverViewModel {
guard hasMore, let cursor = nextCursor, !isLoadingMore else { return }
isLoadingMore = true
do {
let resp = try await APIClient.shared.fetchCampaigns(
let resp = try await client.fetchCampaigns(
sort: selectedSort, categoryID: selectedCategoryID, cursor: cursor
)
campaigns.append(contentsOf: resp.campaigns)
Expand All @@ -49,7 +55,7 @@ final class DiscoverViewModel {
func loadCategories() async {
guard categories.isEmpty else { return }
do {
categories = try await APIClient.shared.fetchCategories()
categories = try await client.fetchCategories()
} catch {
print("DiscoverViewModel: failed to load categories: \(error)")
}
Expand Down
203 changes: 203 additions & 0 deletions ios/KickWatch/Tests/APIClientTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import XCTest
@testable import KickWatch

final class APIClientTests: XCTestCase {
private var client: APIClient!

override func setUp() async throws {
client = APIClient(baseURL: "https://test.example.com", urlSession: .mock())
}

override func tearDown() async throws {
MockURLProtocol.requestHandler = nil
client = nil
}

// MARK: - fetchCampaigns URL building

func testFetchCampaignsURLIncludesSort() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: """
{"campaigns":[],"next_cursor":null,"total":0}
""")
}

_ = try await client.fetchCampaigns(sort: "newest", categoryID: nil, cursor: nil)

let items = queryItems(from: capturedRequest)
XCTAssertTrue(items.contains(URLQueryItem(name: "sort", value: "newest")))
}

func testFetchCampaignsURLIncludesCategoryID() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: """
{"campaigns":[],"next_cursor":null,"total":0}
""")
}

_ = try await client.fetchCampaigns(sort: "trending", categoryID: "16", cursor: nil)

let items = queryItems(from: capturedRequest)
XCTAssertTrue(items.contains(URLQueryItem(name: "category_id", value: "16")))
}

func testFetchCampaignsURLIncludesCursor() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: """
{"campaigns":[],"next_cursor":null,"total":0}
""")
}

_ = try await client.fetchCampaigns(sort: "trending", categoryID: nil, cursor: "cursor-abc")

let items = queryItems(from: capturedRequest)
XCTAssertTrue(items.contains(URLQueryItem(name: "cursor", value: "cursor-abc")))
}

func testFetchCampaignsOmitsCategoryIDWhenNil() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: """
{"campaigns":[],"next_cursor":null,"total":0}
""")
}

_ = try await client.fetchCampaigns(sort: "trending", categoryID: nil, cursor: nil)

let items = queryItems(from: capturedRequest)
XCTAssertFalse(items.contains(where: { $0.name == "category_id" }))
}

// MARK: - fetchCampaigns errors

func testFetchCampaignsThrowsOn500() async {
MockURLProtocol.requestHandler = { request in
(HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data())
}

do {
_ = try await client.fetchCampaigns(sort: "trending", categoryID: nil, cursor: nil)
XCTFail("Expected throw")
} catch APIError.serverError(let code) {
XCTAssertEqual(code, 500)
} catch {
XCTFail("Unexpected error: \(error)")
}
}

func testFetchCampaignsThrowsOn404() async {
MockURLProtocol.requestHandler = { request in
(HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!, Data())
}

do {
_ = try await client.fetchCampaigns(sort: "trending", categoryID: nil, cursor: nil)
XCTFail("Expected throw")
} catch APIError.serverError(let code) {
XCTAssertEqual(code, 404)
} catch {
XCTFail("Unexpected error: \(error)")
}
}

// MARK: - searchCampaigns

func testSearchCampaignsURLIncludesQuery() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: """
{"campaigns":[],"next_cursor":null}
""")
}

_ = try await client.searchCampaigns(query: "board games", categoryID: nil, cursor: nil)

let items = queryItems(from: capturedRequest)
XCTAssertTrue(items.contains(URLQueryItem(name: "q", value: "board games")))
XCTAssertEqual(capturedRequest?.url?.path, "/api/campaigns/search")
}

// MARK: - fetchCategories

func testFetchCategoriesDecodesResponse() async throws {
MockURLProtocol.requestHandler = { request in
self.makeOKResponse(request: request, body: """
[{"id":"1","name":"Art","parent_id":null}]
""")
}

let categories = try await client.fetchCategories()
XCTAssertEqual(categories.count, 1)
XCTAssertEqual(categories.first?.name, "Art")
}

// MARK: - deleteAlert

func testDeleteAlertSendsDELETE() async throws {
var capturedRequest: URLRequest?
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data())
}

try await client.deleteAlert(id: "alert-99")

XCTAssertEqual(capturedRequest?.httpMethod, "DELETE")
XCTAssertTrue(capturedRequest?.url?.path.hasSuffix("alert-99") ?? false)
}

func testDeleteAlertThrowsOnError() async {
MockURLProtocol.requestHandler = { request in
(HTTPURLResponse(url: request.url!, statusCode: 403, httpVersion: nil, headerFields: nil)!, Data())
}

do {
try await client.deleteAlert(id: "alert-1")
XCTFail("Expected throw")
} catch {
XCTAssertNotNil(error)
}
}

// MARK: - createAlert

func testCreateAlertSendsPOST() async throws {
var capturedRequest: URLRequest?
let alertJSON = """
{"id":"a1","device_id":"d1","alert_type":"keyword","keyword":"test",
"category_id":null,"min_percent":0,"velocity_thresh":null,
"is_enabled":true,"created_at":"2024-01-01T00:00:00Z","last_matched_at":null}
"""
MockURLProtocol.requestHandler = { request in
capturedRequest = request
return self.makeOKResponse(request: request, body: alertJSON)
}

let req = CreateAlertRequest(device_id: "d1", alert_type: "keyword", keyword: "test", category_id: nil, min_percent: nil, velocity_thresh: nil)
_ = try await client.createAlert(req)

XCTAssertEqual(capturedRequest?.httpMethod, "POST")
XCTAssertEqual(capturedRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json")
}

// MARK: - Helpers

private func makeOKResponse(request: URLRequest, body: String) -> (HTTPURLResponse, Data) {
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body.data(using: .utf8)!)
}

private func queryItems(from request: URLRequest?) -> [URLQueryItem] {
guard let url = request?.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return [] }
return components.queryItems ?? []
}
}
Loading