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 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" } } 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: - Kratos Configuration /// Kratos public API (self-service login/registration flows). static let kratosPublicURL = "http://127.0.0.1:4433" /// Kratos admin API (create pre-verified identities directly). static let kratosAdminURL = "http://127.0.0.1:4434" /// Identity schema id registered in Kratos for this app. static let kratosSchemaID = "honeydue" // MARK: - Kratos Auth Primitives /// Create a Kratos identity via the ADMIN API. /// When `verified` is true the email's verifiable address is marked /// completed/verified; when false it is left pending/unverified (mirrors a /// freshly-registered account that has not confirmed its email yet). /// Returns true on 201 (created) or 409 (already exists — idempotent). static func createKratosIdentity(email: String, password: String, firstName: String, lastName: String, verified: Bool = true) -> Bool { guard let url = URL(string: "\(kratosAdminURL)/admin/identities") else { return false } let verifiableAddress: [String: Any] = verified ? ["value": email, "verified": true, "via": "email", "status": "completed"] : ["value": email, "verified": false, "via": "email", "status": "pending"] let body: [String: Any] = [ "schema_id": kratosSchemaID, "traits": [ "email": email, "name": ["first": firstName, "last": lastName] ], "credentials": [ "password": ["config": ["password": password]] ], "verifiable_addresses": [verifiableAddress], "state": "active" ] var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = 15 request.httpBody = try? JSONSerialization.data(withJSONObject: body) let semaphore = DispatchSemaphore(value: 0) var success = false let task = URLSession.shared.dataTask(with: request) { data, response, error in defer { semaphore.signal() } if let error = error { print("[Kratos] createIdentity error: \(error.localizedDescription)") return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 // 201 = created, 409 = already exists (idempotent success) if status == 201 || status == 409 { success = true } else { let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" print("[Kratos] createIdentity status \(status): \(bodyStr)") } } task.resume() if semaphore.wait(timeout: .now() + 30) == .timedOut { print("[Kratos] createIdentity TIMEOUT") task.cancel() return false } return success } /// Perform a Kratos self-service login (API flow) and return the session token, or nil. static func kratosLogin(email: String, password: String) -> String? { // Step 1: GET the login flow to discover the action URL. guard let flowURL = URL(string: "\(kratosPublicURL)/self-service/login/api") else { return nil } var flowRequest = URLRequest(url: flowURL) flowRequest.httpMethod = "GET" flowRequest.setValue("application/json", forHTTPHeaderField: "Accept") flowRequest.timeoutInterval = 15 let flowSemaphore = DispatchSemaphore(value: 0) var actionURLString: String? let flowTask = URLSession.shared.dataTask(with: flowRequest) { data, response, error in defer { flowSemaphore.signal() } if let error = error { print("[Kratos] login flow error: \(error.localizedDescription)") return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard let data = data else { print("[Kratos] login flow no data (status \(status))") return } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let ui = json["ui"] as? [String: Any], let action = ui["action"] as? String else { let bodyStr = String(data: data, encoding: .utf8) ?? "" print("[Kratos] login flow parse failed (status \(status)): \(bodyStr)") return } actionURLString = action } flowTask.resume() if flowSemaphore.wait(timeout: .now() + 30) == .timedOut { print("[Kratos] login flow TIMEOUT") flowTask.cancel() return nil } guard let actionURLString = actionURLString, let actionURL = URL(string: actionURLString) else { return nil } // Step 2: POST credentials to the action URL to obtain a session token. let body: [String: Any] = [ "method": "password", "identifier": email, "password": password ] var loginRequest = URLRequest(url: actionURL) loginRequest.httpMethod = "POST" loginRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") loginRequest.setValue("application/json", forHTTPHeaderField: "Accept") loginRequest.timeoutInterval = 15 loginRequest.httpBody = try? JSONSerialization.data(withJSONObject: body) let loginSemaphore = DispatchSemaphore(value: 0) var sessionToken: String? let loginTask = URLSession.shared.dataTask(with: loginRequest) { data, response, error in defer { loginSemaphore.signal() } if let error = error { print("[Kratos] login error: \(error.localizedDescription)") return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard let data = data else { print("[Kratos] login no data (status \(status))") return } // Kratos returns 200 on success, 400 on bad credentials. guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = json["session_token"] as? String else { let bodyStr = String(data: data, encoding: .utf8) ?? "" print("[Kratos] login no session_token (status \(status)): \(bodyStr)") return } sessionToken = token } loginTask.resume() if loginSemaphore.wait(timeout: .now() + 30) == .timedOut { print("[Kratos] login TIMEOUT") loginTask.cancel() return nil } return sessionToken } // MARK: - Auth Methods /// Log in via Kratos. The `username` parameter is treated as the Kratos /// identifier — i.e. the account EMAIL. Returns a TestAuthResponse carrying /// the Kratos session token and the provisioned API user, or nil on failure. static func login(username: String, password: String) -> TestAuthResponse? { guard let token = kratosLogin(email: username, password: password) else { return nil } guard let user = getCurrentUser(token: token) else { return nil } return TestAuthResponse(token: token, user: user, message: nil) } static func getCurrentUser(token: String) -> TestUser? { return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self) } /// Convenience: provision a pre-verified Kratos identity, log in, and fetch /// the provisioned API user. Returns a ready-to-use session, or nil on failure. /// /// `username` is used as the identity's first name (and retained on the /// returned session for reference); the Kratos identifier is the `email`. static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? { guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test") else { return nil } guard let token = kratosLogin(email: email, password: password) else { return nil } guard let user = getCurrentUser(token: token) else { return nil } return TestSession(token: token, user: user, username: username, password: password) } /// Convenience: provision an UNVERIFIED Kratos identity (no email confirmed), /// log in, and fetch the lazily-provisioned API user. Mirrors /// `createVerifiedAccount` but leaves the email address unverified so callers /// can exercise the verification gate. Returns a ready-to-use session, or nil. static func createUnverifiedAccount(username: String, email: String, password: String) -> TestSession? { guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test", verified: false) else { return nil } guard let token = kratosLogin(email: email, password: password) else { return nil } guard let user = getCurrentUser(token: token) else { return nil } return TestSession(token: token, user: user, username: username, password: password) } /// Delete a Kratos identity by its login email via the ADMIN API (true teardown). /// Looks up the identity by `credentials_identifier`, then DELETEs it. /// Returns true if the identity was deleted (204) OR no identity exists /// (already gone — idempotent success). Returns false only on a real failure. static func deleteKratosIdentity(email: String) -> Bool { let encoded = email.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? email guard let lookupURL = URL(string: "\(kratosAdminURL)/admin/identities?credentials_identifier=\(encoded)") else { print("[Kratos] deleteIdentity invalid lookup URL for \(email)") return false } // Step 1: find the identity id by email. var lookupRequest = URLRequest(url: lookupURL) lookupRequest.httpMethod = "GET" lookupRequest.setValue("application/json", forHTTPHeaderField: "Accept") lookupRequest.timeoutInterval = 15 let lookupSemaphore = DispatchSemaphore(value: 0) var identityID: String? var lookupFound = false let lookupTask = URLSession.shared.dataTask(with: lookupRequest) { data, response, error in defer { lookupSemaphore.signal() } if let error = error { print("[Kratos] deleteIdentity lookup error: \(error.localizedDescription)") return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 guard let data = data else { print("[Kratos] deleteIdentity lookup no data (status \(status))") return } guard let identities = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { let bodyStr = String(data: data, encoding: .utf8) ?? "" print("[Kratos] deleteIdentity lookup parse failed (status \(status)): \(bodyStr)") return } lookupFound = true identityID = identities.first?["id"] as? String } lookupTask.resume() if lookupSemaphore.wait(timeout: .now() + 30) == .timedOut { print("[Kratos] deleteIdentity lookup TIMEOUT") lookupTask.cancel() return false } // No identity found (empty array) — already gone, idempotent success. guard let id = identityID else { return lookupFound } // Step 2: DELETE the identity. guard let deleteURL = URL(string: "\(kratosAdminURL)/admin/identities/\(id)") else { print("[Kratos] deleteIdentity invalid delete URL for id \(id)") return false } var deleteRequest = URLRequest(url: deleteURL) deleteRequest.httpMethod = "DELETE" deleteRequest.setValue("application/json", forHTTPHeaderField: "Accept") deleteRequest.timeoutInterval = 15 let deleteSemaphore = DispatchSemaphore(value: 0) var success = false let deleteTask = URLSession.shared.dataTask(with: deleteRequest) { data, response, error in defer { deleteSemaphore.signal() } if let error = error { print("[Kratos] deleteIdentity error: \(error.localizedDescription)") return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 // 204 = deleted, 404 = already gone (idempotent success). if status == 204 || status == 404 { success = true } else { let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" print("[Kratos] deleteIdentity status \(status): \(bodyStr)") } } deleteTask.resume() if deleteSemaphore.wait(timeout: .now() + 30) == .timedOut { print("[Kratos] deleteIdentity TIMEOUT") deleteTask.cancel() return false } return success } // MARK: - Auth with Status Code /// Login returning full APIResult so callers can assert on success/failure. /// `username` is treated as the Kratos identifier (the EMAIL). On a failed /// Kratos login (Kratos returns 400 on bad creds) this maps to statusCode 401 /// so negative-path assertions that expect an unauthorized result still hold. static func loginWithResult(username: String, password: String) -> APIResult { guard let token = kratosLogin(email: username, password: password) else { return APIResult(data: nil, statusCode: 401, errorBody: "Kratos login failed") } guard let user = getCurrentUser(token: token) else { return APIResult(data: nil, statusCode: 401, errorBody: "Failed to fetch current user after login") } let response = TestAuthResponse(token: token, user: user, message: nil) return APIResult(data: response, statusCode: 200, errorBody: nil) } /// 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 = "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 = 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 = 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 { 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, forHTTPHeaderField: "X-Session-Token") } 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: - Mailpit (real email verification codes) /// Mailpit web/API base for the local stack. static let mailpitURL = "http://127.0.0.1:8025" /// Fetch the most recent 6-digit verification code Kratos emailed to `email`. /// The app's onboarding registration uses Kratos's real verification flow /// (not the API's DEBUG fixed code), so onboarding tests must read the live /// code from Mailpit. Polls briefly because the email lands asynchronously. static func latestVerificationCode(for email: String, timeout: TimeInterval = 15) -> String? { let deadline = Date().addingTimeInterval(timeout) let lowered = email.lowercased() while Date() < deadline { if let code = fetchLatestCodeOnce(for: lowered) { return code } Thread.sleep(forTimeInterval: 1.0) } return nil } private static func fetchLatestCodeOnce(for loweredEmail: String) -> String? { guard let url = URL(string: "\(mailpitURL)/api/v1/search?query=to:\(loweredEmail)&limit=5") else { return nil } var request = URLRequest(url: url) request.timeoutInterval = 10 request.setValue("application/json", forHTTPHeaderField: "Accept") let semaphore = DispatchSemaphore(value: 0) var messageID: String? let task = URLSession.shared.dataTask(with: request) { data, _, _ in defer { semaphore.signal() } guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let messages = json["messages"] as? [[String: Any]] else { return } // Messages are newest-first; pick the first addressed to this email. for m in messages { let tos = (m["To"] as? [[String: Any]])?.compactMap { ($0["Address"] as? String)?.lowercased() } ?? [] if tos.contains(loweredEmail) { messageID = m["ID"] as? String break } } } task.resume() _ = semaphore.wait(timeout: .now() + 15) guard let id = messageID else { return nil } return extractCode(messageID: id) } private static func extractCode(messageID: String) -> String? { guard let url = URL(string: "\(mailpitURL)/api/v1/message/\(messageID)") else { return nil } var request = URLRequest(url: url) request.timeoutInterval = 10 request.setValue("application/json", forHTTPHeaderField: "Accept") let semaphore = DispatchSemaphore(value: 0) var code: String? let task = URLSession.shared.dataTask(with: request) { data, _, _ in defer { semaphore.signal() } guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } let text = (json["Text"] as? String ?? "") + " " + (json["HTML"] as? String ?? "") // The Kratos verification email presents a standalone 6-digit code. if let range = text.range(of: "\\b\\d{6}\\b", options: .regularExpression) { code = String(text[range]) } } task.resume() _ = semaphore.wait(timeout: .now() + 15) return code } // MARK: - Reachability static func isBackendReachable() -> Bool { // Probe a live endpoint with no token. The backend returns 401 // (unauthenticated) when it's up — any HTTP response means reachable. let result = rawRequest(method: "GET", path: "/auth/me/") // statusCode 0 means the connection failed; anything else (incl. 401) 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, forHTTPHeaderField: "X-Session-Token") } 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() 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 } }