Add comprehensive iOS unit and UI test suites for greenfield test plan
- Create unit tests: DataLayerTests (27 tests for DATA-001–007), DataManagerExtendedTests (20 tests for TASK-005, TASK-012, TCOMP-003, THEME-001, QA-002), plus ValidationHelpers, TaskMetrics, StringExtensions, DoubleExtensions, DateUtils, DocumentHelpers, ErrorMessageParser - Create UI tests: AuthenticationTests, PasswordResetTests, OnboardingTests, TaskIntegration, ContractorIntegration, ResidenceIntegration, DocumentIntegration, DataLayer, Stability - Add UI test framework: AuthenticatedTestCase, ScreenObjects, TestFlows, TestAccountManager, TestAccountAPIClient, TestDataCleaner, TestDataSeeder - Add accessibility identifiers to password reset views for UI test support - Add greenfield test plan CSVs and update automated column for 27 test IDs - All 297 unit tests pass across 60 suites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
159
iosApp/CaseraUITests/Framework/AuthenticatedTestCase.swift
Normal file
159
iosApp/CaseraUITests/Framework/AuthenticatedTestCase.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import XCTest
|
||||
|
||||
/// Base class for tests requiring a logged-in session against the real local backend.
|
||||
///
|
||||
/// By default, creates a fresh verified account via the API, launches the app
|
||||
/// (without `--ui-test-mock-auth`), and drives the UI through login.
|
||||
///
|
||||
/// Override `useSeededAccount` to log in with a pre-existing database account instead.
|
||||
/// Override `performUILogin` to skip the UI login step (if you only need the API session).
|
||||
///
|
||||
/// ## Data Seeding & Cleanup
|
||||
/// Use the `cleaner` property to seed data that auto-cleans in tearDown:
|
||||
/// ```
|
||||
/// let residence = cleaner.seedResidence(name: "My Test Home")
|
||||
/// let task = cleaner.seedTask(residenceId: residence.id)
|
||||
/// ```
|
||||
/// Or seed without tracking via `TestDataSeeder` and track manually:
|
||||
/// ```
|
||||
/// let res = TestDataSeeder.createResidence(token: session.token)
|
||||
/// cleaner.trackResidence(res.id)
|
||||
/// ```
|
||||
class AuthenticatedTestCase: BaseUITestCase {
|
||||
|
||||
/// The active test session, populated during setUp.
|
||||
var session: TestSession!
|
||||
|
||||
/// Tracks and cleans up resources created during the test.
|
||||
/// Initialized in setUp after the session is established.
|
||||
private(set) var cleaner: TestDataCleaner!
|
||||
|
||||
/// Override to `true` in subclasses that should use the pre-seeded admin account.
|
||||
var useSeededAccount: Bool { false }
|
||||
|
||||
/// Seeded account credentials. Override in subclasses that use a different seeded user.
|
||||
var seededUsername: String { "admin" }
|
||||
var seededPassword: String { "test1234" }
|
||||
|
||||
/// Override to `false` to skip driving the app through the login UI.
|
||||
var performUILogin: Bool { true }
|
||||
|
||||
/// No mock auth - we're testing against the real backend.
|
||||
override var additionalLaunchArguments: [String] { [] }
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Check backend reachability before anything else
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
|
||||
// Create or login account via API
|
||||
if useSeededAccount {
|
||||
guard let s = TestAccountManager.loginSeededAccount(
|
||||
username: seededUsername,
|
||||
password: seededPassword
|
||||
) else {
|
||||
throw XCTSkip("Could not login seeded account '\(seededUsername)'")
|
||||
}
|
||||
session = s
|
||||
} else {
|
||||
guard let s = TestAccountManager.createVerifiedAccount() else {
|
||||
throw XCTSkip("Could not create verified test account")
|
||||
}
|
||||
session = s
|
||||
}
|
||||
|
||||
// Initialize the cleaner with the session token
|
||||
cleaner = TestDataCleaner(token: session.token)
|
||||
|
||||
// Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready)
|
||||
try super.setUpWithError()
|
||||
|
||||
// Drive the UI through login if needed
|
||||
if performUILogin {
|
||||
loginViaUI()
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Clean up all tracked test data
|
||||
cleaner?.cleanAll()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - UI Login
|
||||
|
||||
/// Navigate from onboarding welcome → login screen → type credentials → wait for main tabs.
|
||||
func loginViaUI() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.enterUsername(session.username)
|
||||
login.enterPassword(session.password)
|
||||
|
||||
// Tap the login button
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
|
||||
// Wait for either main tabs or verification screen
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists {
|
||||
return
|
||||
}
|
||||
// Check for email verification gate - if we hit it, enter the debug code
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
if verificationScreen.codeField.exists {
|
||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verificationScreen.submitCode()
|
||||
// Wait for main tabs after verification
|
||||
if mainTabs.waitForExistence(timeout: longTimeout) || tabBar.waitForExistence(timeout: 5) {
|
||||
return
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTFail("Failed to reach main app after login. Debug tree:\n\(app.debugDescription)")
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
func navigateToTab(_ tab: String) {
|
||||
let tabButton = app.buttons[tab]
|
||||
if tabButton.waitForExistence(timeout: defaultTimeout) {
|
||||
tabButton.forceTap()
|
||||
} else {
|
||||
// Fallback: search tab bar buttons by label
|
||||
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
||||
let byLabel = app.tabBars.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", label)
|
||||
).firstMatch
|
||||
byLabel.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
byLabel.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
func navigateToResidences() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||
}
|
||||
|
||||
func navigateToTasks() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.tasksTab)
|
||||
}
|
||||
|
||||
func navigateToContractors() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||
}
|
||||
|
||||
func navigateToDocuments() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||
}
|
||||
|
||||
func navigateToProfile() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.profileTab)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ struct UITestID {
|
||||
static let progressIndicator = "Onboarding.ProgressIndicator"
|
||||
}
|
||||
|
||||
struct PasswordReset {
|
||||
static let emailField = "PasswordReset.EmailField"
|
||||
static let sendCodeButton = "PasswordReset.SendCodeButton"
|
||||
static let backToLoginButton = "PasswordReset.BackToLoginButton"
|
||||
static let codeField = "PasswordReset.CodeField"
|
||||
static let verifyCodeButton = "PasswordReset.VerifyCodeButton"
|
||||
static let resendCodeButton = "PasswordReset.ResendCodeButton"
|
||||
static let newPasswordField = "PasswordReset.NewPasswordField"
|
||||
static let confirmPasswordField = "PasswordReset.ConfirmPasswordField"
|
||||
static let resetButton = "PasswordReset.ResetButton"
|
||||
static let returnToLoginButton = "PasswordReset.ReturnToLoginButton"
|
||||
}
|
||||
|
||||
struct Auth {
|
||||
static let usernameField = "Login.UsernameField"
|
||||
static let passwordField = "Login.PasswordField"
|
||||
@@ -275,3 +288,124 @@ struct RegisterScreen {
|
||||
cancelButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Reset Screens
|
||||
|
||||
struct ForgotPasswordScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var emailField: XCUIElement { app.textFields[UITestID.PasswordReset.emailField] }
|
||||
private var sendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.sendCodeButton] }
|
||||
private var backToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.backToLoginButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
// Wait for the email field or the "Forgot Password?" title
|
||||
let emailLoaded = emailField.waitForExistence(timeout: timeout)
|
||||
if !emailLoaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected forgot password screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterEmail(_ email: String) {
|
||||
emailField.waitUntilHittable(timeout: 10).tap()
|
||||
emailField.typeText(email)
|
||||
}
|
||||
|
||||
func tapSendCode() {
|
||||
sendCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapBackToLogin() {
|
||||
backToLoginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyResetCodeScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var codeField: XCUIElement { app.textFields[UITestID.PasswordReset.codeField] }
|
||||
private var verifyCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.verifyCodeButton] }
|
||||
private var resendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.resendCodeButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
let codeLoaded = codeField.waitForExistence(timeout: timeout)
|
||||
if !codeLoaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Check Your Email'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected verify reset code screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterCode(_ code: String) {
|
||||
codeField.waitUntilHittable(timeout: 10).tap()
|
||||
codeField.typeText(code)
|
||||
}
|
||||
|
||||
func tapVerify() {
|
||||
verifyCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapResendCode() {
|
||||
resendCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetPasswordScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// The new password field may be a SecureField or TextField depending on visibility toggle
|
||||
private var newPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.newPasswordField] }
|
||||
private var newPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.newPasswordField] }
|
||||
private var confirmPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.confirmPasswordField] }
|
||||
private var confirmPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.confirmPasswordField] }
|
||||
private var resetButton: XCUIElement { app.buttons[UITestID.PasswordReset.resetButton] }
|
||||
private var returnToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.returnToLoginButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
let loaded = newPasswordSecureField.waitForExistence(timeout: timeout)
|
||||
|| newPasswordVisibleField.waitForExistence(timeout: 3)
|
||||
if !loaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Set New Password'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected reset password screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterNewPassword(_ password: String) {
|
||||
if newPasswordSecureField.exists {
|
||||
newPasswordSecureField.waitUntilHittable(timeout: 10).tap()
|
||||
newPasswordSecureField.typeText(password)
|
||||
} else {
|
||||
newPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
|
||||
newPasswordVisibleField.typeText(password)
|
||||
}
|
||||
}
|
||||
|
||||
func enterConfirmPassword(_ password: String) {
|
||||
if confirmPasswordSecureField.exists {
|
||||
confirmPasswordSecureField.waitUntilHittable(timeout: 10).tap()
|
||||
confirmPasswordSecureField.typeText(password)
|
||||
} else {
|
||||
confirmPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
|
||||
confirmPasswordVisibleField.typeText(password)
|
||||
}
|
||||
}
|
||||
|
||||
func tapReset() {
|
||||
resetButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapReturnToLogin() {
|
||||
returnToLoginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
var isResetButtonEnabled: Bool {
|
||||
resetButton.waitForExistenceOrFail(timeout: 10)
|
||||
return resetButton.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
505
iosApp/CaseraUITests/Framework/TestAccountAPIClient.swift
Normal file
505
iosApp/CaseraUITests/Framework/TestAccountAPIClient.swift
Normal file
@@ -0,0 +1,505 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "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<TestMessageResponse> = 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<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()
|
||||
semaphore.wait()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
127
iosApp/CaseraUITests/Framework/TestAccountManager.swift
Normal file
127
iosApp/CaseraUITests/Framework/TestAccountManager.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// High-level account lifecycle management for UI tests.
|
||||
enum TestAccountManager {
|
||||
|
||||
// MARK: - Credential Generation
|
||||
|
||||
/// Generate unique credentials with a timestamp + random suffix to avoid collisions.
|
||||
static func uniqueCredentials(prefix: String = "uit") -> (username: String, email: String, password: String) {
|
||||
let stamp = Int(Date().timeIntervalSince1970)
|
||||
let random = Int.random(in: 1000...9999)
|
||||
let username = "\(prefix)_\(stamp)_\(random)"
|
||||
let email = "\(username)@test.example.com"
|
||||
let password = "Pass\(stamp)!"
|
||||
return (username, email, password)
|
||||
}
|
||||
|
||||
// MARK: - Account Creation
|
||||
|
||||
/// Create a verified account via the backend API. Returns a ready-to-use session.
|
||||
/// Calls `XCTFail` and returns nil if any step fails.
|
||||
static func createVerifiedAccount(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
let creds = uniqueCredentials()
|
||||
|
||||
guard let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: creds.username,
|
||||
email: creds.email,
|
||||
password: creds.password
|
||||
) else {
|
||||
XCTFail("Failed to create verified account for \(creds.username)", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/// Create an unverified account (register only, no email verification).
|
||||
/// Useful for testing the verification gate.
|
||||
static func createUnverifiedAccount(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
let creds = uniqueCredentials()
|
||||
|
||||
guard let response = TestAccountAPIClient.register(
|
||||
username: creds.username,
|
||||
email: creds.email,
|
||||
password: creds.password
|
||||
) else {
|
||||
XCTFail("Failed to register unverified account for \(creds.username)", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TestSession(
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Seeded Accounts
|
||||
|
||||
/// Login with a pre-seeded account that already exists in the database.
|
||||
static func loginSeededAccount(
|
||||
username: String = "admin",
|
||||
password: String = "test1234",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
guard let response = TestAccountAPIClient.login(username: username, password: password) else {
|
||||
XCTFail("Failed to login seeded account '\(username)'", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TestSession(
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Password Reset
|
||||
|
||||
/// Execute the full forgot→verify→reset cycle via the backend API.
|
||||
static func resetPassword(
|
||||
email: String,
|
||||
newPassword: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> Bool {
|
||||
guard TestAccountAPIClient.forgotPassword(email: email) != nil else {
|
||||
XCTFail("Forgot password request failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let verifyResponse = TestAccountAPIClient.verifyResetCode(email: email) else {
|
||||
XCTFail("Verify reset code failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard TestAccountAPIClient.resetPassword(resetToken: verifyResponse.resetToken, newPassword: newPassword) != nil else {
|
||||
XCTFail("Reset password failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Token Management
|
||||
|
||||
/// Invalidate a session token via the logout API.
|
||||
static func invalidateToken(
|
||||
_ session: TestSession,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if TestAccountAPIClient.logout(token: session.token) == nil {
|
||||
XCTFail("Failed to invalidate token for \(session.username)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
iosApp/CaseraUITests/Framework/TestDataCleaner.swift
Normal file
130
iosApp/CaseraUITests/Framework/TestDataCleaner.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Tracks and cleans up resources created during integration tests.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```
|
||||
/// let cleaner = TestDataCleaner(token: session.token)
|
||||
/// let residence = TestDataSeeder.createResidence(token: session.token)
|
||||
/// cleaner.trackResidence(residence.id)
|
||||
/// // ... test runs ...
|
||||
/// cleaner.cleanAll() // called in tearDown
|
||||
/// ```
|
||||
class TestDataCleaner {
|
||||
private let token: String
|
||||
private var residenceIds: [Int] = []
|
||||
private var taskIds: [Int] = []
|
||||
private var contractorIds: [Int] = []
|
||||
private var documentIds: [Int] = []
|
||||
|
||||
init(token: String) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
// MARK: - Track Resources
|
||||
|
||||
func trackResidence(_ id: Int) {
|
||||
residenceIds.append(id)
|
||||
}
|
||||
|
||||
func trackTask(_ id: Int) {
|
||||
taskIds.append(id)
|
||||
}
|
||||
|
||||
func trackContractor(_ id: Int) {
|
||||
contractorIds.append(id)
|
||||
}
|
||||
|
||||
func trackDocument(_ id: Int) {
|
||||
documentIds.append(id)
|
||||
}
|
||||
|
||||
// MARK: - Seed + Track (Convenience)
|
||||
|
||||
/// Create a residence and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedResidence(name: String? = nil) -> TestResidence {
|
||||
let residence = TestDataSeeder.createResidence(token: token, name: name)
|
||||
trackResidence(residence.id)
|
||||
return residence
|
||||
}
|
||||
|
||||
/// Create a task and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
|
||||
let task = TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
|
||||
trackTask(task.id)
|
||||
return task
|
||||
}
|
||||
|
||||
/// Create a contractor and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
|
||||
let contractor = TestDataSeeder.createContractor(token: token, name: name, fields: fields)
|
||||
trackContractor(contractor.id)
|
||||
return contractor
|
||||
}
|
||||
|
||||
/// Create a document and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "Other") -> TestDocument {
|
||||
let document = TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
|
||||
trackDocument(document.id)
|
||||
return document
|
||||
}
|
||||
|
||||
/// Create a residence with tasks, all tracked for cleanup.
|
||||
func seedResidenceWithTasks(residenceName: String? = nil, taskCount: Int = 3) -> (residence: TestResidence, tasks: [TestTask]) {
|
||||
let result = TestDataSeeder.createResidenceWithTasks(token: token, residenceName: residenceName, taskCount: taskCount)
|
||||
trackResidence(result.residence.id)
|
||||
result.tasks.forEach { trackTask($0.id) }
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a full residence with task, contractor, and document, all tracked.
|
||||
func seedFullResidence() -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
|
||||
let result = TestDataSeeder.createFullResidence(token: token)
|
||||
trackResidence(result.residence.id)
|
||||
trackTask(result.task.id)
|
||||
trackContractor(result.contractor.id)
|
||||
trackDocument(result.document.id)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Delete all tracked resources in reverse dependency order.
|
||||
/// Documents and tasks first (they depend on residences), then contractors, then residences.
|
||||
/// Failures are logged but don't fail the test — cleanup is best-effort.
|
||||
func cleanAll() {
|
||||
// Delete documents first (depend on residences)
|
||||
for id in documentIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteDocument(token: token, id: id)
|
||||
}
|
||||
documentIds.removeAll()
|
||||
|
||||
// Delete tasks (depend on residences)
|
||||
for id in taskIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteTask(token: token, id: id)
|
||||
}
|
||||
taskIds.removeAll()
|
||||
|
||||
// Delete contractors (independent, but clean before residences)
|
||||
for id in contractorIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteContractor(token: token, id: id)
|
||||
}
|
||||
contractorIds.removeAll()
|
||||
|
||||
// Delete residences last
|
||||
for id in residenceIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteResidence(token: token, id: id)
|
||||
}
|
||||
residenceIds.removeAll()
|
||||
}
|
||||
|
||||
/// Number of tracked resources (for debugging).
|
||||
var trackedCount: Int {
|
||||
residenceIds.count + taskIds.count + contractorIds.count + documentIds.count
|
||||
}
|
||||
}
|
||||
235
iosApp/CaseraUITests/Framework/TestDataSeeder.swift
Normal file
235
iosApp/CaseraUITests/Framework/TestDataSeeder.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Seeds backend data for integration tests via API calls.
|
||||
///
|
||||
/// All methods require a valid auth token from a `TestSession`.
|
||||
/// Created resources are tracked so `TestDataCleaner` can remove them in teardown.
|
||||
enum TestDataSeeder {
|
||||
|
||||
// MARK: - Residence Seeding
|
||||
|
||||
/// Create a residence with just a name. Returns the residence or fails the test.
|
||||
@discardableResult
|
||||
static func createResidence(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestResidence {
|
||||
let residenceName = name ?? "Test Residence \(uniqueSuffix())"
|
||||
guard let residence = TestAccountAPIClient.createResidence(token: token, name: residenceName) else {
|
||||
XCTFail("Failed to seed residence '\(residenceName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return residence
|
||||
}
|
||||
|
||||
/// Create a residence with address fields populated.
|
||||
@discardableResult
|
||||
static func createResidenceWithAddress(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
street: String = "123 Test St",
|
||||
city: String = "Testville",
|
||||
state: String = "TX",
|
||||
postalCode: String = "78701",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestResidence {
|
||||
let residenceName = name ?? "Addressed Residence \(uniqueSuffix())"
|
||||
guard let residence = TestAccountAPIClient.createResidence(
|
||||
token: token,
|
||||
name: residenceName,
|
||||
fields: [
|
||||
"street_address": street,
|
||||
"city": city,
|
||||
"state_province": state,
|
||||
"postal_code": postalCode
|
||||
]
|
||||
) else {
|
||||
XCTFail("Failed to seed residence with address '\(residenceName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return residence
|
||||
}
|
||||
|
||||
// MARK: - Task Seeding
|
||||
|
||||
/// Create a task in a residence. Returns the task or fails the test.
|
||||
@discardableResult
|
||||
static func createTask(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let taskTitle = title ?? "Test Task \(uniqueSuffix())"
|
||||
guard let task = TestAccountAPIClient.createTask(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: taskTitle,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed task '\(taskTitle)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
/// Create a task with a due date.
|
||||
@discardableResult
|
||||
static func createTaskWithDueDate(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
daysFromNow: Int = 7,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let dueDate = Calendar.current.date(byAdding: .day, value: daysFromNow, to: Date())!
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withFullDate]
|
||||
let dueDateStr = formatter.string(from: dueDate)
|
||||
|
||||
return createTask(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: title ?? "Due Task \(uniqueSuffix())",
|
||||
fields: ["due_date": dueDateStr],
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a cancelled task (create then cancel via API).
|
||||
@discardableResult
|
||||
static func createCancelledTask(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let task = createTask(token: token, residenceId: residenceId, title: title ?? "Cancelled Task \(uniqueSuffix())", file: file, line: line)
|
||||
guard let cancelled = TestAccountAPIClient.cancelTask(token: token, id: task.id) else {
|
||||
XCTFail("Failed to cancel seeded task \(task.id)", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// MARK: - Contractor Seeding
|
||||
|
||||
/// Create a contractor. Returns the contractor or fails the test.
|
||||
@discardableResult
|
||||
static func createContractor(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestContractor {
|
||||
let contractorName = name ?? "Test Contractor \(uniqueSuffix())"
|
||||
guard let contractor = TestAccountAPIClient.createContractor(
|
||||
token: token,
|
||||
name: contractorName,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed contractor '\(contractorName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return contractor
|
||||
}
|
||||
|
||||
/// Create a contractor with contact info.
|
||||
@discardableResult
|
||||
static func createContractorWithContact(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
company: String = "Test Co",
|
||||
phone: String = "555-0100",
|
||||
email: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestContractor {
|
||||
let contractorName = name ?? "Contact Contractor \(uniqueSuffix())"
|
||||
let contactEmail = email ?? "\(uniqueSuffix())@contractor.test"
|
||||
return createContractor(
|
||||
token: token,
|
||||
name: contractorName,
|
||||
fields: ["company": company, "phone": phone, "email": contactEmail],
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Document Seeding
|
||||
|
||||
/// Create a document in a residence. Returns the document or fails the test.
|
||||
@discardableResult
|
||||
static func createDocument(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
documentType: String = "Other",
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestDocument {
|
||||
let docTitle = title ?? "Test Doc \(uniqueSuffix())"
|
||||
guard let document = TestAccountAPIClient.createDocument(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: docTitle,
|
||||
documentType: documentType,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed document '\(docTitle)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
// MARK: - Composite Scenarios
|
||||
|
||||
/// Create a residence with N tasks already in it. Returns (residence, [tasks]).
|
||||
static func createResidenceWithTasks(
|
||||
token: String,
|
||||
residenceName: String? = nil,
|
||||
taskCount: Int = 3,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> (residence: TestResidence, tasks: [TestTask]) {
|
||||
let residence = createResidence(token: token, name: residenceName, file: file, line: line)
|
||||
var tasks: [TestTask] = []
|
||||
for i in 1...taskCount {
|
||||
let task = createTask(token: token, residenceId: residence.id, title: "Task \(i) \(uniqueSuffix())", file: file, line: line)
|
||||
tasks.append(task)
|
||||
}
|
||||
return (residence, tasks)
|
||||
}
|
||||
|
||||
/// Create a residence with a contractor and a document. Returns all three.
|
||||
static func createFullResidence(
|
||||
token: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
|
||||
let residence = createResidence(token: token, file: file, line: line)
|
||||
let task = createTask(token: token, residenceId: residence.id, file: file, line: line)
|
||||
let contractor = createContractor(token: token, file: file, line: line)
|
||||
let document = createDocument(token: token, residenceId: residence.id, file: file, line: line)
|
||||
return (residence, task, contractor, document)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func uniqueSuffix() -> String {
|
||||
let stamp = Int(Date().timeIntervalSince1970) % 100000
|
||||
let random = Int.random(in: 100...999)
|
||||
return "\(stamp)_\(random)"
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,47 @@ enum TestFlows {
|
||||
return createAccount
|
||||
}
|
||||
|
||||
/// Type credentials into the login screen and tap login.
|
||||
/// Assumes the app is already showing the login screen.
|
||||
static func loginWithCredentials(app: XCUIApplication, username: String, password: String) {
|
||||
let login = LoginScreen(app: app)
|
||||
login.waitForLoad()
|
||||
login.enterUsername(username)
|
||||
login.enterPassword(password)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
/// Drive the full forgot password → verify code → reset password flow using the debug code.
|
||||
static func completeForgotPasswordFlow(
|
||||
app: XCUIApplication,
|
||||
email: String,
|
||||
newPassword: String,
|
||||
confirmPassword: String? = nil
|
||||
) {
|
||||
let confirm = confirmPassword ?? newPassword
|
||||
|
||||
// Step 1: Enter email on forgot password screen
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Step 2: Enter debug verification code
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Step 3: Enter new password
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
resetScreen.waitForLoad()
|
||||
resetScreen.enterNewPassword(newPassword)
|
||||
resetScreen.enterConfirmPassword(confirm)
|
||||
resetScreen.tapReset()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func openRegisterFromLogin(app: XCUIApplication) -> RegisterScreen {
|
||||
let login: LoginScreen
|
||||
|
||||
Reference in New Issue
Block a user