Files
honeyDueKMP/iosApp/HoneyDueUITests/Framework/TestAccountAPIClient.swift
Trey T 4df8707b92 UI test infrastructure overhaul — 58% to 96% pass rate (231/241)
Major infrastructure changes:
- BaseUITestCase: per-suite app termination via class setUp() prevents
  stale state when parallel clones share simulators
- relaunchBetweenTests override for suites that modify login/onboarding state
- focusAndType: dedicated SecureTextField path handles iOS strong password
  autofill suggestions (Choose My Own Password / Not Now dialogs)
- LoginScreenObject: tapSignUp/tapForgotPassword use scrollIntoView for
  offscreen buttons instead of simple swipeUp
- Removed all coordinate taps from ForgotPasswordScreen, VerifyResetCodeScreen,
  ResetPasswordScreen (Rule 3 compliance)
- Removed all usleep calls from screen objects (Rule 14 compliance)

App fixes exposed by tests:
- ContractorsListView: added onDismiss to sheet for list refresh after save
- AllTasksView: added Task.RefreshButton accessibility identifier
- AccessibilityIdentifiers: added Task.refreshButton
- DocumentsWarrantiesView: onDismiss handler for document list refresh
- Various form views: textContentType, submitLabel, onSubmit for keyboard flow

Test fixes:
- PasswordResetTests: handle auto-login after reset (app skips success screen)
- AuthenticatedUITestCase: refreshTasks() helper for kanban toolbar button
- All pre-login suites use relaunchBetweenTests for test independence
- Deleted dead code: AuthenticatedTestCase, SeededTestData, SeedTests,
  CleanupTests, old Suite0/2/3, Suite1_RegistrationRebuildTests

10 remaining failures: 5 iOS strong password autofill (simulator env),
3 pull-to-refresh gesture on empty lists, 2 feature coverage edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:05:37 -05:00

600 lines
22 KiB
Swift

