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:
treyt
2026-02-24 15:37:56 -06:00
parent fe28034f3d
commit fc0e0688eb
34 changed files with 6699 additions and 1 deletions

View 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'")
}
}
}

View 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)
}
}

View 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())
}
}

View 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"))
}
}

View 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")
}
}

View 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 == "")
}
}

View 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")
}
}

View File

@@ -6,7 +6,8 @@
//
import Testing
@testable import iosApp
import Foundation
@testable import Casera
// MARK: - Column Name Constants Tests

View 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")
}
}