From 4d9181e9d8a4d2f72bb3100e45779ecf65ea6306 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Fri, 27 Feb 2026 21:50:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test:=20add=20full=20iOS=20unit=20test=20su?= =?UTF-8?q?ite=20=E2=80=94=2091=20tests=20across=20ViewModels,=20APIClient?= =?UTF-8?q?,=20models,=20Keychain,=20ImageCache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Services/APIClient.swift | 6 +- .../Sources/Services/APIClientProtocol.swift | 13 ++ .../Sources/Services/ImageCache.swift | 7 +- .../Sources/ViewModels/AlertsViewModel.swift | 14 +- .../ViewModels/DiscoverViewModel.swift | 12 +- ios/KickWatch/Tests/APIClientTests.swift | 203 ++++++++++++++++ .../Tests/AlertsViewModelTests.swift | 191 +++++++++++++++ ios/KickWatch/Tests/DTOTests.swift | 196 ++++++++++++++++ .../Tests/DiscoverViewModelTests.swift | 219 ++++++++++++++++++ ios/KickWatch/Tests/ImageCacheTests.swift | 70 ++++++ ios/KickWatch/Tests/KeychainHelperTests.swift | 52 +++++ ios/KickWatch/Tests/MockAPIClient.swift | 115 +++++++++ ios/KickWatch/Tests/MockURLProtocol.swift | 33 +++ ios/KickWatch/Tests/ModelTests.swift | 178 ++++++++++++++ ios/project.yml | 12 + 15 files changed, 1310 insertions(+), 11 deletions(-) create mode 100644 ios/KickWatch/Sources/Services/APIClientProtocol.swift create mode 100644 ios/KickWatch/Tests/APIClientTests.swift create mode 100644 ios/KickWatch/Tests/AlertsViewModelTests.swift create mode 100644 ios/KickWatch/Tests/DTOTests.swift create mode 100644 ios/KickWatch/Tests/DiscoverViewModelTests.swift create mode 100644 ios/KickWatch/Tests/ImageCacheTests.swift create mode 100644 ios/KickWatch/Tests/KeychainHelperTests.swift create mode 100644 ios/KickWatch/Tests/MockAPIClient.swift create mode 100644 ios/KickWatch/Tests/MockURLProtocol.swift create mode 100644 ios/KickWatch/Tests/ModelTests.swift diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift index 8ee9b46..5d241af 100644 --- a/ios/KickWatch/Sources/Services/APIClient.swift +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -89,13 +89,13 @@ 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 @@ -103,7 +103,7 @@ actor APIClient { #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 { diff --git a/ios/KickWatch/Sources/Services/APIClientProtocol.swift b/ios/KickWatch/Sources/Services/APIClientProtocol.swift new file mode 100644 index 0000000..0ac6a77 --- /dev/null +++ b/ios/KickWatch/Sources/Services/APIClientProtocol.swift @@ -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] +} diff --git a/ios/KickWatch/Sources/Services/ImageCache.swift b/ios/KickWatch/Sources/Services/ImageCache.swift index 7bbe6cb..1847c3b 100644 --- a/ios/KickWatch/Sources/Services/ImageCache.swift +++ b/ios/KickWatch/Sources/Services/ImageCache.swift @@ -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 diff --git a/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift index 9745824..f52eb91 100644 --- a/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift +++ b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift @@ -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 } @@ -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 @@ -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 } @@ -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 diff --git a/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift index 1f97de9..d88e909 100644 --- a/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift +++ b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift @@ -3,6 +3,8 @@ import SwiftData @Observable final class DiscoverViewModel { + private let client: any APIClientProtocol + var campaigns: [CampaignDTO] = [] var categories: [CategoryDTO] = [] var isLoading = false @@ -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 @@ -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) @@ -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)") } diff --git a/ios/KickWatch/Tests/APIClientTests.swift b/ios/KickWatch/Tests/APIClientTests.swift new file mode 100644 index 0000000..6e3031f --- /dev/null +++ b/ios/KickWatch/Tests/APIClientTests.swift @@ -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 ?? [] + } +} diff --git a/ios/KickWatch/Tests/AlertsViewModelTests.swift b/ios/KickWatch/Tests/AlertsViewModelTests.swift new file mode 100644 index 0000000..9596feb --- /dev/null +++ b/ios/KickWatch/Tests/AlertsViewModelTests.swift @@ -0,0 +1,191 @@ +import XCTest +@testable import KickWatch + +final class AlertsViewModelTests: XCTestCase { + private var mock: MockAPIClient! + private var vm: AlertsViewModel! + + override func setUp() { + super.setUp() + mock = MockAPIClient() + vm = AlertsViewModel(client: mock) + } + + // MARK: - load() + + func testLoadSetsAlerts() async { + mock.alertsResponse = [ + MockAPIClient.makeAlertDTO(id: "a1"), + MockAPIClient.makeAlertDTO(id: "a2") + ] + + await vm.load(deviceID: "device-1") + + XCTAssertEqual(vm.alerts.count, 2) + XCTAssertFalse(vm.isLoading) + XCTAssertNil(vm.error) + } + + func testLoadSetsErrorOnFailure() async { + mock.shouldThrow = APIError.serverError(statusCode: 500) + + await vm.load(deviceID: "device-1") + + XCTAssertNotNil(vm.error) + XCTAssertFalse(vm.isLoading) + XCTAssertTrue(vm.alerts.isEmpty) + } + + func testLoadClearsErrorBeforeRequest() async { + vm.error = "stale" + mock.alertsResponse = [] + + await vm.load(deviceID: "device-1") + + XCTAssertNil(vm.error) + } + + // MARK: - createAlert() + + func testCreateAlertInsertsAtIndex0() async { + let existing = MockAPIClient.makeAlertDTO(id: "existing") + vm.alerts = [existing] + mock.createAlertResult = MockAPIClient.makeAlertDTO(id: "new") + + await vm.createAlert(deviceID: "d1", keyword: "robots") + + XCTAssertEqual(vm.alerts.count, 2) + XCTAssertEqual(vm.alerts[0].id, "new") + XCTAssertEqual(vm.alerts[1].id, "existing") + } + + func testCreateAlertEmptyKeywordBecomesNil() async { + mock.createAlertResult = MockAPIClient.makeAlertDTO() + + await vm.createAlert(deviceID: "d1", keyword: "") + + XCTAssertNil(mock.createAlertRequests.last?.keyword) + } + + func testCreateAlertNonEmptyKeywordIsPreserved() async { + mock.createAlertResult = MockAPIClient.makeAlertDTO() + + await vm.createAlert(deviceID: "d1", keyword: "board games") + + XCTAssertEqual(mock.createAlertRequests.last?.keyword, "board games") + } + + func testCreateAlertZeroMinPercentBecomesNil() async { + mock.createAlertResult = MockAPIClient.makeAlertDTO() + + await vm.createAlert(deviceID: "d1", minPercent: 0) + + XCTAssertNil(mock.createAlertRequests.last?.min_percent) + } + + func testCreateAlertPositiveMinPercentIsPreserved() async { + mock.createAlertResult = MockAPIClient.makeAlertDTO() + + await vm.createAlert(deviceID: "d1", minPercent: 50) + + XCTAssertEqual(mock.createAlertRequests.last?.min_percent, 50) + } + + func testCreateAlertZeroVelocityThreshBecomesNil() async { + mock.createAlertResult = MockAPIClient.makeAlertDTO() + + await vm.createAlert(deviceID: "d1", velocityThresh: 0) + + XCTAssertNil(mock.createAlertRequests.last?.velocity_thresh) + } + + func testCreateAlertSetsErrorOnFailure() async { + mock.shouldThrow = APIError.serverError(statusCode: 422) + + await vm.createAlert(deviceID: "d1", keyword: "test") + + XCTAssertNotNil(vm.error) + XCTAssertTrue(vm.alerts.isEmpty) + } + + // MARK: - toggleAlert() + + func testToggleAlertFlipsIsEnabled() async { + let alert = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: true) + vm.alerts = [alert] + mock.updateAlertResult = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: false) + + await vm.toggleAlert(alert) + + XCTAssertEqual(vm.alerts.first?.is_enabled, false) + } + + func testToggleAlertSendsCorrectIDAndInvertedEnabled() async { + let alert = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: false) + vm.alerts = [alert] + mock.updateAlertResult = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: true) + + await vm.toggleAlert(alert) + + let updateReq = mock.updateAlertRequests.last + XCTAssertEqual(updateReq?.id, "a1") + XCTAssertEqual(updateReq?.req.is_enabled, true) + } + + func testToggleAlertUpdatesInPlaceByID() async { + let a1 = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: true) + let a2 = MockAPIClient.makeAlertDTO(id: "a2", isEnabled: true) + vm.alerts = [a1, a2] + mock.updateAlertResult = MockAPIClient.makeAlertDTO(id: "a1", isEnabled: false) + + await vm.toggleAlert(a1) + + XCTAssertEqual(vm.alerts.count, 2) + XCTAssertEqual(vm.alerts[0].is_enabled, false) + XCTAssertEqual(vm.alerts[1].is_enabled, true) + } + + func testToggleAlertSetsErrorOnFailure() async { + let alert = MockAPIClient.makeAlertDTO(id: "a1") + vm.alerts = [alert] + mock.shouldThrow = APIError.serverError(statusCode: 500) + + await vm.toggleAlert(alert) + + XCTAssertNotNil(vm.error) + XCTAssertEqual(vm.alerts.first?.is_enabled, alert.is_enabled) + } + + // MARK: - deleteAlert() + + func testDeleteAlertRemovesFromList() async { + let a1 = MockAPIClient.makeAlertDTO(id: "a1") + let a2 = MockAPIClient.makeAlertDTO(id: "a2") + vm.alerts = [a1, a2] + + await vm.deleteAlert(a1) + + XCTAssertEqual(vm.alerts.count, 1) + XCTAssertEqual(vm.alerts.first?.id, "a2") + } + + func testDeleteAlertCallsAPIWithCorrectID() async { + let alert = MockAPIClient.makeAlertDTO(id: "del-99") + vm.alerts = [alert] + + await vm.deleteAlert(alert) + + XCTAssertEqual(mock.deleteAlertIDs.last, "del-99") + } + + func testDeleteAlertSetsErrorOnFailure() async { + let alert = MockAPIClient.makeAlertDTO(id: "a1") + vm.alerts = [alert] + mock.shouldThrow = APIError.serverError(statusCode: 500) + + await vm.deleteAlert(alert) + + XCTAssertNotNil(vm.error) + XCTAssertEqual(vm.alerts.count, 1) + } +} diff --git a/ios/KickWatch/Tests/DTOTests.swift b/ios/KickWatch/Tests/DTOTests.swift new file mode 100644 index 0000000..4e9ec5f --- /dev/null +++ b/ios/KickWatch/Tests/DTOTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import KickWatch + +final class DTOTests: XCTestCase { + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + // MARK: - CampaignDTO + + func testCampaignDTODecodeFullFields() throws { + let json = """ + { + "pid": "123", "name": "Cool Project", "blurb": "A blurb", + "photo_url": "https://example.com/img.jpg", + "goal_amount": 5000.0, "goal_currency": "USD", + "pledged_amount": 2500.0, "deadline": "2024-12-31T00:00:00Z", + "state": "live", "category_name": "Technology", "category_id": "16", + "project_url": "https://kickstarter.com/projects/test", + "creator_name": "Alice", "percent_funded": 50.0, + "slug": "test-project", "velocity_24h": 100.0, + "pledge_delta_24h": 50.0, "first_seen_at": "2024-01-01T00:00:00Z" + } + """.data(using: .utf8)! + + let dto = try decoder.decode(CampaignDTO.self, from: json) + XCTAssertEqual(dto.pid, "123") + XCTAssertEqual(dto.name, "Cool Project") + XCTAssertEqual(dto.blurb, "A blurb") + XCTAssertEqual(dto.goal_amount, 5000.0) + XCTAssertEqual(dto.goal_currency, "USD") + XCTAssertEqual(dto.pledged_amount, 2500.0) + XCTAssertEqual(dto.state, "live") + XCTAssertEqual(dto.category_name, "Technology") + XCTAssertEqual(dto.percent_funded, 50.0) + XCTAssertEqual(dto.slug, "test-project") + XCTAssertEqual(dto.velocity_24h, 100.0) + XCTAssertEqual(dto.pledge_delta_24h, 50.0) + } + + func testCampaignDTODecodeMinimalFields() throws { + let json = #"{"pid": "42", "name": "Minimal"}"#.data(using: .utf8)! + + let dto = try decoder.decode(CampaignDTO.self, from: json) + XCTAssertEqual(dto.pid, "42") + XCTAssertEqual(dto.name, "Minimal") + XCTAssertNil(dto.blurb) + XCTAssertNil(dto.photo_url) + XCTAssertNil(dto.deadline) + XCTAssertNil(dto.velocity_24h) + } + + // MARK: - CategoryDTO + + func testCategoryDTODecodeNoParent() throws { + let json = #"{"id": "16", "name": "Technology", "parent_id": null}"#.data(using: .utf8)! + + let dto = try decoder.decode(CategoryDTO.self, from: json) + XCTAssertEqual(dto.id, "16") + XCTAssertEqual(dto.name, "Technology") + XCTAssertNil(dto.parent_id) + } + + func testCategoryDTODecodeWithParent() throws { + let json = #"{"id": "44", "name": "Apps", "parent_id": "16"}"#.data(using: .utf8)! + + let dto = try decoder.decode(CategoryDTO.self, from: json) + XCTAssertEqual(dto.parent_id, "16") + } + + // MARK: - CampaignListResponse + + func testCampaignListResponseWithCursor() throws { + let json = """ + {"campaigns": [{"pid": "1", "name": "P1"}], "next_cursor": "abc123", "total": 42} + """.data(using: .utf8)! + + let resp = try decoder.decode(CampaignListResponse.self, from: json) + XCTAssertEqual(resp.campaigns.count, 1) + XCTAssertEqual(resp.next_cursor, "abc123") + XCTAssertEqual(resp.total, 42) + } + + func testCampaignListResponseNoCursor() throws { + let json = #"{"campaigns": [], "next_cursor": null, "total": 0}"#.data(using: .utf8)! + + let resp = try decoder.decode(CampaignListResponse.self, from: json) + XCTAssertNil(resp.next_cursor) + XCTAssertEqual(resp.total, 0) + } + + // MARK: - SearchResponse + + func testSearchResponseDecode() throws { + let json = #"{"campaigns": [], "next_cursor": null}"#.data(using: .utf8)! + + let resp = try decoder.decode(SearchResponse.self, from: json) + XCTAssertTrue(resp.campaigns.isEmpty) + XCTAssertNil(resp.next_cursor) + } + + func testSearchResponseWithResults() throws { + let json = """ + {"campaigns": [{"pid": "x", "name": "X"}], "next_cursor": "next"} + """.data(using: .utf8)! + + let resp = try decoder.decode(SearchResponse.self, from: json) + XCTAssertEqual(resp.campaigns.count, 1) + XCTAssertEqual(resp.next_cursor, "next") + } + + // MARK: - AlertDTO + + func testAlertDTODecode() throws { + let json = """ + { + "id": "alert-1", "device_id": "device-abc", "alert_type": "keyword", + "keyword": "gaming", "category_id": null, "min_percent": 0.0, + "velocity_thresh": null, "is_enabled": true, + "created_at": "2024-01-01T00:00:00Z", "last_matched_at": null + } + """.data(using: .utf8)! + + let dto = try decoder.decode(AlertDTO.self, from: json) + XCTAssertEqual(dto.id, "alert-1") + XCTAssertEqual(dto.device_id, "device-abc") + XCTAssertEqual(dto.keyword, "gaming") + XCTAssertTrue(dto.is_enabled) + XCTAssertNil(dto.last_matched_at) + XCTAssertNil(dto.velocity_thresh) + } + + func testAlertDTODecodeWithOptionalsFilled() throws { + let json = """ + { + "id": "a2", "device_id": "d1", "alert_type": "category", + "keyword": "", "category_id": "16", "min_percent": 50.0, + "velocity_thresh": 100.0, "is_enabled": false, + "created_at": "2024-06-01T00:00:00Z", "last_matched_at": "2024-06-15T00:00:00Z" + } + """.data(using: .utf8)! + + let dto = try decoder.decode(AlertDTO.self, from: json) + XCTAssertEqual(dto.category_id, "16") + XCTAssertEqual(dto.min_percent, 50.0) + XCTAssertEqual(dto.velocity_thresh, 100.0) + XCTAssertFalse(dto.is_enabled) + XCTAssertNotNil(dto.last_matched_at) + } + + // MARK: - Encoding requests + + func testCreateAlertRequestEncoding() throws { + let req = CreateAlertRequest( + device_id: "d1", alert_type: "keyword", + keyword: "board games", category_id: nil, + min_percent: 25.0, velocity_thresh: nil + ) + let data = try encoder.encode(req) + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(obj["device_id"] as? String, "d1") + XCTAssertEqual(obj["keyword"] as? String, "board games") + XCTAssertEqual(obj["min_percent"] as? Double, 25.0) + } + + func testUpdateAlertRequestEncoding() throws { + let req = UpdateAlertRequest(is_enabled: false, keyword: nil, category_id: nil, min_percent: nil) + let data = try encoder.encode(req) + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(obj["is_enabled"] as? Bool, false) + } + + func testRegisterDeviceRequestEncoding() throws { + let req = RegisterDeviceRequest(device_token: "tok123") + let data = try encoder.encode(req) + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(obj["device_token"] as? String, "tok123") + } + + // MARK: - APIError + + func testAPIErrorInvalidURLDescription() { + XCTAssertEqual(APIError.invalidURL.errorDescription, "Invalid URL") + } + + func testAPIErrorInvalidResponseDescription() { + XCTAssertEqual(APIError.invalidResponse.errorDescription, "Invalid server response") + } + + func testAPIErrorServerError404Description() { + XCTAssertEqual(APIError.serverError(statusCode: 404).errorDescription, "Server error: 404") + } + + func testAPIErrorServerError500Description() { + XCTAssertEqual(APIError.serverError(statusCode: 500).errorDescription, "Server error: 500") + } +} diff --git a/ios/KickWatch/Tests/DiscoverViewModelTests.swift b/ios/KickWatch/Tests/DiscoverViewModelTests.swift new file mode 100644 index 0000000..350ed89 --- /dev/null +++ b/ios/KickWatch/Tests/DiscoverViewModelTests.swift @@ -0,0 +1,219 @@ +import XCTest +@testable import KickWatch + +final class DiscoverViewModelTests: XCTestCase { + private var mock: MockAPIClient! + private var vm: DiscoverViewModel! + + override func setUp() { + super.setUp() + mock = MockAPIClient() + vm = DiscoverViewModel(client: mock) + } + + // MARK: - load() + + func testLoadSetsCampaigns() async { + mock.campaignListResponse = CampaignListResponse( + campaigns: [MockAPIClient.makeCampaignDTO(pid: "p1")], + next_cursor: nil, total: 1 + ) + + await vm.load() + + XCTAssertEqual(vm.campaigns.count, 1) + XCTAssertEqual(vm.campaigns.first?.pid, "p1") + } + + func testLoadClearsIsLoadingOnSuccess() async { + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.load() + + XCTAssertFalse(vm.isLoading) + XCTAssertNil(vm.error) + } + + func testLoadSetsHasMoreWhenCursorPresent() async { + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: "c1", total: 0) + + await vm.load() + + XCTAssertTrue(vm.hasMore) + XCTAssertEqual(vm.nextCursor, "c1") + } + + func testLoadSetsHasMoreFalseWhenNoCursor() async { + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.load() + + XCTAssertFalse(vm.hasMore) + XCTAssertNil(vm.nextCursor) + } + + func testLoadSetsErrorOnFailure() async { + mock.shouldThrow = APIError.serverError(statusCode: 503) + + await vm.load() + + XCTAssertNotNil(vm.error) + XCTAssertFalse(vm.isLoading) + XCTAssertTrue(vm.campaigns.isEmpty) + } + + func testLoadClearsErrorBeforeRequest() async { + vm.error = "stale error" + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.load() + + XCTAssertNil(vm.error) + } + + func testLoadPassesSelectedSortAndCategory() async { + vm.selectedSort = "magic" + vm.selectedCategoryID = "16" + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.load() + + let call = mock.fetchCampaignsCalls.last + XCTAssertEqual(call?.sort, "magic") + XCTAssertEqual(call?.categoryID, "16") + XCTAssertNil(call?.cursor) + } + + // MARK: - loadMore() + + func testLoadMoreDoesNothingWhenHasMoreFalse() async { + vm.hasMore = false + + await vm.loadMore() + + XCTAssertTrue(mock.fetchCampaignsCalls.isEmpty) + } + + func testLoadMoreDoesNothingWhenNoNextCursor() async { + vm.hasMore = true + vm.nextCursor = nil + + await vm.loadMore() + + XCTAssertTrue(mock.fetchCampaignsCalls.isEmpty) + } + + func testLoadMoreAppendsCampaigns() async { + // First page + let p1 = MockAPIClient.makeCampaignDTO(pid: "p1") + mock.campaignListResponse = CampaignListResponse(campaigns: [p1], next_cursor: "cur2", total: 2) + await vm.load() + + // Second page + let p2 = MockAPIClient.makeCampaignDTO(pid: "p2") + mock.campaignListResponse = CampaignListResponse(campaigns: [p2], next_cursor: nil, total: 2) + await vm.loadMore() + + XCTAssertEqual(vm.campaigns.count, 2) + XCTAssertEqual(vm.campaigns[0].pid, "p1") + XCTAssertEqual(vm.campaigns[1].pid, "p2") + XCTAssertFalse(vm.hasMore) + } + + func testLoadMorePassesCursorToAPI() async { + mock.campaignListResponse = CampaignListResponse( + campaigns: [MockAPIClient.makeCampaignDTO()], next_cursor: "cursor-xyz", total: 10 + ) + await vm.load() + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 10) + + await vm.loadMore() + + XCTAssertEqual(mock.fetchCampaignsCalls.last?.cursor, "cursor-xyz") + } + + func testLoadMoreSetsErrorOnFailure() async { + mock.campaignListResponse = CampaignListResponse( + campaigns: [MockAPIClient.makeCampaignDTO()], next_cursor: "c", total: 2 + ) + await vm.load() + + mock.shouldThrow = APIError.serverError(statusCode: 500) + await vm.loadMore() + + XCTAssertNotNil(vm.error) + XCTAssertFalse(vm.isLoadingMore) + } + + // MARK: - loadCategories() + + func testLoadCategoriesFetchesAndStores() async { + mock.categoriesResponse = [CategoryDTO(id: "1", name: "Art", parent_id: nil)] + + await vm.loadCategories() + + XCTAssertEqual(vm.categories.count, 1) + XCTAssertEqual(vm.categories.first?.name, "Art") + } + + func testLoadCategoriesSkipsIfAlreadyLoaded() async { + vm.categories = [CategoryDTO(id: "1", name: "Art", parent_id: nil)] + let initialCallCount = mock.fetchCampaignsCalls.count + + await vm.loadCategories() + + XCTAssertEqual(mock.fetchCampaignsCalls.count, initialCallCount) + XCTAssertEqual(vm.categories.count, 1) + } + + // MARK: - selectSort() + + func testSelectSortUpdatesSelectedSort() async { + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.selectSort("newest") + + XCTAssertEqual(vm.selectedSort, "newest") + } + + func testSelectSortResetsCursorAndReloads() async { + vm.nextCursor = "old-cursor" + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.selectSort("magic") + + let call = mock.fetchCampaignsCalls.last + XCTAssertNil(call?.cursor) + XCTAssertEqual(call?.sort, "magic") + } + + // MARK: - selectCategory() + + func testSelectCategoryUpdatesAndReloads() async { + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.selectCategory("16") + + XCTAssertEqual(vm.selectedCategoryID, "16") + XCTAssertEqual(mock.fetchCampaignsCalls.last?.categoryID, "16") + } + + func testSelectCategoryNilClearsCategory() async { + vm.selectedCategoryID = "16" + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.selectCategory(nil) + + XCTAssertNil(vm.selectedCategoryID) + XCTAssertNil(mock.fetchCampaignsCalls.last?.categoryID) + } + + func testSelectCategoryResetsCursor() async { + vm.nextCursor = "stale" + mock.campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: 0) + + await vm.selectCategory("5") + + XCTAssertNil(mock.fetchCampaignsCalls.last?.cursor) + } +} diff --git a/ios/KickWatch/Tests/ImageCacheTests.swift b/ios/KickWatch/Tests/ImageCacheTests.swift new file mode 100644 index 0000000..56cae3e --- /dev/null +++ b/ios/KickWatch/Tests/ImageCacheTests.swift @@ -0,0 +1,70 @@ +import XCTest +import SwiftUI +@testable import KickWatch + +final class ImageCacheTests: XCTestCase { + + func testImageForInvalidURLReturnsNil() async { + let cache = ImageCache(session: .mock()) + MockURLProtocol.requestHandler = { _ in throw URLError(.cannotConnectToHost) } + let url = URL(string: "https://test.example.com/fail.png")! + + let result = await cache.image(for: url) + + XCTAssertNil(result) + } + + func testImageForNonImageDataReturnsNil() async { + let cache = ImageCache(session: .mock()) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data("not an image".utf8)) + } + let url = URL(string: "https://test.example.com/text.png")! + + let result = await cache.image(for: url) + + XCTAssertNil(result) + } + + func testImageIsReturnedForValidPNG() async { + let cache = ImageCache(session: .mock()) + let pngData = makeSinglePixelPNG() + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, pngData) + } + let url = URL(string: "https://test.example.com/image.png")! + + let result = await cache.image(for: url) + + XCTAssertNotNil(result) + } + + func testCacheReturnsSameImageOnSecondCall() async { + let cache = ImageCache(session: .mock()) + let pngData = makeSinglePixelPNG() + var callCount = 0 + MockURLProtocol.requestHandler = { request in + callCount += 1 + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, pngData) + } + let url = URL(string: "https://test.example.com/cached.png")! + + _ = await cache.image(for: url) + _ = await cache.image(for: url) + + XCTAssertEqual(callCount, 1, "Second call should use cache, not make a network request") + } + + // Minimal 1×1 red PNG + private func makeSinglePixelPNG() -> Data { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) + let image = renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + return image.pngData()! + } +} diff --git a/ios/KickWatch/Tests/KeychainHelperTests.swift b/ios/KickWatch/Tests/KeychainHelperTests.swift new file mode 100644 index 0000000..eaef726 --- /dev/null +++ b/ios/KickWatch/Tests/KeychainHelperTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import KickWatch + +final class KeychainHelperTests: XCTestCase { + private let key = "com.test.kickwatch.keychain.unit" + + override func tearDown() { + KeychainHelper.delete(for: key) + super.tearDown() + } + + func testSaveAndLoad() { + KeychainHelper.save("hello", for: key) + XCTAssertEqual(KeychainHelper.load(for: key), "hello") + } + + func testSaveOverwritesExistingValue() { + KeychainHelper.save("first", for: key) + KeychainHelper.save("second", for: key) + XCTAssertEqual(KeychainHelper.load(for: key), "second") + } + + func testDeleteRemovesValue() { + KeychainHelper.save("value", for: key) + KeychainHelper.delete(for: key) + XCTAssertNil(KeychainHelper.load(for: key)) + } + + func testLoadMissingKeyReturnsNil() { + XCTAssertNil(KeychainHelper.load(for: key + ".nonexistent")) + } + + func testDeleteNonexistentKeyDoesNotCrash() { + KeychainHelper.delete(for: key + ".nonexistent") + } + + func testSaveEmptyString() { + KeychainHelper.save("", for: key) + XCTAssertEqual(KeychainHelper.load(for: key), "") + } + + func testSaveUnicodeValue() { + KeychainHelper.save("日本語🎮", for: key) + XCTAssertEqual(KeychainHelper.load(for: key), "日本語🎮") + } + + func testSaveLongValue() { + let longValue = String(repeating: "a", count: 4096) + KeychainHelper.save(longValue, for: key) + XCTAssertEqual(KeychainHelper.load(for: key), longValue) + } +} diff --git a/ios/KickWatch/Tests/MockAPIClient.swift b/ios/KickWatch/Tests/MockAPIClient.swift new file mode 100644 index 0000000..48a7a6e --- /dev/null +++ b/ios/KickWatch/Tests/MockAPIClient.swift @@ -0,0 +1,115 @@ +import Foundation +@testable import KickWatch + +final class MockAPIClient: APIClientProtocol, @unchecked Sendable { + // Stubbed responses + var campaignListResponse = CampaignListResponse(campaigns: [], next_cursor: nil, total: nil) + var searchResponse = SearchResponse(campaigns: [], next_cursor: nil) + var categoriesResponse: [CategoryDTO] = [] + var alertsResponse: [AlertDTO] = [] + var createAlertResult: AlertDTO = MockAPIClient.makeAlertDTO() + var updateAlertResult: AlertDTO = MockAPIClient.makeAlertDTO() + var alertMatchesResult: [CampaignDTO] = [] + var registerDeviceResult = RegisterDeviceResponse(device_id: "mock-device-id") + + // Error stub + var shouldThrow: Error? + + // Call tracking + var fetchCampaignsCalls: [(sort: String, categoryID: String?, cursor: String?)] = [] + var searchCalls: [(query: String, categoryID: String?, cursor: String?)] = [] + var deleteAlertIDs: [String] = [] + var createAlertRequests: [CreateAlertRequest] = [] + var updateAlertRequests: [(id: String, req: UpdateAlertRequest)] = [] + var fetchAlertsCalled = false + + func fetchCampaigns(sort: String, categoryID: String?, cursor: String?) async throws -> CampaignListResponse { + fetchCampaignsCalls.append((sort, categoryID, cursor)) + if let e = shouldThrow { throw e } + return campaignListResponse + } + + func searchCampaigns(query: String, categoryID: String?, cursor: String?) async throws -> SearchResponse { + searchCalls.append((query, categoryID, cursor)) + if let e = shouldThrow { throw e } + return searchResponse + } + + func fetchCategories() async throws -> [CategoryDTO] { + if let e = shouldThrow { throw e } + return categoriesResponse + } + + func registerDevice(token: String) async throws -> RegisterDeviceResponse { + if let e = shouldThrow { throw e } + return registerDeviceResult + } + + func fetchAlerts(deviceID: String) async throws -> [AlertDTO] { + fetchAlertsCalled = true + if let e = shouldThrow { throw e } + return alertsResponse + } + + func createAlert(_ req: CreateAlertRequest) async throws -> AlertDTO { + createAlertRequests.append(req) + if let e = shouldThrow { throw e } + return createAlertResult + } + + func updateAlert(id: String, req: UpdateAlertRequest) async throws -> AlertDTO { + updateAlertRequests.append((id, req)) + if let e = shouldThrow { throw e } + return updateAlertResult + } + + func deleteAlert(id: String) async throws { + deleteAlertIDs.append(id) + if let e = shouldThrow { throw e } + } + + func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO] { + if let e = shouldThrow { throw e } + return alertMatchesResult + } + + // MARK: - Factories + + static func makeAlertDTO(id: String = "alert-1", isEnabled: Bool = true) -> AlertDTO { + AlertDTO( + id: id, + device_id: "device-1", + alert_type: "keyword", + keyword: "test", + category_id: nil, + min_percent: 0, + velocity_thresh: nil, + is_enabled: isEnabled, + created_at: "2024-01-01T00:00:00Z", + last_matched_at: nil + ) + } + + static func makeCampaignDTO(pid: String = "c1", name: String = "Test Campaign") -> CampaignDTO { + CampaignDTO( + pid: pid, + name: name, + blurb: nil, + photo_url: nil, + goal_amount: 1000, + goal_currency: "USD", + pledged_amount: 500, + deadline: nil, + state: "live", + category_name: nil, + category_id: nil, + project_url: nil, + creator_name: nil, + percent_funded: 50, + slug: nil, + velocity_24h: nil, + pledge_delta_24h: nil, + first_seen_at: nil + ) + } +} diff --git a/ios/KickWatch/Tests/MockURLProtocol.swift b/ios/KickWatch/Tests/MockURLProtocol.swift new file mode 100644 index 0000000..53f64e3 --- /dev/null +++ b/ios/KickWatch/Tests/MockURLProtocol.swift @@ -0,0 +1,33 @@ +import Foundation + +final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +extension URLSession { + static func mock() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } +} diff --git a/ios/KickWatch/Tests/ModelTests.swift b/ios/KickWatch/Tests/ModelTests.swift new file mode 100644 index 0000000..4394f71 --- /dev/null +++ b/ios/KickWatch/Tests/ModelTests.swift @@ -0,0 +1,178 @@ +import XCTest +import SwiftData +@testable import KickWatch + +final class CampaignModelTests: XCTestCase { + private var container: ModelContainer! + private var context: ModelContext! + + override func setUp() { + super.setUp() + container = try! ModelContainer( + for: Campaign.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + super.tearDown() + } + + private func make(pid: String = "1", state: String = "live", deadline: Date = .distantFuture) -> Campaign { + let c = Campaign(pid: pid, name: "Test", deadline: deadline, state: state) + context.insert(c) + return c + } + + // MARK: - stateLabel + + func testStateLabelSuccessful() { + XCTAssertEqual(make(state: "successful").stateLabel, "Funded") + } + + func testStateLabelFailed() { + XCTAssertEqual(make(state: "failed").stateLabel, "Failed") + } + + func testStateLabelCanceled() { + XCTAssertEqual(make(state: "canceled").stateLabel, "Canceled") + } + + func testStateLabelLive() { + XCTAssertEqual(make(state: "live").stateLabel, "Live") + } + + func testStateLabelUnknownDefaultsToLive() { + XCTAssertEqual(make(state: "anything").stateLabel, "Live") + } + + // MARK: - daysLeft + + func testDaysLeftFutureDate() { + let future = Calendar.current.date(byAdding: .day, value: 10, to: .now)! + let c = make(deadline: future) + XCTAssertGreaterThanOrEqual(c.daysLeft, 9) + XCTAssertLessThanOrEqual(c.daysLeft, 10) + } + + func testDaysLeftPastDateReturnsZero() { + let past = Calendar.current.date(byAdding: .day, value: -5, to: .now)! + XCTAssertEqual(make(deadline: past).daysLeft, 0) + } + + func testDaysLeftTodayReturnsZero() { + XCTAssertEqual(make(deadline: .now).daysLeft, 0) + } + + func testDaysLeftDistantFutureIsPositive() { + XCTAssertGreaterThan(make(deadline: .distantFuture).daysLeft, 0) + } + + // MARK: - Default init values + + func testDefaultValues() { + let c = Campaign(pid: "x", name: "Test") + context.insert(c) + XCTAssertEqual(c.blurb, "") + XCTAssertEqual(c.photoURL, "") + XCTAssertEqual(c.goalAmount, 0) + XCTAssertEqual(c.goalCurrency, "USD") + XCTAssertEqual(c.pledgedAmount, 0) + XCTAssertEqual(c.state, "live") + XCTAssertFalse(c.isWatched) + XCTAssertEqual(c.categoryID, "") + XCTAssertEqual(c.creatorName, "") + XCTAssertEqual(c.percentFunded, 0) + } +} + +final class WatchlistAlertModelTests: XCTestCase { + private var container: ModelContainer! + private var context: ModelContext! + + override func setUp() { + super.setUp() + container = try! ModelContainer( + for: WatchlistAlert.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + super.tearDown() + } + + func testDefaultValues() { + let alert = WatchlistAlert(keyword: "robots") + context.insert(alert) + XCTAssertFalse(alert.id.isEmpty) + XCTAssertEqual(alert.keyword, "robots") + XCTAssertNil(alert.categoryID) + XCTAssertEqual(alert.minPercentFunded, 0) + XCTAssertTrue(alert.isEnabled) + XCTAssertNil(alert.lastMatchedAt) + } + + func testCustomValues() { + let alert = WatchlistAlert( + id: "fixed-id", + keyword: "games", + categoryID: "16", + minPercentFunded: 50, + isEnabled: false + ) + context.insert(alert) + XCTAssertEqual(alert.id, "fixed-id") + XCTAssertEqual(alert.categoryID, "16") + XCTAssertEqual(alert.minPercentFunded, 50) + XCTAssertFalse(alert.isEnabled) + } +} + +final class RecentSearchModelTests: XCTestCase { + private var container: ModelContainer! + private var context: ModelContext! + + override func setUp() { + super.setUp() + container = try! ModelContainer( + for: RecentSearch.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + super.tearDown() + } + + func testInitSetsQuery() { + let rs = RecentSearch(query: "board games") + context.insert(rs) + XCTAssertEqual(rs.query, "board games") + } + + func testSearchedAtDefaultsToNow() { + let before = Date() + let rs = RecentSearch(query: "q") + context.insert(rs) + let after = Date() + XCTAssertGreaterThanOrEqual(rs.searchedAt, before) + XCTAssertLessThanOrEqual(rs.searchedAt, after) + } + + func testCustomSearchedAt() { + let date = Date(timeIntervalSince1970: 0) + let rs = RecentSearch(query: "q", searchedAt: date) + context.insert(rs) + XCTAssertEqual(rs.searchedAt, date) + } +} diff --git a/ios/project.yml b/ios/project.yml index 63a02cc..516f271 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,6 +8,18 @@ settings: base: SWIFT_VERSION: "5.9" targets: + KickWatchTests: + type: bundle.unit-test + platform: iOS + sources: + - path: KickWatch/Tests + dependencies: + - target: KickWatch + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch.tests + DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM) + GENERATE_INFOPLIST_FILE: YES KickWatch: type: application platform: iOS From 48631080f0fc970553918c33cfd826bbcb460bb4 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sat, 28 Feb 2026 11:56:38 +0800 Subject: [PATCH 2/2] fix(test): track fetchCategories calls in MockAPIClient testLoadCategoriesSkipsIfAlreadyLoaded was checking fetchCampaignsCalls.count instead of fetchCategories calls, so the test would pass even if the skip-refetch logic broke. - Add fetchCategoriesCalled flag to MockAPIClient - Update test to check fetchCategoriesCalled instead of fetchCampaignsCalls - Set flag in fetchCategories() implementation Co-Authored-By: Claude Sonnet 4.5 --- ios/KickWatch/Tests/DiscoverViewModelTests.swift | 4 ++-- ios/KickWatch/Tests/MockAPIClient.swift | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ios/KickWatch/Tests/DiscoverViewModelTests.swift b/ios/KickWatch/Tests/DiscoverViewModelTests.swift index 350ed89..8cf3d38 100644 --- a/ios/KickWatch/Tests/DiscoverViewModelTests.swift +++ b/ios/KickWatch/Tests/DiscoverViewModelTests.swift @@ -158,11 +158,11 @@ final class DiscoverViewModelTests: XCTestCase { func testLoadCategoriesSkipsIfAlreadyLoaded() async { vm.categories = [CategoryDTO(id: "1", name: "Art", parent_id: nil)] - let initialCallCount = mock.fetchCampaignsCalls.count + mock.fetchCategoriesCalled = false await vm.loadCategories() - XCTAssertEqual(mock.fetchCampaignsCalls.count, initialCallCount) + XCTAssertFalse(mock.fetchCategoriesCalled) XCTAssertEqual(vm.categories.count, 1) } diff --git a/ios/KickWatch/Tests/MockAPIClient.swift b/ios/KickWatch/Tests/MockAPIClient.swift index 48a7a6e..b51bbb5 100644 --- a/ios/KickWatch/Tests/MockAPIClient.swift +++ b/ios/KickWatch/Tests/MockAPIClient.swift @@ -18,6 +18,7 @@ final class MockAPIClient: APIClientProtocol, @unchecked Sendable { // Call tracking var fetchCampaignsCalls: [(sort: String, categoryID: String?, cursor: String?)] = [] var searchCalls: [(query: String, categoryID: String?, cursor: String?)] = [] + var fetchCategoriesCalled = false var deleteAlertIDs: [String] = [] var createAlertRequests: [CreateAlertRequest] = [] var updateAlertRequests: [(id: String, req: UpdateAlertRequest)] = [] @@ -36,6 +37,7 @@ final class MockAPIClient: APIClientProtocol, @unchecked Sendable { } func fetchCategories() async throws -> [CategoryDTO] { + fetchCategoriesCalled = true if let e = shouldThrow { throw e } return categoriesResponse }