import Foundation
import XCTest
// MARK: - API Result Type
/// Result of an API call with status code access for error assertions.
struct APIResult<T> {
let data: T?
let statusCode: Int
let errorBody: String?
var succeeded: Bool { (200...299).contains(statusCode) }
/// Unwrap data or fail the test.
func unwrap(file: StaticString = #filePath, line: UInt = #line) -> T {
guard let data = data else {
XCTFail("Expected data but got status \(statusCode): \(errorBody ?? "nil")", file: file, line: line)
preconditionFailure("unwrap failed")
}
return data
}
}
// MARK: - Auth Response Types
struct TestUser: Decodable {
let id: Int
let username: String
let email: String
let firstName: String?
let lastName: String?
let isActive: Bool?
let verified: Bool?
enum CodingKeys: String, CodingKey {
case id, username, email
case firstName = "first_name"
case lastName = "last_name"
case isActive = "is_active"
case verified
}
}
struct TestAuthResponse: Decodable {
let token: String
let user: TestUser
let message: String?
}
struct TestVerifyEmailResponse: Decodable {
let message: String
let verified: Bool
}
struct TestVerifyResetCodeResponse: Decodable {
let message: String
let resetToken: String
enum CodingKeys: String, CodingKey {
case message
case resetToken = "reset_token"
}
}
struct TestMessageResponse: Decodable {
let message: String
}
struct TestSession {
let token: String
let user: TestUser
let username: String
let password: String
}
// MARK: - CRUD Response Types
/// Wrapper for create/update/get responses that include a summary.
struct TestWrappedResponse<T: Decodable>: Decodable {
let data: T
}
struct TestResidence: Decodable {
let id: Int
let name: String
let ownerId: Int?
let streetAddress: String?
let city: String?
let stateProvince: String?
let postalCode: String?
let isPrimary: Bool?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, name
case ownerId = "owner_id"
case streetAddress = "street_address"
case city
case stateProvince = "state_province"
case postalCode = "postal_code"
case isPrimary = "is_primary"
case isActive = "is_active"
}
}
struct TestTask: Decodable {
let id: Int
let residenceId: Int
let title: String
let description: String?
let inProgress: Bool?
let isCancelled: Bool?
let isArchived: Bool?
let kanbanColumn: String?
enum CodingKeys: String, CodingKey {
case id, title, description
case residenceId = "residence_id"
case inProgress = "in_progress"
case isCancelled = "is_cancelled"
case isArchived = "is_archived"
case kanbanColumn = "kanban_column"
}
}
struct TestContractor: Decodable {
let id: Int
let name: String
let company: String?
let phone: String?
let email: String?
let isFavorite: Bool?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, name, company, phone, email
case isFavorite = "is_favorite"
case isActive = "is_active"
}
}
struct TestDocument: Decodable {
let id: Int
let residenceId: Int
let title: String
let documentType: String?
let isActive: Bool?
enum CodingKeys: String, CodingKey {
case id, title
case residenceId = "residence_id"
case documentType = "document_type"
case isActive = "is_active"
}
}
struct TestShareCode: Decodable {
let id: Int
let code: String
let residenceId: Int
let isActive: Bool
enum CodingKeys: String, CodingKey {
case id, code
case residenceId = "residence_id"
case isActive = "is_active"
}
}
struct TestGenerateShareCodeResponse: Decodable {
let message: String
let shareCode: TestShareCode
enum CodingKeys: String, CodingKey {
case message
case shareCode = "share_code"
}
}
struct TestGetShareCodeResponse: Decodable {
let shareCode: TestShareCode
enum CodingKeys: String, CodingKey {
case shareCode = "share_code"
}
}
struct TestJoinResidenceResponse: Decodable {
let message: String
let residence: TestResidence
}
struct TestResidenceUser: Decodable {
let id: Int
let username: String
let email: String
enum CodingKeys: String, CodingKey {
case id, username, email
}
}
// MARK: - API Client
enum TestAccountAPIClient {
static let baseURL = "http://127.0.0.1:8000/api"
static let debugVerificationCode = "123456"
// MARK: - Auth Methods
static func register(username: String, email: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = [
"username": username,
"email": email,
"password": password
]
return performRequest(method: "POST", path: "/auth/register/", body: body, responseType: TestAuthResponse.self)
}
static func login(username: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = ["username": username, "password": password]
return performRequest(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
}
static func verifyEmail(token: String) -> TestVerifyEmailResponse? {
let body: [String: Any] = ["code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-email/", body: body, token: token, responseType: TestVerifyEmailResponse.self)
}
static func getCurrentUser(token: String) -> TestUser? {
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
}
static func forgotPassword(email: String) -> TestMessageResponse? {
let body: [String: Any] = ["email": email]
return performRequest(method: "POST", path: "/auth/forgot-password/", body: body, responseType: TestMessageResponse.self)
}
static func verifyResetCode(email: String) -> TestVerifyResetCodeResponse? {
let body: [String: Any] = ["email": email, "code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-reset-code/", body: body, responseType: TestVerifyResetCodeResponse.self)
}
static func resetPassword(resetToken: String, newPassword: String) -> TestMessageResponse? {
let body: [String: Any] = ["reset_token": resetToken, "new_password": newPassword]
return performRequest(method: "POST", path: "/auth/reset-password/", body: body, responseType: TestMessageResponse.self)
}
static func logout(token: String) -> TestMessageResponse? {
return performRequest(method: "POST", path: "/auth/logout/", token: token, responseType: TestMessageResponse.self)
}
/// Convenience: register + verify + re-login, returns ready session.
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
guard let registerResponse = register(username: username, email: email, password: password) else { return nil }
guard verifyEmail(token: registerResponse.token) != nil else { return nil }
guard let loginResponse = login(username: username, password: password) else { return nil }
return TestSession(token: loginResponse.token, user: loginResponse.user, username: username, password: password)
}
// MARK: - Auth with Status Code
/// Login returning full APIResult so callers can assert on 401, 400, etc.
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
let body: [String: Any] = ["username": username, "password": password]
return performRequestWithResult(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
}
/// Hit a protected endpoint without a token to get the 401.
static func getCurrentUserWithResult(token: String?) -> APIResult<TestUser> {
return performRequestWithResult(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
}
// MARK: - Residence CRUD
static func createResidence(token: String, name: String, fields: [String: Any] = [:]) -> TestResidence? {
var body: [String: Any] = ["name": name]
for (k, v) in fields { body[k] = v }
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
method: "POST", path: "/residences/", body: body, token: token,
responseType: TestWrappedResponse<TestResidence>.self
)
return wrapped?.data
}
static func listResidences(token: String) -> [TestResidence]? {
return performRequest(method: "GET", path: "/residences/", token: token, responseType: [TestResidence].self)
}
static func updateResidence(token: String, id: Int, fields: [String: Any]) -> TestResidence? {
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
method: "PUT", path: "/residences/\(id)/", body: fields, token: token,
responseType: TestWrappedResponse<TestResidence>.self
)
return wrapped?.data
}
static func deleteResidence(token: String, id: Int) -> Bool {
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
method: "DELETE", path: "/residences/\(id)/", token: token,
responseType: TestWrappedResponse<String>.self
)
return result.succeeded
}
// MARK: - Task CRUD
static func createTask(token: String, residenceId: Int, title: String, fields: [String: Any] = [:]) -> TestTask? {
var body: [String: Any] = ["residence_id": residenceId, "title": title]
for (k, v) in fields { body[k] = v }
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/", body: body, token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func listTasks(token: String) -> [TestTask]? {
return performRequest(method: "GET", path: "/tasks/", token: token, responseType: [TestTask].self)
}
static func listTasksByResidence(token: String, residenceId: Int) -> [TestTask]? {
return performRequest(
method: "GET", path: "/tasks/by-residence/\(residenceId)/", token: token,
responseType: [TestTask].self
)
}
static func updateTask(token: String, id: Int, fields: [String: Any]) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "PUT", path: "/tasks/\(id)/", body: fields, token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func deleteTask(token: String, id: Int) -> Bool {
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
method: "DELETE", path: "/tasks/\(id)/", token: token,
responseType: TestWrappedResponse<String>.self
)
return result.succeeded
}
static func markTaskInProgress(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/mark-in-progress/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func cancelTask(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/cancel/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
static func uncancelTask(token: String, id: Int) -> TestTask? {
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
method: "POST", path: "/tasks/\(id)/uncancel/", token: token,
responseType: TestWrappedResponse<TestTask>.self
)
return wrapped?.data
}
// MARK: - Contractor CRUD
static func createContractor(token: String, name: String, fields: [String: Any] = [:]) -> TestContractor? {
var body: [String: Any] = ["name": name]
for (k, v) in fields { body[k] = v }
return performRequest(method: "POST", path: "/contractors/", body: body, token: token, responseType: TestContractor.self)
}
static func listContractors(token: String) -> [TestContractor]? {
return performRequest(method: "GET", path: "/contractors/", token: token, responseType: [TestContractor].self)
}
static func updateContractor(token: String, id: Int, fields: [String: Any]) -> TestContractor? {
return performRequest(method: "PUT", path: "/contractors/\(id)/", body: fields, token: token, responseType: TestContractor.self)
}
static func deleteContractor(token: String, id: Int) -> Bool {
let result: APIResult<TestMessageResponse> = performRequestWithResult(
method: "DELETE", path: "/contractors/\(id)/", token: token,
responseType: TestMessageResponse.self
)
return result.succeeded
}
static func toggleContractorFavorite(token: String, id: Int) -> TestContractor? {
return performRequest(method: "POST", path: "/contractors/\(id)/toggle-favorite/", token: token, responseType: TestContractor.self)
}
// MARK: - Document CRUD
static func createDocument(token: String, residenceId: Int, title: String, documentType: String = "general", fields: [String: Any] = [:]) -> TestDocument? {
var body: [String: Any] = ["residence_id": residenceId, "title": title, "document_type": documentType]
for (k, v) in fields { body[k] = v }
return performRequest(method: "POST", path: "/documents/", body: body, token: token, responseType: TestDocument.self)
}
static func listDocuments(token: String) -> [TestDocument]? {
return performRequest(method: "GET", path: "/documents/", token: token, responseType: [TestDocument].self)
}
static func updateDocument(token: String, id: Int, fields: [String: Any]) -> TestDocument? {
return performRequest(method: "PUT", path: "/documents/\(id)/", body: fields, token: token, responseType: TestDocument.self)
}
static func deleteDocument(token: String, id: Int) -> Bool {
let result: APIResult<TestMessageResponse> = performRequestWithResult(
method: "DELETE", path: "/documents/\(id)/", token: token,
responseType: TestMessageResponse.self
)
return result.succeeded
}
// MARK: - Residence Sharing
static func generateShareCode(token: String, residenceId: Int) -> TestShareCode? {
let wrapped: TestGenerateShareCodeResponse? = performRequest(
method: "POST", path: "/residences/\(residenceId)/generate-share-code/",
body: [:], token: token,
responseType: TestGenerateShareCodeResponse.self
)
return wrapped?.shareCode
}
static func getShareCode(token: String, residenceId: Int) -> TestShareCode? {
let wrapped: TestGetShareCodeResponse? = performRequest(
method: "GET", path: "/residences/\(residenceId)/share-code/",
token: token, responseType: TestGetShareCodeResponse.self
)
return wrapped?.shareCode
}
static func joinWithCode(token: String, code: String) -> TestJoinResidenceResponse? {
let body: [String: Any] = ["code": code]
return performRequest(
method: "POST", path: "/residences/join-with-code/",
body: body, token: token,
responseType: TestJoinResidenceResponse.self
)
}
static func removeUser(token: String, residenceId: Int, userId: Int) -> Bool {
let result: APIResult<TestMessageResponse> = performRequestWithResult(
method: "DELETE", path: "/residences/\(residenceId)/users/\(userId)/",
token: token, responseType: TestMessageResponse.self
)
return result.succeeded
}
static func listResidenceUsers(token: String, residenceId: Int) -> [TestResidenceUser]? {
return performRequest(
method: "GET", path: "/residences/\(residenceId)/users/",
token: token, responseType: [TestResidenceUser].self
)
}
// MARK: - Raw Request (for custom/edge-case assertions)
/// Make a raw request and return the full APIResult with status code.
static func rawRequest(method: String, path: String, body: [String: Any]? = nil, token: String? = nil) -> APIResult<Data> {
guard let url = URL(string: "\(baseURL)\(path)") else {
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL")
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let semaphore = DispatchSemaphore(value: 0)
var result = APIResult<Data>(data: nil, statusCode: 0, errorBody: "No response")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) }
if (200...299).contains(status) {
result = APIResult(data: data, statusCode: status, errorBody: nil)
} else {
result = APIResult(data: nil, statusCode: status, errorBody: bodyStr)
}
}
task.resume()
semaphore.wait()
return result
}
// MARK: - Reachability
static func isBackendReachable() -> Bool {
let result = rawRequest(method: "POST", path: "/auth/login/", body: [:])
// Any HTTP response (even 400) means the backend is up
return result.statusCode > 0
}
// MARK: - Private Core
/// Perform a request and return the decoded value, or nil on failure (logs errors).
private static func performRequest<T: Decodable>(
method: String,
path: String,
body: [String: Any]? = nil,
token: String? = nil,
responseType: T.Type
) -> T? {
let result = performRequestWithResult(method: method, path: path, body: body, token: token, responseType: responseType)
return result.data
}
/// Perform a request and return the full APIResult with status code.
static func performRequestWithResult<T: Decodable>(
method: String,
path: String,
body: [String: Any]? = nil,
token: String? = nil,
responseType: T.Type
) -> APIResult<T> {
guard let url = URL(string: "\(baseURL)\(path)") else {
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL: \(baseURL)\(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let semaphore = DispatchSemaphore(value: 0)
var result = APIResult<T>(data: nil, statusCode: 0, errorBody: "No response")
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("[TestAPI] \(method) \(path) error: \(error.localizedDescription)")
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
return
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data = data else {
print("[TestAPI] \(method) \(path) no data (status \(statusCode))")
result = APIResult(data: nil, statusCode: statusCode, errorBody: "No data")
return
}
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
guard (200...299).contains(statusCode) else {
print("[TestAPI] \(method) \(path) status \(statusCode): \(bodyStr)")
result = APIResult(data: nil, statusCode: statusCode, errorBody: bodyStr)
return
}
do {
let decoded = try JSONDecoder().decode(T.self, from: data)
result = APIResult(data: decoded, statusCode: statusCode, errorBody: nil)
} catch {
print("[TestAPI] \(method) \(path) decode error: \(error)\nBody: \(bodyStr)")
result = APIResult(data: nil, statusCode: statusCode, errorBody: "Decode error: \(error)")
}
}
task.resume()
let waitResult = semaphore.wait(timeout: .now() + 30)
if waitResult == .timedOut {
print("[TestAPI] \(method) \(path) TIMEOUT after 30s")
task.cancel()
return APIResult(data: nil, statusCode: 0, errorBody: "Request timed out after 30s")
}
return result
}
}