c52ce4d497
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
907 lines
36 KiB
Swift
907 lines
36 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 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: - 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) } ?? "<nil>"
|
|
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) ?? "<binary>"
|
|
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) ?? "<binary>"
|
|
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) ?? "<binary>"
|
|
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) } ?? "<nil>"
|
|
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<TestAuthResponse> {
|
|
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<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, forHTTPHeaderField: "X-Session-Token")
|
|
}
|
|
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: - 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<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, forHTTPHeaderField: "X-Session-Token")
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|