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>
This commit is contained in:
572
iosApp/CaseraTests/DataLayerTests.swift
Normal file
572
iosApp/CaseraTests/DataLayerTests.swift
Normal file
@@ -0,0 +1,572 @@
|
||||
//
|
||||
// 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'")
|
||||
}
|
||||
}
|
||||
}
|
||||
364
iosApp/CaseraTests/DataManagerExtendedTests.swift
Normal file
364
iosApp/CaseraTests/DataManagerExtendedTests.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// DataManagerExtendedTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Extended unit tests covering TASK-005, TASK-012, THEME-001, TCOMP-003, and QA-002.
|
||||
//
|
||||
// IMPORTANT: DataManager-mutating suites are nested inside the DataLayerTests
|
||||
// serialized parent (defined in DataLayerTests.swift) via extension, so ALL
|
||||
// DataManager tests serialize together and avoid concurrent state interference.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Casera
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Extension of DataLayerTests (serialized parent in DataLayerTests.swift)
|
||||
|
||||
extension DataLayerTests {
|
||||
|
||||
// MARK: - TASK-005: Template Search Tests
|
||||
|
||||
@Suite struct TemplateSearchTests {
|
||||
|
||||
// Helper to build a TaskTemplate with the fields that searchTaskTemplates inspects.
|
||||
// categoryId and frequencyId are nullable Int in Kotlin; pass nil directly.
|
||||
private func makeTemplate(
|
||||
id: Int32,
|
||||
title: String,
|
||||
description: String = "",
|
||||
tags: [String] = []
|
||||
) -> TaskTemplate {
|
||||
TaskTemplate(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
categoryId: nil,
|
||||
category: nil,
|
||||
frequencyId: nil,
|
||||
frequency: nil,
|
||||
iconIos: "",
|
||||
iconAndroid: "",
|
||||
tags: tags,
|
||||
displayOrder: 0,
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
|
||||
@Test func searchWithSingleCharReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Appliance check")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "A")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchWithTwoCharsMatchesTitles() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "HVAC inspection"),
|
||||
makeTemplate(id: 3, title: "Lawn care")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "Pl")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Plumbing repair")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchIsCaseInsensitive() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "Electrical panel check")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "plumbing")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Plumbing repair")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchMatchesDescription() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Pipe maintenance", description: "Fix water leaks and pipe corrosion"),
|
||||
makeTemplate(id: 2, title: "Roof inspection", description: "Check shingles and gutters")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "water")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Pipe maintenance")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchMatchesTags() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Air filter replacement", tags: ["hvac", "filter", "air quality"]),
|
||||
makeTemplate(id: 2, title: "Lawn mowing", tags: ["landscaping", "outdoor"])
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "hvac")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Air filter replacement")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchReturnsMaxTenResults() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
// Create 15 templates all matching "maintenance"
|
||||
var templates: [TaskTemplate] = []
|
||||
for i in 1...15 {
|
||||
templates.append(makeTemplate(id: Int32(i), title: "maintenance task \(i)"))
|
||||
}
|
||||
DataManager.shared.setTaskTemplates(templates: templates)
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "maintenance")
|
||||
#expect(results.count == 10)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchNoMatchReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "Roof inspection")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "xyz")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func emptyQueryReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TASK-012: Remove Task Cache Updates
|
||||
|
||||
@Suite struct RemoveTaskTests {
|
||||
|
||||
@Test func allTasksIsNilAfterClear() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let value = DataManager.shared.allTasks.value
|
||||
#expect(value == nil)
|
||||
}
|
||||
|
||||
@Test func removeTaskOnNilAllTasksIsNoOp() {
|
||||
// When allTasks is nil, removeTask should not crash and allTasks remains nil.
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.removeTask(taskId: 42)
|
||||
|
||||
let value = DataManager.shared.allTasks.value
|
||||
#expect(value == nil)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func tasksByResidenceIsEmptyAfterClear() {
|
||||
// After clear, tasksByResidence is empty — removeTask has no residence caches to update.
|
||||
// Calling removeTask with no tasks cached should be a no-op (no crash, allTasks stays nil).
|
||||
DataManager.shared.clear()
|
||||
|
||||
// removeTask with empty caches must not throw or crash
|
||||
DataManager.shared.removeTask(taskId: 999)
|
||||
|
||||
// allTasks should remain nil after a no-op removeTask on empty state
|
||||
#expect(DataManager.shared.allTasks.value == nil)
|
||||
}
|
||||
|
||||
// NOTE: Full integration coverage for removeTask (removing from kanban columns and
|
||||
// residence caches) is exercised in the UI test suite (TaskIntegrationTests) where
|
||||
// real TaskColumnsResponse objects are constructed through the API layer.
|
||||
// Constructing TaskColumnsResponse directly from Swift requires bridging complex
|
||||
// Kotlin generics (Map<String,String> for icons) that are impractical in unit tests.
|
||||
}
|
||||
|
||||
// MARK: - THEME-001: Theme Persistence Tests
|
||||
|
||||
@Suite struct ThemePersistenceTests {
|
||||
|
||||
@Test func defaultThemeIdIsDefault() {
|
||||
// Explicitly set to "default" first since clear() does NOT reset themeId.
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
DataManager.shared.clear()
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "default")
|
||||
}
|
||||
|
||||
@Test func setThemeIdUpdatesValue() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "ocean")
|
||||
|
||||
// Restore to default so other tests are unaffected
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func setThemeIdToMultipleThemes() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setThemeId(id: "forest")
|
||||
let intermediate = DataManager.shared.themeId.value as! String
|
||||
#expect(intermediate == "forest")
|
||||
|
||||
DataManager.shared.setThemeId(id: "midnight")
|
||||
let final_ = DataManager.shared.themeId.value as! String
|
||||
#expect(final_ == "midnight")
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func clearDoesNotResetTheme() {
|
||||
// Per CLAUDE.md architecture, clear() does NOT reset themeId.
|
||||
// The Kotlin source confirms _themeId is absent from the clear() method.
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
// Theme should be preserved after clear — verify it is still a non-empty string.
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(!themeId.isEmpty)
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func themeIdIsPreservedAsOceanAfterClear() {
|
||||
// Stronger assertion: the exact value set before clear() survives clear().
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "ocean")
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCOMP-003: Task Completion Form Validation
|
||||
// These tests use only ValidationHelpers (pure Swift, no DataManager mutation).
|
||||
|
||||
struct TaskCompletionValidationTests {
|
||||
|
||||
@Test func completedByFieldRequired() {
|
||||
let result = ValidationHelpers.validateRequired("", fieldName: "Completed by")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Completed by is required")
|
||||
}
|
||||
|
||||
@Test func completedByFieldWithValuePasses() {
|
||||
let result = ValidationHelpers.validateRequired("Trey Tartt", fieldName: "Completed by")
|
||||
#expect(result.isValid)
|
||||
#expect(result.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func completedByFieldWhitespaceOnlyFails() {
|
||||
let result = ValidationHelpers.validateRequired(" ", fieldName: "Completed by")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Completed by is required")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QA-002: JSON Unknown Fields Resilience
|
||||
// Swift's JSONDecoder ignores unknown keys by default — these tests confirm that behaviour
|
||||
// holds for WidgetDataManager.WidgetTask, which uses a custom CodingKeys enum.
|
||||
|
||||
struct JSONUnknownFieldsResilienceTests {
|
||||
|
||||
@Test func widgetTaskDecodesWithExtraFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Test",
|
||||
"description": null,
|
||||
"priority": "high",
|
||||
"in_progress": false,
|
||||
"due_date": null,
|
||||
"category": "test",
|
||||
"residence_name": "Home",
|
||||
"is_overdue": false,
|
||||
"is_due_within_7_days": false,
|
||||
"is_due_8_to_30_days": false,
|
||||
"unknown_field": "should be ignored",
|
||||
"another_unknown": 42
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 1)
|
||||
#expect(task.title == "Test")
|
||||
#expect(task.priority == "high")
|
||||
#expect(task.isOverdue == false)
|
||||
#expect(task.isDueWithin7Days == false)
|
||||
#expect(task.isDue8To30Days == false)
|
||||
#expect(task.residenceName == "Home")
|
||||
}
|
||||
|
||||
@Test func widgetTaskIgnoresUnknownNestedObjects() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 99,
|
||||
"title": "Nested unknown test",
|
||||
"description": "Some description",
|
||||
"priority": "low",
|
||||
"in_progress": true,
|
||||
"due_date": "2026-03-01",
|
||||
"category": "plumbing",
|
||||
"residence_name": null,
|
||||
"is_overdue": true,
|
||||
"is_due_within_7_days": false,
|
||||
"is_due_8_to_30_days": false,
|
||||
"unknown_nested": {
|
||||
"key1": "value1",
|
||||
"key2": 123,
|
||||
"key3": true
|
||||
},
|
||||
"unknown_array": [1, 2, 3]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 99)
|
||||
#expect(task.title == "Nested unknown test")
|
||||
#expect(task.description == "Some description")
|
||||
#expect(task.inProgress == true)
|
||||
#expect(task.dueDate == "2026-03-01")
|
||||
#expect(task.category == "plumbing")
|
||||
#expect(task.residenceName == nil)
|
||||
#expect(task.isOverdue == true)
|
||||
}
|
||||
}
|
||||
286
iosApp/CaseraTests/DateUtilsTests.swift
Normal file
286
iosApp/CaseraTests/DateUtilsTests.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
//
|
||||
// DateUtilsTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for DateUtils formatting, parsing, and timezone utilities.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - DateUtils.formatDate Tests
|
||||
|
||||
struct DateUtilsFormatDateTests {
|
||||
|
||||
@Test func todayReturnsToday() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatDate(today)
|
||||
#expect(result == "Today")
|
||||
}
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatDate(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func emptyReturnsEmpty() {
|
||||
let result = DateUtils.formatDate("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsSelf() {
|
||||
let result = DateUtils.formatDate("not-a-date")
|
||||
#expect(result == "not-a-date")
|
||||
}
|
||||
|
||||
@Test func dateWithTimePartExtractsDate() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatDate("\(today)T12:00:00Z")
|
||||
#expect(result == "Today")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatDateMedium Tests
|
||||
|
||||
struct DateUtilsFormatDateMediumTests {
|
||||
|
||||
@Test func validDateFormatsAsMedium() {
|
||||
let result = DateUtils.formatDateMedium("2024-01-15")
|
||||
#expect(result == "Jan 15, 2024")
|
||||
}
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatDateMedium(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsSelf() {
|
||||
let result = DateUtils.formatDateMedium("bad-date")
|
||||
#expect(result == "bad-date")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.isOverdue Tests
|
||||
|
||||
struct DateUtilsIsOverdueTests {
|
||||
|
||||
@Test func pastDateIsOverdue() {
|
||||
let result = DateUtils.isOverdue("2020-01-01")
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func futureDateIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue("2099-12-31")
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func nilIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue(nil)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func emptyIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue("")
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func todayIsNotOverdue() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.isOverdue(today)
|
||||
#expect(result == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.parseDate Tests
|
||||
|
||||
struct DateUtilsParseDateTests {
|
||||
|
||||
@Test func validDateStringParsed() {
|
||||
let date = DateUtils.parseDate("2024-06-15")
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func nilReturnsNil() {
|
||||
let date = DateUtils.parseDate(nil)
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func emptyReturnsNil() {
|
||||
let date = DateUtils.parseDate("")
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func invalidReturnsNil() {
|
||||
let date = DateUtils.parseDate("not-a-date")
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func dateTimeStringExtractsDate() {
|
||||
let date = DateUtils.parseDate("2024-06-15T10:30:00Z")
|
||||
#expect(date != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatHour Tests
|
||||
|
||||
struct DateUtilsFormatHourTests {
|
||||
|
||||
@Test func midnightFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(0) == "12:00 AM")
|
||||
}
|
||||
|
||||
@Test func morningFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(8) == "8:00 AM")
|
||||
}
|
||||
|
||||
@Test func noonFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(12) == "12:00 PM")
|
||||
}
|
||||
|
||||
@Test func afternoonFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(14) == "2:00 PM")
|
||||
}
|
||||
|
||||
@Test func elevenPMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(23) == "11:00 PM")
|
||||
}
|
||||
|
||||
@Test func oneAMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(1) == "1:00 AM")
|
||||
}
|
||||
|
||||
@Test func elevenAMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(11) == "11:00 AM")
|
||||
}
|
||||
|
||||
@Test func onePMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(13) == "1:00 PM")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatRelativeDate Tests
|
||||
|
||||
struct DateUtilsFormatRelativeDateTests {
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatRelativeDate(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func emptyReturnsEmpty() {
|
||||
let result = DateUtils.formatRelativeDate("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func todayReturnsToday() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatRelativeDate(today)
|
||||
#expect(result == "Today")
|
||||
}
|
||||
|
||||
@Test func invalidReturnsSelf() {
|
||||
let result = DateUtils.formatRelativeDate("bad")
|
||||
#expect(result == "bad")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.localHourToUtc / utcHourToLocal Round-Trip Tests
|
||||
|
||||
struct TimezoneConversionTests {
|
||||
|
||||
@Test func roundTripLocalToUtcToLocal() {
|
||||
let localHour = 10
|
||||
let utc = DateUtils.localHourToUtc(localHour)
|
||||
let backToLocal = DateUtils.utcHourToLocal(utc)
|
||||
#expect(backToLocal == localHour)
|
||||
}
|
||||
|
||||
@Test func utcAndLocalDifferByTimezoneOffset() {
|
||||
// Just verify it returns a value in 0-23 range
|
||||
let utc = DateUtils.localHourToUtc(15)
|
||||
#expect(utc >= 0 && utc <= 23)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateExtensions Tests
|
||||
|
||||
struct DateExtensionsTests {
|
||||
|
||||
@Test func todayIsToday() {
|
||||
#expect(Date().isToday)
|
||||
}
|
||||
|
||||
@Test func todayRelativeDescriptionIsToday() {
|
||||
#expect(Date().relativeDescription == "Today")
|
||||
}
|
||||
|
||||
@Test func apiFormatHasCorrectPattern() {
|
||||
let formatted = Date().formattedAPI()
|
||||
// Should match yyyy-MM-dd pattern
|
||||
let parts = formatted.split(separator: "-")
|
||||
#expect(parts.count == 3)
|
||||
#expect(parts[0].count == 4)
|
||||
#expect(parts[1].count == 2)
|
||||
#expect(parts[2].count == 2)
|
||||
}
|
||||
|
||||
@Test func distantPastIsPast() {
|
||||
let past = Date.distantPast
|
||||
#expect(past.isPast)
|
||||
}
|
||||
|
||||
@Test func distantFutureIsNotPast() {
|
||||
let future = Date.distantFuture
|
||||
#expect(!future.isPast)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Date Extension Tests
|
||||
|
||||
struct StringDateExtensionTests {
|
||||
|
||||
@Test func validAPIDateParsed() {
|
||||
let date = "2024-06-15".toDate()
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsNil() {
|
||||
let date = "invalid".toDate()
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func dateTimeStringParsed() {
|
||||
let date = "2024-06-15T10:30:00Z".toDate()
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func pastDateIsOverdue() {
|
||||
#expect("2020-01-01".isOverdue())
|
||||
}
|
||||
|
||||
@Test func futureDateIsNotOverdue() {
|
||||
#expect(!"2099-12-31".isOverdue())
|
||||
}
|
||||
|
||||
@Test func toFormattedDateReturnsFormatted() {
|
||||
let result = "2024-01-15".toFormattedDate()
|
||||
#expect(result == "Jan 15, 2024")
|
||||
}
|
||||
|
||||
@Test func invalidDateToFormattedReturnsSelf() {
|
||||
let result = "bad-date".toFormattedDate()
|
||||
#expect(result == "bad-date")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper for date tests
|
||||
|
||||
private extension DateUtils {
|
||||
/// Returns today's date as an API-formatted string (yyyy-MM-dd)
|
||||
static func todayString() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
}
|
||||
115
iosApp/CaseraTests/DocumentHelpersTests.swift
Normal file
115
iosApp/CaseraTests/DocumentHelpersTests.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// DocumentHelpersTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for DocumentTypeHelper and DocumentCategoryHelper.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - DocumentTypeHelper Tests
|
||||
|
||||
struct DocumentTypeHelperTests {
|
||||
|
||||
@Test func warrantyDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "warranty") == "Warranty")
|
||||
}
|
||||
|
||||
@Test func manualDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "manual") == "User Manual")
|
||||
}
|
||||
|
||||
@Test func receiptDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "receipt") == "Receipt/Invoice")
|
||||
}
|
||||
|
||||
@Test func inspectionDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "inspection") == "Inspection Report")
|
||||
}
|
||||
|
||||
@Test func insuranceDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "insurance") == "Insurance")
|
||||
}
|
||||
|
||||
@Test func permitDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "permit") == "Permit")
|
||||
}
|
||||
|
||||
@Test func deedDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "deed") == "Deed/Title")
|
||||
}
|
||||
|
||||
@Test func contractDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "contract") == "Contract")
|
||||
}
|
||||
|
||||
@Test func photoDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "photo") == "Photo")
|
||||
}
|
||||
|
||||
@Test func unknownTypeDefaultsToOther() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "unknown") == "Other")
|
||||
}
|
||||
|
||||
@Test func emptyTypeDefaultsToOther() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "") == "Other")
|
||||
}
|
||||
|
||||
@Test func allTypesArrayNotEmpty() {
|
||||
#expect(!DocumentTypeHelper.allTypes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func allTypesContainsWarranty() {
|
||||
#expect(DocumentTypeHelper.allTypes.contains("warranty"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DocumentCategoryHelper Tests
|
||||
|
||||
struct DocumentCategoryHelperTests {
|
||||
|
||||
@Test func applianceDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "appliance") == "Appliance")
|
||||
}
|
||||
|
||||
@Test func hvacDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "hvac") == "HVAC")
|
||||
}
|
||||
|
||||
@Test func plumbingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "plumbing") == "Plumbing")
|
||||
}
|
||||
|
||||
@Test func electricalDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "electrical") == "Electrical")
|
||||
}
|
||||
|
||||
@Test func roofingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "roofing") == "Roofing")
|
||||
}
|
||||
|
||||
@Test func structuralDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "structural") == "Structural")
|
||||
}
|
||||
|
||||
@Test func landscapingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "landscaping") == "Landscaping")
|
||||
}
|
||||
|
||||
@Test func generalDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "general") == "General")
|
||||
}
|
||||
|
||||
@Test func unknownCategoryDefaultsToOther() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "xyz") == "Other")
|
||||
}
|
||||
|
||||
@Test func allCategoriesArrayNotEmpty() {
|
||||
#expect(!DocumentCategoryHelper.allCategories.isEmpty)
|
||||
}
|
||||
|
||||
@Test func allCategoriesContainsHVAC() {
|
||||
#expect(DocumentCategoryHelper.allCategories.contains("hvac"))
|
||||
}
|
||||
}
|
||||
150
iosApp/CaseraTests/DoubleExtensionsTests.swift
Normal file
150
iosApp/CaseraTests/DoubleExtensionsTests.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// DoubleExtensionsTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for Double, Int number formatting extensions.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - Double.toCurrency Tests
|
||||
|
||||
struct DoubleCurrencyTests {
|
||||
|
||||
@Test func wholeNumberCurrency() {
|
||||
let result = 100.0.toCurrency()
|
||||
#expect(result.contains("100"))
|
||||
#expect(result.contains("$"))
|
||||
}
|
||||
|
||||
@Test func decimalCurrency() {
|
||||
let result = 49.99.toCurrency()
|
||||
#expect(result.contains("49.99"))
|
||||
}
|
||||
|
||||
@Test func zeroCurrency() {
|
||||
let result = 0.0.toCurrency()
|
||||
#expect(result.contains("0"))
|
||||
#expect(result.contains("$"))
|
||||
}
|
||||
|
||||
@Test func largeNumberCurrency() {
|
||||
let result = 1234567.89.toCurrency()
|
||||
#expect(result.contains("1,234,567"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.toFileSize Tests
|
||||
|
||||
struct FileSizeTests {
|
||||
|
||||
@Test func bytesSize() {
|
||||
let result = 512.0.toFileSize()
|
||||
#expect(result == "512.0 B")
|
||||
}
|
||||
|
||||
@Test func kilobytesSize() {
|
||||
let result = 2048.0.toFileSize()
|
||||
#expect(result == "2.0 KB")
|
||||
}
|
||||
|
||||
@Test func megabytesSize() {
|
||||
let result = (5.0 * 1024 * 1024).toFileSize()
|
||||
#expect(result == "5.0 MB")
|
||||
}
|
||||
|
||||
@Test func gigabytesSize() {
|
||||
let result = (2.5 * 1024 * 1024 * 1024).toFileSize()
|
||||
#expect(result == "2.5 GB")
|
||||
}
|
||||
|
||||
@Test func zeroBytes() {
|
||||
let result = 0.0.toFileSize()
|
||||
#expect(result == "0.0 B")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.rounded Tests
|
||||
|
||||
struct DoubleRoundedTests {
|
||||
|
||||
@Test func roundToTwoPlaces() {
|
||||
#expect(3.14159.rounded(to: 2) == 3.14)
|
||||
}
|
||||
|
||||
@Test func roundToZeroPlaces() {
|
||||
#expect(3.7.rounded(to: 0) == 4.0)
|
||||
}
|
||||
|
||||
@Test func roundToOnePlaceDown() {
|
||||
#expect(3.14.rounded(to: 1) == 3.1)
|
||||
}
|
||||
|
||||
@Test func roundToOnePlaceUp() {
|
||||
#expect(3.15.rounded(to: 1) == 3.2)
|
||||
}
|
||||
|
||||
@Test func zeroRoundsToZero() {
|
||||
#expect(0.0.rounded(to: 3) == 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.toPercentage Tests
|
||||
|
||||
struct PercentageTests {
|
||||
|
||||
@Test func fiftyPercent() {
|
||||
let result = 50.0.toPercentage()
|
||||
#expect(result.contains("50"))
|
||||
#expect(result.contains("%"))
|
||||
}
|
||||
|
||||
@Test func zeroPercent() {
|
||||
let result = 0.0.toPercentage()
|
||||
#expect(result.contains("0"))
|
||||
#expect(result.contains("%"))
|
||||
}
|
||||
|
||||
@Test func hundredPercent() {
|
||||
let result = 100.0.toPercentage()
|
||||
#expect(result.contains("100"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Int.pluralSuffix Tests
|
||||
|
||||
struct PluralSuffixTests {
|
||||
|
||||
@Test func singularNoSuffix() {
|
||||
#expect(1.pluralSuffix() == "")
|
||||
}
|
||||
|
||||
@Test func pluralDefaultSuffix() {
|
||||
#expect(2.pluralSuffix() == "s")
|
||||
}
|
||||
|
||||
@Test func zeroIsPlural() {
|
||||
#expect(0.pluralSuffix() == "s")
|
||||
}
|
||||
|
||||
@Test func customSuffixes() {
|
||||
#expect(1.pluralSuffix("y", "ies") == "y")
|
||||
#expect(3.pluralSuffix("y", "ies") == "ies")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Int.toFileSize Tests
|
||||
|
||||
struct IntFileSizeTests {
|
||||
|
||||
@Test func intBytesToFileSize() {
|
||||
let result = 1024.toFileSize()
|
||||
#expect(result == "1.0 KB")
|
||||
}
|
||||
|
||||
@Test func intZeroBytes() {
|
||||
let result = 0.toFileSize()
|
||||
#expect(result == "0.0 B")
|
||||
}
|
||||
}
|
||||
221
iosApp/CaseraTests/ErrorMessageParserTests.swift
Normal file
221
iosApp/CaseraTests/ErrorMessageParserTests.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// ErrorMessageParserTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for ErrorMessageParser error code mapping, network error detection,
|
||||
// and message parsing logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - API Error Code Mapping Tests
|
||||
|
||||
struct ErrorCodeMappingTests {
|
||||
|
||||
@Test func invalidCredentialsCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_credentials")
|
||||
#expect(result == "Invalid username or password. Please try again.")
|
||||
}
|
||||
|
||||
@Test func invalidTokenCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_token")
|
||||
#expect(result == "Your session has expired. Please log in again.")
|
||||
}
|
||||
|
||||
@Test func usernameTakenCode() {
|
||||
let result = ErrorMessageParser.parse("error.username_taken")
|
||||
#expect(result == "This username is already taken. Please choose another.")
|
||||
}
|
||||
|
||||
@Test func emailTakenCode() {
|
||||
let result = ErrorMessageParser.parse("error.email_taken")
|
||||
#expect(result == "This email is already registered. Try logging in instead.")
|
||||
}
|
||||
|
||||
@Test func invalidVerificationCodeCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_verification_code")
|
||||
#expect(result == "Invalid verification code. Please check and try again.")
|
||||
}
|
||||
|
||||
@Test func verificationCodeExpiredCode() {
|
||||
let result = ErrorMessageParser.parse("error.verification_code_expired")
|
||||
#expect(result == "Your verification code has expired. Please request a new one.")
|
||||
}
|
||||
|
||||
@Test func rateLimitExceededCode() {
|
||||
let result = ErrorMessageParser.parse("error.rate_limit_exceeded")
|
||||
#expect(result == "Too many attempts. Please wait a few minutes and try again.")
|
||||
}
|
||||
|
||||
@Test func taskNotFoundCode() {
|
||||
let result = ErrorMessageParser.parse("error.task_not_found")
|
||||
#expect(result == "Task not found. It may have been deleted.")
|
||||
}
|
||||
|
||||
@Test func residenceNotFoundCode() {
|
||||
let result = ErrorMessageParser.parse("error.residence_not_found")
|
||||
#expect(result == "Property not found. It may have been deleted.")
|
||||
}
|
||||
|
||||
@Test func accessDeniedCode() {
|
||||
let result = ErrorMessageParser.parse("error.access_denied")
|
||||
#expect(result == "You don't have permission for this action.")
|
||||
}
|
||||
|
||||
@Test func shareCodeInvalidCode() {
|
||||
let result = ErrorMessageParser.parse("error.share_code_invalid")
|
||||
#expect(result == "Invalid share code. Please check and try again.")
|
||||
}
|
||||
|
||||
@Test func propertiesLimitReachedCode() {
|
||||
let result = ErrorMessageParser.parse("error.properties_limit_reached")
|
||||
#expect(result == "You've reached your property limit. Upgrade to add more.")
|
||||
}
|
||||
|
||||
@Test func internalErrorCode() {
|
||||
let result = ErrorMessageParser.parse("error.internal")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func titleRequiredCode() {
|
||||
let result = ErrorMessageParser.parse("error.title_required")
|
||||
#expect(result == "Title is required.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unknown Error Code Tests
|
||||
|
||||
struct UnknownErrorCodeTests {
|
||||
|
||||
@Test func unknownErrorCodeGeneratesMessage() {
|
||||
let result = ErrorMessageParser.parse("error.some_new_error")
|
||||
#expect(result == "Some new error. Please try again.")
|
||||
}
|
||||
|
||||
@Test func unknownErrorCodeWithSingleWord() {
|
||||
let result = ErrorMessageParser.parse("error.forbidden")
|
||||
#expect(result == "Forbidden. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network Error Pattern Tests
|
||||
|
||||
struct NetworkErrorPatternTests {
|
||||
|
||||
@Test func offlineErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("The Internet connection appears to be offline")
|
||||
#expect(result == "No internet connection. Please check your network.")
|
||||
}
|
||||
|
||||
@Test func timeoutErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("The request timed out")
|
||||
#expect(result == "Request timed out. Please try again.")
|
||||
}
|
||||
|
||||
@Test func connectionLostDetected() {
|
||||
let result = ErrorMessageParser.parse("The network connection was lost")
|
||||
#expect(result == "Connection lost. Please try again.")
|
||||
}
|
||||
|
||||
@Test func sslErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("An SSL error has occurred")
|
||||
#expect(result == "Secure connection failed. Please try again.")
|
||||
}
|
||||
|
||||
@Test func nsUrlErrorCodeDetected() {
|
||||
let result = ErrorMessageParser.parse("Error Code=-1009 in domain NSURLErrorDomain")
|
||||
#expect(result.contains("internet") || result.contains("connect"))
|
||||
}
|
||||
|
||||
@Test func connectionRefusedDetected() {
|
||||
let result = ErrorMessageParser.parse("Connection refused")
|
||||
#expect(result == "Unable to connect. The server may be temporarily unavailable.")
|
||||
}
|
||||
|
||||
@Test func socketTimeoutDetected() {
|
||||
let result = ErrorMessageParser.parse("SocketTimeoutException: connect timed out")
|
||||
#expect(result == "Request timed out. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Technical Error Detection Tests
|
||||
|
||||
struct TechnicalErrorDetectionTests {
|
||||
|
||||
@Test func stackTraceDetected() {
|
||||
let result = ErrorMessageParser.parse("java.lang.NullPointerException at com.example.App.method(App.kt:42)")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func swiftErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("Fatal error in module.swift:123")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func kotlinErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("Exception at kotlin.coroutines.ContinuationKt.kt:55")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func nsErrorDomainDetected() {
|
||||
let result = ErrorMessageParser.parse("Error Domain=NSURLErrorDomain Code=-1001 UserInfo={NSLocalizedDescription=timed out}")
|
||||
#expect(result != "Error Domain=NSURLErrorDomain Code=-1001 UserInfo={NSLocalizedDescription=timed out}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Error Parsing Tests
|
||||
|
||||
struct JSONErrorParsingTests {
|
||||
|
||||
@Test func jsonWithErrorFieldParsed() {
|
||||
let json = #"{"error": "error.invalid_credentials"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Invalid username or password. Please try again.")
|
||||
}
|
||||
|
||||
@Test func jsonWithMessageFieldParsed() {
|
||||
let json = #"{"message": "Something went wrong"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Something went wrong")
|
||||
}
|
||||
|
||||
@Test func jsonWithDetailFieldParsed() {
|
||||
let json = #"{"detail": "Not authorized"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Not authorized")
|
||||
}
|
||||
|
||||
@Test func jsonWithDataObjectReturnsGeneric() {
|
||||
let json = #"{"id": 1, "title": "Test"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Request failed. Please check your input and try again.")
|
||||
}
|
||||
|
||||
@Test func invalidJsonReturnsGeneric() {
|
||||
let json = #"{malformed json"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "An error occurred. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User-Friendly Message Tests
|
||||
|
||||
struct UserFriendlyMessageTests {
|
||||
|
||||
@Test func shortReadableMessagePassedThrough() {
|
||||
let result = ErrorMessageParser.parse("Invalid email address")
|
||||
#expect(result == "Invalid email address")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsSelf() {
|
||||
// Empty/whitespace strings pass through the parser's user-friendly check
|
||||
let result = ErrorMessageParser.parse("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyReturnsTrimmed() {
|
||||
let result = ErrorMessageParser.parse(" ")
|
||||
#expect(result == "")
|
||||
}
|
||||
}
|
||||
203
iosApp/CaseraTests/StringExtensionsTests.swift
Normal file
203
iosApp/CaseraTests/StringExtensionsTests.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// StringExtensionsTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for String and Optional<String> extensions.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - isBlank Tests
|
||||
|
||||
struct IsBlankTests {
|
||||
|
||||
@Test func emptyStringIsBlank() {
|
||||
#expect("".isBlank)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyIsBlank() {
|
||||
#expect(" ".isBlank)
|
||||
}
|
||||
|
||||
@Test func nonEmptyIsNotBlank() {
|
||||
#expect(!"hello".isBlank)
|
||||
}
|
||||
|
||||
@Test func stringWithWhitespacePaddingIsNotBlank() {
|
||||
#expect(!" hello ".isBlank)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - nilIfBlank Tests
|
||||
|
||||
struct NilIfBlankTests {
|
||||
|
||||
@Test func emptyReturnsNil() {
|
||||
#expect("".nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyReturnsNil() {
|
||||
#expect(" ".nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func nonEmptyReturnsTrimmed() {
|
||||
#expect(" hello ".nilIfBlank == "hello")
|
||||
}
|
||||
|
||||
@Test func plainStringReturnsSelf() {
|
||||
#expect("test".nilIfBlank == "test")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - capitalizedFirst Tests
|
||||
|
||||
struct CapitalizedFirstTests {
|
||||
|
||||
@Test func lowercaseFirstCapitalized() {
|
||||
#expect("hello".capitalizedFirst == "Hello")
|
||||
}
|
||||
|
||||
@Test func alreadyCapitalizedUnchanged() {
|
||||
#expect("Hello".capitalizedFirst == "Hello")
|
||||
}
|
||||
|
||||
@Test func allUppercaseOnlyFirstKept() {
|
||||
#expect("hELLO".capitalizedFirst == "HELLO")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsEmpty() {
|
||||
#expect("".capitalizedFirst == "")
|
||||
}
|
||||
|
||||
@Test func singleCharCapitalized() {
|
||||
#expect("a".capitalizedFirst == "A")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - truncated Tests
|
||||
|
||||
struct TruncatedTests {
|
||||
|
||||
@Test func shortStringNotTruncated() {
|
||||
#expect("hi".truncated(to: 10) == "hi")
|
||||
}
|
||||
|
||||
@Test func longStringTruncatedWithEllipsis() {
|
||||
#expect("Hello World".truncated(to: 5) == "Hello...")
|
||||
}
|
||||
|
||||
@Test func longStringTruncatedWithoutEllipsis() {
|
||||
#expect("Hello World".truncated(to: 5, addEllipsis: false) == "Hello")
|
||||
}
|
||||
|
||||
@Test func exactLengthNotTruncated() {
|
||||
#expect("Hello".truncated(to: 5) == "Hello")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - isValidEmail Tests
|
||||
|
||||
struct IsValidEmailTests {
|
||||
|
||||
@Test func standardEmailValid() {
|
||||
#expect("user@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithSubdomainValid() {
|
||||
#expect("user@mail.example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithPlusValid() {
|
||||
#expect("user+tag@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithDotsValid() {
|
||||
#expect("first.last@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noAtSignInvalid() {
|
||||
#expect(!"userexample.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noDomainInvalid() {
|
||||
#expect(!"user@".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noTLDInvalid() {
|
||||
#expect(!"user@example".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emptyStringInvalid() {
|
||||
#expect(!"".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func numericLocalPartValid() {
|
||||
#expect("123@example.com".isValidEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - isValidPhone Tests
|
||||
|
||||
struct IsValidPhoneTests {
|
||||
|
||||
@Test func tenDigitsValid() {
|
||||
#expect("5551234567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func formattedUSPhoneValid() {
|
||||
#expect("(555) 123-4567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func internationalFormatValid() {
|
||||
#expect("+1 555-123-4567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func tooShortInvalid() {
|
||||
#expect(!"12345".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func lettersInvalid() {
|
||||
#expect(!"555-ABC-DEFG".isValidPhone)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Optional String Tests
|
||||
|
||||
struct OptionalStringTests {
|
||||
|
||||
@Test func nilIsNilOrBlank() {
|
||||
let s: String? = nil
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func emptyIsNilOrBlank() {
|
||||
let s: String? = ""
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func whitespaceIsNilOrBlank() {
|
||||
let s: String? = " "
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func valueIsNotNilOrBlank() {
|
||||
let s: String? = "hello"
|
||||
#expect(!s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func nilNilIfBlankReturnsNil() {
|
||||
let s: String? = nil
|
||||
#expect(s.nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func blankOptionalNilIfBlankReturnsNil() {
|
||||
let s: String? = " "
|
||||
#expect(s.nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func valueOptionalNilIfBlankReturnsTrimmed() {
|
||||
let s: String? = " hello "
|
||||
#expect(s.nilIfBlank == "hello")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import iosApp
|
||||
import Foundation
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - Column Name Constants Tests
|
||||
|
||||
|
||||
445
iosApp/CaseraTests/ValidationHelpersTests.swift
Normal file
445
iosApp/CaseraTests/ValidationHelpersTests.swift
Normal file
@@ -0,0 +1,445 @@
|
||||
//
|
||||
// ValidationHelpersTests.swift
|
||||
// CaseraTests
|
||||
//
|
||||
// Unit tests for ValidationHelpers, FormValidator, and related types.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Casera
|
||||
|
||||
// MARK: - ValidationResult Tests
|
||||
|
||||
struct ValidationResultTests {
|
||||
|
||||
@Test func validResultIsValid() {
|
||||
let result = ValidationResult.valid
|
||||
#expect(result.isValid)
|
||||
#expect(result.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func invalidResultIsNotValid() {
|
||||
let result = ValidationResult.invalid("Error")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Validation Tests
|
||||
|
||||
struct EmailValidationTests {
|
||||
|
||||
@Test func validEmailPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyEmailFails() {
|
||||
let result = ValidationHelpers.validateEmail("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Email is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyEmailFails() {
|
||||
let result = ValidationHelpers.validateEmail(" ")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Email is required")
|
||||
}
|
||||
|
||||
@Test func emailWithoutAtSignFails() {
|
||||
let result = ValidationHelpers.validateEmail("userexample.com")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Please enter a valid email address")
|
||||
}
|
||||
|
||||
@Test func emailWithoutDomainFails() {
|
||||
let result = ValidationHelpers.validateEmail("user@")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithSubdomainPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user@mail.example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithPlusAddressingPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user+tag@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithDotsInLocalPartPasses() {
|
||||
let result = ValidationHelpers.validateEmail("first.last@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Validation Tests
|
||||
|
||||
struct PasswordValidationTests {
|
||||
|
||||
@Test func validPasswordPasses() {
|
||||
let result = ValidationHelpers.validatePassword("StrongPass1")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyPasswordFails() {
|
||||
let result = ValidationHelpers.validatePassword("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password is required")
|
||||
}
|
||||
|
||||
@Test func shortPasswordFails() {
|
||||
let result = ValidationHelpers.validatePassword("short")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password must be at least 8 characters")
|
||||
}
|
||||
|
||||
@Test func exactMinLengthPasswordPasses() {
|
||||
let result = ValidationHelpers.validatePassword("12345678")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func customMinLengthEnforced() {
|
||||
let result = ValidationHelpers.validatePassword("1234", minLength: 6)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password must be at least 6 characters")
|
||||
}
|
||||
|
||||
@Test func customMinLengthPassesWhenMet() {
|
||||
let result = ValidationHelpers.validatePassword("123456", minLength: 6)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Confirmation Tests
|
||||
|
||||
struct PasswordConfirmationTests {
|
||||
|
||||
@Test func matchingPasswordsPasses() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "password123")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func mismatchedPasswordsFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "password456")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Passwords do not match")
|
||||
}
|
||||
|
||||
@Test func emptyConfirmationFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func caseSensitiveMismatchFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("Password", confirmation: "password")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Name Validation Tests
|
||||
|
||||
struct NameValidationTests {
|
||||
|
||||
@Test func validNamePasses() {
|
||||
let result = ValidationHelpers.validateName("John")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyNameFails() {
|
||||
let result = ValidationHelpers.validateName("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Name is required")
|
||||
}
|
||||
|
||||
@Test func singleCharNameFails() {
|
||||
let result = ValidationHelpers.validateName("J")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Name must be at least 2 characters")
|
||||
}
|
||||
|
||||
@Test func twoCharNamePasses() {
|
||||
let result = ValidationHelpers.validateName("Jo")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func customFieldNameInError() {
|
||||
let result = ValidationHelpers.validateName("", fieldName: "Username")
|
||||
#expect(result.errorMessage == "Username is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyNameFails() {
|
||||
let result = ValidationHelpers.validateName(" ")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone Validation Tests
|
||||
|
||||
struct PhoneValidationTests {
|
||||
|
||||
@Test func validUSPhonePasses() {
|
||||
let result = ValidationHelpers.validatePhone("(555) 123-4567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyPhoneFails() {
|
||||
let result = ValidationHelpers.validatePhone("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Phone number is required")
|
||||
}
|
||||
|
||||
@Test func shortPhoneFails() {
|
||||
let result = ValidationHelpers.validatePhone("12345")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Please enter a valid phone number")
|
||||
}
|
||||
|
||||
@Test func phoneWithCountryCodePasses() {
|
||||
let result = ValidationHelpers.validatePhone("+1 555-123-4567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func digitsOnlyPhonePasses() {
|
||||
let result = ValidationHelpers.validatePhone("5551234567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Required Field Validation Tests
|
||||
|
||||
struct RequiredFieldValidationTests {
|
||||
|
||||
@Test func nonEmptyPasses() {
|
||||
let result = ValidationHelpers.validateRequired("value", fieldName: "Field")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyFails() {
|
||||
let result = ValidationHelpers.validateRequired("", fieldName: "Title")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Title is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyFails() {
|
||||
let result = ValidationHelpers.validateRequired(" ", fieldName: "Description")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Number Validation Tests
|
||||
|
||||
struct NumberValidationTests {
|
||||
|
||||
@Test func validNumberPasses() {
|
||||
let result = ValidationHelpers.validateNumber("42.5", fieldName: "Cost")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyNumberFails() {
|
||||
let result = ValidationHelpers.validateNumber("", fieldName: "Cost")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost is required")
|
||||
}
|
||||
|
||||
@Test func nonNumericFails() {
|
||||
let result = ValidationHelpers.validateNumber("abc", fieldName: "Cost")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be a valid number")
|
||||
}
|
||||
|
||||
@Test func belowMinFails() {
|
||||
let result = ValidationHelpers.validateNumber("5", fieldName: "Cost", min: 10)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be at least 10.0")
|
||||
}
|
||||
|
||||
@Test func aboveMaxFails() {
|
||||
let result = ValidationHelpers.validateNumber("100", fieldName: "Cost", max: 50)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be at most 50.0")
|
||||
}
|
||||
|
||||
@Test func withinRangePasses() {
|
||||
let result = ValidationHelpers.validateNumber("25", fieldName: "Cost", min: 10, max: 50)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func negativeNumberValidated() {
|
||||
let result = ValidationHelpers.validateNumber("-5", fieldName: "Value", min: 0)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integer Validation Tests
|
||||
|
||||
struct IntegerValidationTests {
|
||||
|
||||
@Test func validIntegerPasses() {
|
||||
let result = ValidationHelpers.validateInteger("42", fieldName: "Bedrooms")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func decimalFails() {
|
||||
let result = ValidationHelpers.validateInteger("3.5", fieldName: "Bedrooms")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Bedrooms must be a whole number")
|
||||
}
|
||||
|
||||
@Test func belowMinFails() {
|
||||
let result = ValidationHelpers.validateInteger("0", fieldName: "Bedrooms", min: 1)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func aboveMaxFails() {
|
||||
let result = ValidationHelpers.validateInteger("100", fieldName: "Bedrooms", max: 50)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Length Validation Tests
|
||||
|
||||
struct LengthValidationTests {
|
||||
|
||||
@Test func withinLengthPasses() {
|
||||
let result = ValidationHelpers.validateLength("hello", fieldName: "Title", min: 2, max: 100)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func tooShortFails() {
|
||||
let result = ValidationHelpers.validateLength("a", fieldName: "Title", min: 3)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Title must be at least 3 characters")
|
||||
}
|
||||
|
||||
@Test func tooLongFails() {
|
||||
let result = ValidationHelpers.validateLength("abcdef", fieldName: "Code", max: 5)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code must be at most 5 characters")
|
||||
}
|
||||
|
||||
@Test func emptyWithNoMinPasses() {
|
||||
let result = ValidationHelpers.validateLength("", fieldName: "Notes")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Validation Tests
|
||||
|
||||
struct URLValidationTests {
|
||||
|
||||
@Test func validURLPasses() {
|
||||
let result = ValidationHelpers.validateURL("https://example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyURLFails() {
|
||||
let result = ValidationHelpers.validateURL("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "URL is required")
|
||||
}
|
||||
|
||||
@Test func httpURLPasses() {
|
||||
let result = ValidationHelpers.validateURL("http://example.com/path")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Validation Tests
|
||||
|
||||
struct CustomValidationTests {
|
||||
|
||||
@Test func passingCustomValidatorPasses() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"abc123",
|
||||
fieldName: "Code",
|
||||
validator: { $0.count == 6 },
|
||||
errorMessage: "Code must be exactly 6 characters"
|
||||
)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func failingCustomValidatorFails() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"abc",
|
||||
fieldName: "Code",
|
||||
validator: { $0.count == 6 },
|
||||
errorMessage: "Code must be exactly 6 characters"
|
||||
)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code must be exactly 6 characters")
|
||||
}
|
||||
|
||||
@Test func emptyValueInCustomValidatorFails() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"",
|
||||
fieldName: "Code",
|
||||
validator: { _ in true },
|
||||
errorMessage: "Unused"
|
||||
)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code is required")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FormValidator Tests
|
||||
|
||||
struct FormValidatorTests {
|
||||
|
||||
@Test func allValidFieldsPass() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("user@example.com") }
|
||||
validator.add(fieldName: "name") { ValidationHelpers.validateName("John") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(result.isValid)
|
||||
#expect(result.errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test func singleInvalidFieldFails() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.add(fieldName: "name") { ValidationHelpers.validateName("John") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors.count == 1)
|
||||
#expect(result.errors["email"] != nil)
|
||||
}
|
||||
|
||||
@Test func multipleInvalidFieldsFail() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.add(fieldName: "password") { ValidationHelpers.validatePassword("") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors.count == 2)
|
||||
}
|
||||
|
||||
@Test func clearRemovesValidations() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.clear()
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FormValidationResult Tests
|
||||
|
||||
struct FormValidationResultTests {
|
||||
|
||||
@Test func validResultHasNoErrors() {
|
||||
let result = FormValidationResult.valid
|
||||
#expect(result.isValid)
|
||||
#expect(result.errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test func invalidResultHasErrors() {
|
||||
let result = FormValidationResult.invalid(["email": "Email is required"])
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors["email"] == "Email is required")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user