Files
honeyDueKMP/iosApp/CaseraTests/DataLayerTests.swift
treyt fc0e0688eb Add comprehensive iOS unit and UI test suites for greenfield test plan
- Create unit tests: DataLayerTests (27 tests for DATA-001–007), DataManagerExtendedTests
  (20 tests for TASK-005, TASK-012, TCOMP-003, THEME-001, QA-002), plus ValidationHelpers,
  TaskMetrics, StringExtensions, DoubleExtensions, DateUtils, DocumentHelpers, ErrorMessageParser
- Create UI tests: AuthenticationTests, PasswordResetTests, OnboardingTests, TaskIntegration,
  ContractorIntegration, ResidenceIntegration, DocumentIntegration, DataLayer, Stability
- Add UI test framework: AuthenticatedTestCase, ScreenObjects, TestFlows, TestAccountManager,
  TestAccountAPIClient, TestDataCleaner, TestDataSeeder
- Add accessibility identifiers to password reset views for UI test support
- Add greenfield test plan CSVs and update automated column for 27 test IDs
- All 297 unit tests pass across 60 suites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:37:56 -06:00

573 lines
22 KiB
Swift

//
// 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) == 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) == 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) == 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<Int> = [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'")
}
}
}