// // DataLayerTests.swift // CaseraTests // // Unit tests for the DATA layer domain (DATA-001 through DATA-007). // Exercises Kotlin DataManager directly from Swift without launching the app. // // IMPORTANT: All suites that mutate DataManager (a shared singleton) are nested // inside a .serialized parent suite to prevent concurrent test interference. // import Testing import Foundation @testable import Casera import ComposeApp // MARK: - Serialized Parent Suite (prevents concurrent DataManager mutations) @Suite(.serialized) struct DataLayerTests { // MARK: - DATA-004: Cache Validation Tests @Suite struct CacheValidationTests { @Test func cacheTimeZeroIsInvalid() { #expect(DataManager.shared.isCacheValid(cacheTime: 0, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == false) } @Test func recentCacheTimeIsValid() { // 5 minutes ago should be valid (well within the 1-hour timeout) let fiveMinutesAgo = Int64(Date().timeIntervalSince1970 * 1000) - (5 * 60 * 1000) #expect(DataManager.shared.isCacheValid(cacheTime: fiveMinutesAgo, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == true) } @Test func expiredCacheTimeIsInvalid() { // 2 hours ago should be invalid (past the 1-hour timeout) let twoHoursAgo = Int64(Date().timeIntervalSince1970 * 1000) - (2 * 60 * 60 * 1000) #expect(DataManager.shared.isCacheValid(cacheTime: twoHoursAgo, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == false) } @Test func cacheTimeoutConstantIsOneHour() { #expect(DataManager.shared.CACHE_TIMEOUT_MS == 3_600_000) } @Test func allCacheTimestampsStartAtZeroAfterClear() { DataManager.shared.clear() #expect(DataManager.shared.residencesCacheTime == 0) #expect(DataManager.shared.myResidencesCacheTime == 0) #expect(DataManager.shared.tasksCacheTime == 0) #expect(DataManager.shared.contractorsCacheTime == 0) #expect(DataManager.shared.documentsCacheTime == 0) #expect(DataManager.shared.summaryCacheTime == 0) } } // MARK: - DATA-005: Clear & ClearUserData Tests @Suite struct ClearTests { @Test func clearResetsAllCacheTimestamps() { let cat = TaskCategory(id: 1, name: "Test", description: "", icon: "", color: "", displayOrder: 0) DataManager.shared.setTaskCategories(categories: [cat]) DataManager.shared.clear() #expect(DataManager.shared.residencesCacheTime == 0) #expect(DataManager.shared.myResidencesCacheTime == 0) #expect(DataManager.shared.tasksCacheTime == 0) #expect(DataManager.shared.contractorsCacheTime == 0) #expect(DataManager.shared.documentsCacheTime == 0) #expect(DataManager.shared.summaryCacheTime == 0) } @Test func clearEmptiesLookupLists() { // Populate all 5 lookup types DataManager.shared.setTaskCategories(categories: [ TaskCategory(id: 1, name: "Cat", description: "", icon: "", color: "", displayOrder: 0) ]) DataManager.shared.setTaskPriorities(priorities: [ TaskPriority(id: 1, name: "High", level: 1, color: "red", displayOrder: 0) ]) DataManager.shared.setTaskFrequencies(frequencies: [ TaskFrequency(id: 1, name: "Weekly", days: nil, displayOrder: 0) ]) DataManager.shared.setResidenceTypes(types: [ ResidenceType(id: 1, name: "House") ]) DataManager.shared.setContractorSpecialties(specialties: [ ContractorSpecialty(id: 1, name: "Plumbing", description: nil, icon: nil, displayOrder: 0) ]) DataManager.shared.clear() let categories = DataManager.shared.taskCategories.value as! [TaskCategory] let priorities = DataManager.shared.taskPriorities.value as! [TaskPriority] let frequencies = DataManager.shared.taskFrequencies.value as! [TaskFrequency] let residenceTypes = DataManager.shared.residenceTypes.value as! [ResidenceType] let specialties = DataManager.shared.contractorSpecialties.value as! [ContractorSpecialty] #expect(categories.isEmpty) #expect(priorities.isEmpty) #expect(frequencies.isEmpty) #expect(residenceTypes.isEmpty) #expect(specialties.isEmpty) } @Test func clearResetsLookupsInitializedFlag() { DataManager.shared.markLookupsInitialized() let before = DataManager.shared.lookupsInitialized.value as! Bool #expect(before == true) DataManager.shared.clear() let after = DataManager.shared.lookupsInitialized.value as! Bool #expect(after == false) } @Test func clearUserDataKeepsLookups() { DataManager.shared.clear() // Populate lookups DataManager.shared.setTaskCategories(categories: [ TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0) ]) DataManager.shared.markLookupsInitialized() // Clear user data only DataManager.shared.clearUserData() // Lookups should remain let categories = DataManager.shared.taskCategories.value as! [TaskCategory] #expect(categories.count == 1) #expect(categories.first?.name == "Plumbing") let initialized = DataManager.shared.lookupsInitialized.value as! Bool #expect(initialized == true) // Clean up DataManager.shared.clear() } @Test func clearUserDataResetsCacheTimestamps() { DataManager.shared.clear() DataManager.shared.setTaskCategories(categories: [ TaskCategory(id: 1, name: "Test", description: "", icon: "", color: "", displayOrder: 0) ]) DataManager.shared.clearUserData() #expect(DataManager.shared.residencesCacheTime == 0) #expect(DataManager.shared.myResidencesCacheTime == 0) #expect(DataManager.shared.tasksCacheTime == 0) #expect(DataManager.shared.contractorsCacheTime == 0) #expect(DataManager.shared.documentsCacheTime == 0) #expect(DataManager.shared.summaryCacheTime == 0) DataManager.shared.clear() } } // MARK: - DATA-001, DATA-007: Lookup Setter & O(1) Getter Tests @Suite struct LookupSetterTests { @Test func setTaskCategoriesPopulatesList() { DataManager.shared.clear() let categories = [ TaskCategory(id: 1, name: "Plumbing", description: "Water", icon: "wrench", color: "blue", displayOrder: 1), TaskCategory(id: 2, name: "HVAC", description: "Air", icon: "fan", color: "green", displayOrder: 2) ] DataManager.shared.setTaskCategories(categories: categories) let stored = DataManager.shared.taskCategories.value as! [TaskCategory] #expect(stored.count == 2) #expect(stored[0].name == "Plumbing") #expect(stored[1].name == "HVAC") DataManager.shared.clear() } @Test func setTaskCategoriesBuildsMappedLookup() { DataManager.shared.clear() let categories = [ TaskCategory(id: 10, name: "Electrical", description: "", icon: "", color: "", displayOrder: 0), TaskCategory(id: 20, name: "Landscaping", description: "", icon: "", color: "", displayOrder: 1) ] DataManager.shared.setTaskCategories(categories: categories) // Verify the O(1) map-based getter works let result10 = DataManager.shared.getTaskCategory(id: 10) let result20 = DataManager.shared.getTaskCategory(id: 20) #expect(result10?.name == "Electrical") #expect(result20?.name == "Landscaping") DataManager.shared.clear() } @Test func setTaskPrioritiesPopulatesListAndMap() { DataManager.shared.clear() let priorities = [ TaskPriority(id: 1, name: "High", level: 3, color: "red", displayOrder: 0), TaskPriority(id: 2, name: "Medium", level: 2, color: "yellow", displayOrder: 1), TaskPriority(id: 3, name: "Low", level: 1, color: "green", displayOrder: 2) ] DataManager.shared.setTaskPriorities(priorities: priorities) let stored = DataManager.shared.taskPriorities.value as! [TaskPriority] #expect(stored.count == 3) let high = DataManager.shared.getTaskPriority(id: 1) #expect(high?.name == "High") #expect(high?.level == 3) let low = DataManager.shared.getTaskPriority(id: 3) #expect(low?.name == "Low") DataManager.shared.clear() } @Test func setTaskFrequenciesPopulatesListAndMap() { DataManager.shared.clear() let frequencies = [ TaskFrequency(id: 1, name: "Daily", days: 1, displayOrder: 0), TaskFrequency(id: 2, name: "Weekly", days: 7, displayOrder: 1) ] DataManager.shared.setTaskFrequencies(frequencies: frequencies) let stored = DataManager.shared.taskFrequencies.value as! [TaskFrequency] #expect(stored.count == 2) let daily = DataManager.shared.getTaskFrequency(id: 1) #expect(daily?.name == "Daily") let weekly = DataManager.shared.getTaskFrequency(id: 2) #expect(weekly?.name == "Weekly") DataManager.shared.clear() } @Test func setResidenceTypesPopulatesListAndMap() { DataManager.shared.clear() let types = [ ResidenceType(id: 1, name: "Single Family"), ResidenceType(id: 2, name: "Condo"), ResidenceType(id: 3, name: "Townhouse") ] DataManager.shared.setResidenceTypes(types: types) let stored = DataManager.shared.residenceTypes.value as! [ResidenceType] #expect(stored.count == 3) let condo = DataManager.shared.getResidenceType(id: 2) #expect(condo?.name == "Condo") DataManager.shared.clear() } @Test func setContractorSpecialtiesPopulatesListAndMap() { DataManager.shared.clear() let specialties = [ ContractorSpecialty(id: 1, name: "Plumbing", description: "Pipes", icon: "wrench", displayOrder: 0), ContractorSpecialty(id: 2, name: "Electrical", description: "Wiring", icon: "bolt", displayOrder: 1) ] DataManager.shared.setContractorSpecialties(specialties: specialties) let stored = DataManager.shared.contractorSpecialties.value as! [ContractorSpecialty] #expect(stored.count == 2) let plumbing = DataManager.shared.getContractorSpecialty(id: 1) #expect(plumbing?.name == "Plumbing") DataManager.shared.clear() } @Test func markLookupsInitializedSetsFlag() { DataManager.shared.clear() let before = DataManager.shared.lookupsInitialized.value as! Bool #expect(before == false) DataManager.shared.markLookupsInitialized() let after = DataManager.shared.lookupsInitialized.value as! Bool #expect(after == true) DataManager.shared.clear() } @Test func getterReturnsNilForMissingId() { DataManager.shared.clear() DataManager.shared.setTaskCategories(categories: [ TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0) ]) let result = DataManager.shared.getTaskCategory(id: 9999) #expect(result == nil) DataManager.shared.clear() } @Test func getterReturnsNilForNilId() { DataManager.shared.clear() DataManager.shared.setTaskCategories(categories: [ TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0) ]) let result = DataManager.shared.getTaskCategory(id: nil) #expect(result == nil) DataManager.shared.clear() } } } // MARK: - DATA-002: ETag Tests (requires local backend) struct DataLayerETagTests { /// Base URL for direct HTTP calls to the static_data endpoint. /// Uses localhost for iOS simulator (127.0.0.1). private static let staticDataURL = "http://127.0.0.1:8000/api/static_data/" /// Synchronous HTTP GET with optional headers. Returns (statusCode, headers, data). private func syncRequest( url: String, headers: [String: String] = [:] ) -> (statusCode: Int, headers: [String: String], data: Data?) { let semaphore = DispatchSemaphore(value: 0) var statusCode = 0 var responseHeaders: [String: String] = [:] var responseData: Data? var request = URLRequest(url: URL(string: url)!) request.timeoutInterval = 10 for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } let task = URLSession.shared.dataTask(with: request) { data, response, _ in if let httpResponse = response as? HTTPURLResponse { statusCode = httpResponse.statusCode for (key, value) in httpResponse.allHeaderFields { responseHeaders["\(key)"] = "\(value)" } } responseData = data semaphore.signal() } task.resume() semaphore.wait() return (statusCode, responseHeaders, responseData) } /// Check if the local backend is reachable before running network tests. private func isBackendReachable() -> Bool { let semaphore = DispatchSemaphore(value: 0) var reachable = false var request = URLRequest(url: URL(string: Self.staticDataURL)!) request.timeoutInterval = 3 request.httpMethod = "HEAD" let task = URLSession.shared.dataTask(with: request) { _, response, _ in if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode > 0 { reachable = true } semaphore.signal() } task.resume() semaphore.wait() return reachable } @Test func staticDataEndpointReturnsETag() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test") let (statusCode, headers, _) = syncRequest(url: Self.staticDataURL) #expect(statusCode == 200) // ETag header should be present (case-insensitive check) let etag = headers["Etag"] ?? headers["ETag"] ?? headers["etag"] #expect(etag != nil, "Response should include an ETag header") #expect(etag?.isEmpty == false) } @Test func conditionalRequestReturns304WhenDataUnchanged() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test") // First request: get the ETag let (statusCode1, headers1, _) = syncRequest(url: Self.staticDataURL) #expect(statusCode1 == 200) let etag = headers1["Etag"] ?? headers1["ETag"] ?? headers1["etag"] try #require(etag != nil, "First response must include ETag") // Second request: send If-None-Match with valid ETag let (statusCode2, headers2, _) = syncRequest( url: Self.staticDataURL, headers: ["If-None-Match": etag!] ) // Server should return 304 (data unchanged) or 200 with a new ETag // (if data was modified between requests). Both are valid ETag behavior. let validResponses: Set = [200, 304] #expect(validResponses.contains(statusCode2), "Conditional request should return 304 (unchanged) or 200 (new data), got \(statusCode2)") if statusCode2 == 200 { // If server returned 200, it should include a new ETag let newEtag = headers2["Etag"] ?? headers2["ETag"] ?? headers2["etag"] #expect(newEtag != nil, "200 response should include a new ETag") } } @Test func staleETagReturns200() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test") // Send a bogus ETag — server should return 200 with fresh data let (statusCode, _, data) = syncRequest( url: Self.staticDataURL, headers: ["If-None-Match": "\"bogus-etag-value\""] ) #expect(statusCode == 200, "Stale/bogus ETag should result in 200 with fresh data") #expect(data != nil && (data?.count ?? 0) > 0) } } // MARK: - DATA-003, DATA-007: API Schema Validation Tests (requires local backend) struct DataLayerAPISchemaTests { private static let staticDataURL = "http://127.0.0.1:8000/api/static_data/" /// Synchronous HTTP GET returning decoded JSON dictionary. private func fetchStaticData() -> [String: Any]? { let semaphore = DispatchSemaphore(value: 0) var result: [String: Any]? var request = URLRequest(url: URL(string: Self.staticDataURL)!) request.timeoutInterval = 10 let task = URLSession.shared.dataTask(with: request) { data, response, _ in if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let data = data { result = try? JSONSerialization.jsonObject(with: data) as? [String: Any] } semaphore.signal() } task.resume() semaphore.wait() return result } private func isBackendReachable() -> Bool { let semaphore = DispatchSemaphore(value: 0) var reachable = false var request = URLRequest(url: URL(string: Self.staticDataURL)!) request.timeoutInterval = 3 request.httpMethod = "HEAD" let task = URLSession.shared.dataTask(with: request) { _, response, _ in if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode > 0 { reachable = true } semaphore.signal() } task.resume() semaphore.wait() return reachable } @Test func staticDataContainsAllRequiredLookupTypes() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test") let data = try #require(fetchStaticData(), "Failed to fetch static data") let requiredKeys = [ "residence_types", "task_frequencies", "task_priorities", "task_categories", "contractor_specialties" ] for key in requiredKeys { #expect(data[key] != nil, "static_data response missing required key: \(key)") let array = data[key] as? [[String: Any]] #expect(array != nil, "\(key) should be an array of objects") #expect((array?.count ?? 0) > 0, "\(key) should not be empty") } } @Test func allLookupItemsHaveIdAndName() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test") let data = try #require(fetchStaticData(), "Failed to fetch static data") let lookupKeys = [ "residence_types", "task_frequencies", "task_priorities", "task_categories", "contractor_specialties" ] for key in lookupKeys { guard let items = data[key] as? [[String: Any]] else { continue } for (index, item) in items.enumerated() { #expect(item["id"] != nil, "\(key)[\(index)] missing 'id'") #expect(item["name"] != nil, "\(key)[\(index)] missing 'name'") #expect(item["name"] as? String != "", "\(key)[\(index)] has empty 'name'") } } } @Test func allIdsAreUniquePerLookupType() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test") let data = try #require(fetchStaticData(), "Failed to fetch static data") let lookupKeys = [ "residence_types", "task_frequencies", "task_priorities", "task_categories", "contractor_specialties" ] for key in lookupKeys { guard let items = data[key] as? [[String: Any]] else { continue } let ids = items.compactMap { $0["id"] as? Int } let uniqueIds = Set(ids) #expect(ids.count == uniqueIds.count, "\(key) has duplicate IDs — would break associateBy map") } } @Test func taskCategoriesHaveColorAndIcon() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test") let data = try #require(fetchStaticData(), "Failed to fetch static data") let categories = try #require(data["task_categories"] as? [[String: Any]], "Missing task_categories") for (index, cat) in categories.enumerated() { #expect(cat["color"] != nil, "task_categories[\(index)] missing 'color'") #expect(cat["icon"] != nil, "task_categories[\(index)] missing 'icon'") } } @Test func taskPrioritiesHaveLevelAndColor() throws { try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test") let data = try #require(fetchStaticData(), "Failed to fetch static data") let priorities = try #require(data["task_priorities"] as? [[String: Any]], "Missing task_priorities") for (index, pri) in priorities.enumerated() { #expect(pri["level"] != nil, "task_priorities[\(index)] missing 'level'") #expect(pri["color"] != nil, "task_priorities[\(index)] missing 'color'") } } }