import Foundation import XCTest // MARK: - API Result Type /// Result of an API call with status code access for error assertions. struct APIResult { 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: 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" } } // 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 { 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 { 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? = performRequest( method: "POST", path: "/residences/", body: body, token: token, responseType: TestWrappedResponse.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? = performRequest( method: "PUT", path: "/residences/\(id)/", body: fields, token: token, responseType: TestWrappedResponse.self ) return wrapped?.data } static func deleteResidence(token: String, id: Int) -> Bool { let result: APIResult> = performRequestWithResult( method: "DELETE", path: "/residences/\(id)/", token: token, responseType: TestWrappedResponse.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? = performRequest( method: "POST", path: "/tasks/", body: body, token: token, responseType: TestWrappedResponse.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? = performRequest( method: "PUT", path: "/tasks/\(id)/", body: fields, token: token, responseType: TestWrappedResponse.self ) return wrapped?.data } static func deleteTask(token: String, id: Int) -> Bool { let result: APIResult> = performRequestWithResult( method: "DELETE", path: "/tasks/\(id)/", token: token, responseType: TestWrappedResponse.self ) return result.succeeded } static func markTaskInProgress(token: String, id: Int) -> TestTask? { let wrapped: TestWrappedResponse? = performRequest( method: "POST", path: "/tasks/\(id)/mark-in-progress/", token: token, responseType: TestWrappedResponse.self ) return wrapped?.data } static func cancelTask(token: String, id: Int) -> TestTask? { let wrapped: TestWrappedResponse? = performRequest( method: "POST", path: "/tasks/\(id)/cancel/", token: token, responseType: TestWrappedResponse.self ) return wrapped?.data } static func uncancelTask(token: String, id: Int) -> TestTask? { let wrapped: TestWrappedResponse? = performRequest( method: "POST", path: "/tasks/\(id)/uncancel/", token: token, responseType: TestWrappedResponse.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 = 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 = "Other", 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 = performRequestWithResult( method: "DELETE", path: "/documents/\(id)/", token: token, responseType: TestMessageResponse.self ) return result.succeeded } // 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 { 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: 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( 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( method: String, path: String, body: [String: Any]? = nil, token: String? = nil, responseType: T.Type ) -> APIResult { 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(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) ?? "" 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() semaphore.wait() return result } }