Rebrand from Casera/MyCrib to honeyDue

Total rebrand across KMM project:
- Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations)
- Gradle: rootProject.name, namespace, applicationId
- Android: manifest, strings.xml (all languages), widget resources
- iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig
- iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc.
- Swift source: all class/struct/enum renames
- Deep links: casera:// -> honeydue://, .casera -> .honeydue
- App icons replaced with honeyDue honeycomb icon
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- Database table names preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-07 06:33:57 -06:00
parent 9c574c4343
commit 1e2adf7660
450 changed files with 1730 additions and 1788 deletions

View 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)
}
}

View File

@@ -0,0 +1,124 @@
import XCTest
class BaseUITestCase: XCTestCase {
let app = XCUIApplication()
let shortTimeout: TimeInterval = 5
let defaultTimeout: TimeInterval = 15
let longTimeout: TimeInterval = 30
var includeResetStateLaunchArgument: Bool { true }
var additionalLaunchArguments: [String] { [] }
override func setUpWithError() throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
var launchArguments = [
"--ui-testing",
"--disable-animations"
]
if includeResetStateLaunchArgument {
launchArguments.append("--reset-state")
}
launchArguments.append(contentsOf: additionalLaunchArguments)
app.launchArguments = launchArguments
app.launch()
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout)
}
override func tearDownWithError() throws {
if let run = testRun, !run.hasSucceeded {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Failure-\(name)"
attachment.lifetime = .keepAlways
add(attachment)
}
}
}
extension XCUIElement {
@discardableResult
func waitForExistenceOrFail(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
if !waitForExistence(timeout: timeout) {
XCTFail(message ?? "Expected element to exist: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitUntilHittable(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> XCUIElement {
let predicate = NSPredicate(format: "exists == true AND hittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to become hittable: \(self)", file: file, line: line)
}
return self
}
@discardableResult
func waitForNonExistence(
timeout: TimeInterval,
message: String? = nil,
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
if result != .completed {
XCTFail(message ?? "Expected element to disappear: \(self)", file: file, line: line)
return false
}
return true
}
func scrollIntoView(
in scrollView: XCUIElement,
maxSwipes: Int = 8,
file: StaticString = #filePath,
line: UInt = #line
) {
if isHittable { return }
for _ in 0..<maxSwipes {
scrollView.swipeUp()
if isHittable { return }
}
for _ in 0..<maxSwipes {
scrollView.swipeDown()
if isHittable { return }
}
XCTFail("Failed to scroll element into view: \(self)", file: file, line: line)
}
func forceTap(file: StaticString = #filePath, line: UInt = #line) {
if isHittable {
tap()
return
}
if exists {
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
return
}
XCTFail("Expected element to exist before forceTap: \(self)", file: file, line: line)
}
}

View File

@@ -0,0 +1,183 @@
import XCTest
struct RebuildTestUser {
let username: String
let email: String
let password: String
}
enum RebuildTestUserFactory {
static func unique(prefix: String = "uit") -> RebuildTestUser {
let stamp = Int(Date().timeIntervalSince1970)
return RebuildTestUser(
username: "\(prefix)_user_\(stamp)",
email: "\(prefix)_\(stamp)@example.com",
password: "Pass1234"
)
}
static var seeded: RebuildTestUser {
RebuildTestUser(username: "testuser", email: "test@example.com", password: "TestPass123!")
}
}
struct VerificationScreen {
let app: XCUIApplication
private var authCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] }
private var onboardingCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] }
private var authVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Authentication.verifyButton] }
private var onboardingVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] }
var codeField: XCUIElement {
if authCodeField.exists { return authCodeField }
return onboardingCodeField
}
var verifyButton: XCUIElement {
if authVerifyButton.exists { return authVerifyButton }
if onboardingVerifyButton.exists { return onboardingVerifyButton }
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
}
func waitForLoad(timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let loaded = authCodeField.waitForExistence(timeout: timeout)
|| onboardingCodeField.waitForExistence(timeout: timeout)
|| authVerifyButton.waitForExistence(timeout: timeout)
|| onboardingVerifyButton.waitForExistence(timeout: timeout)
XCTAssertTrue(loaded, "Expected verification screen to load", file: file, line: line)
}
func enterCode(_ code: String) {
codeField.waitForExistenceOrFail(timeout: 10)
codeField.forceTap()
codeField.typeText(code)
}
func submitCode() {
verifyButton.waitForExistenceOrFail(timeout: 10)
verifyButton.forceTap()
}
func tapLogoutIfAvailable() {
let logout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
if logout.waitForExistence(timeout: 3) {
logout.forceTap()
}
}
}
struct MainTabScreenObject {
let app: XCUIApplication
var tabBar: XCUIElement { app.tabBars.firstMatch }
var mainRoot: XCUIElement { app.otherElements[UITestID.Root.mainTabs] }
var residencesTab: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
if byID.exists { return byID }
return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
}
var profileTab: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Navigation.profileTab]
if byID.exists { return byID }
return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
}
func waitForLoad(timeout: TimeInterval = 15) {
let loaded = mainRoot.waitForExistence(timeout: timeout)
|| tabBar.waitForExistence(timeout: timeout)
XCTAssertTrue(loaded, "Expected main app root to appear")
}
func goToResidences() {
residencesTab.waitForExistenceOrFail(timeout: 10)
residencesTab.forceTap()
}
func goToProfile() {
profileTab.waitForExistenceOrFail(timeout: 10)
profileTab.forceTap()
}
}
struct ResidenceListScreen {
let app: XCUIApplication
var addButton: XCUIElement {
let byID = app.buttons[AccessibilityIdentifiers.Residence.addButton]
if byID.exists { return byID }
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
}
var list: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.residencesList] }
var emptyState: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.emptyStateView] }
var residenceCard: XCUIElement { app.otherElements.matching(identifier: AccessibilityIdentifiers.Residence.residenceCard).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
let deadline = Date().addingTimeInterval(timeout)
var loaded = false
repeat {
loaded = list.exists
|| emptyState.exists
|| residenceCard.exists
|| addButton.exists
|| app.staticTexts["Residences"].exists
if loaded { break }
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
} while Date() < deadline
XCTAssertTrue(loaded, "Expected residences list screen to load")
}
func openCreateResidence() {
addButton.waitForExistenceOrFail(timeout: 10)
addButton.forceTap()
}
}
struct ResidenceFormScreen {
let app: XCUIApplication
var nameField: XCUIElement { app.textFields[AccessibilityIdentifiers.Residence.nameField] }
var saveButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.saveButton] }
var cancelButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.formCancelButton] }
func waitForLoad(timeout: TimeInterval = 15) {
XCTAssertTrue(nameField.waitForExistence(timeout: timeout), "Expected residence form")
}
func enterName(_ value: String) {
nameField.waitForExistenceOrFail(timeout: 10)
nameField.forceTap()
nameField.typeText(value)
}
func save() { saveButton.waitForExistenceOrFail(timeout: 10); saveButton.forceTap() }
func cancel() { cancelButton.waitForExistenceOrFail(timeout: 10); cancelButton.forceTap() }
}
enum RebuildSessionAssertions {
static func assertOnLogin(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: timeout)
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Expected login state", file: file, line: line)
}
static func assertOnMainApp(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let main = MainTabScreenObject(app: app)
main.waitForLoad(timeout: timeout)
XCTAssertTrue(
app.otherElements[UITestID.Root.mainTabs].exists || main.tabBar.exists,
"Expected main app state",
file: file,
line: line
)
}
static func assertOnVerification(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) {
let verify = VerificationScreen(app: app)
verify.waitForLoad(timeout: timeout, file: file, line: line)
}
}

View File

@@ -0,0 +1,411 @@
import XCTest
struct UITestID {
struct Root {
static let ready = "ui.app.ready"
static let onboarding = "ui.root.onboarding"
static let login = "ui.root.login"
static let mainTabs = "ui.root.mainTabs"
}
struct Onboarding {
static let welcomeTitle = "Onboarding.WelcomeTitle"
static let startFreshButton = "Onboarding.StartFreshButton"
static let joinExistingButton = "Onboarding.JoinExistingButton"
static let loginButton = "Onboarding.LoginButton"
static let valuePropsContainer = "Onboarding.ValuePropsTitle"
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
static let residenceNameField = "Onboarding.ResidenceNameField"
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
static let createAccountTitle = "Onboarding.CreateAccountTitle"
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
static let createAccountButton = "Onboarding.CreateAccountButton"
static let backButton = "Onboarding.BackButton"
static let skipButton = "Onboarding.SkipButton"
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"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let loginButton = "Login.LoginButton"
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let registerUsernameField = "Register.UsernameField"
static let registerEmailField = "Register.EmailField"
static let registerPasswordField = "Register.PasswordField"
static let registerConfirmPasswordField = "Register.ConfirmPasswordField"
static let registerButton = "Register.RegisterButton"
static let registerCancelButton = "Register.CancelButton"
}
}
struct RootScreen {
let app: XCUIApplication
func waitForReady(timeout: TimeInterval = 15) {
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: timeout)
}
}
struct OnboardingWelcomeScreen {
let app: XCUIApplication
private var onboardingRoot: XCUIElement { app.otherElements[UITestID.Root.onboarding] }
private var startFreshButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch }
private var joinExistingButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.joinExistingButton).firstMatch }
private var loginButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.loginButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
onboardingRoot.waitForExistenceOrFail(timeout: timeout)
if startFreshButton.waitForExistence(timeout: 2) {
return
}
for _ in 0..<4 {
if backButton.exists && backButton.isHittable {
backButton.tap()
}
if startFreshButton.waitForExistence(timeout: 2) {
return
}
}
if !startFreshButton.waitForExistence(timeout: timeout) {
XCTFail("Expected onboarding welcome entry point. Debug tree:\n\(app.debugDescription)")
}
}
func tapStartFresh() {
startFreshButton.waitUntilHittable(timeout: 10).tap()
}
func tapJoinExisting() {
joinExistingButton.waitUntilHittable(timeout: 10).tap()
}
func tapAlreadyHaveAccount() {
loginButton.waitForExistenceOrFail(timeout: 10)
if loginButton.isHittable {
loginButton.tap()
} else {
loginButton.forceTap()
}
}
}
struct OnboardingValuePropsScreen {
let app: XCUIApplication
private var container: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch }
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsNextButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
container.waitForExistenceOrFail(timeout: timeout)
}
func tapContinue() {
continueButton.waitUntilHittable(timeout: 10).tap()
}
func tapBack() {
backButton.waitForExistenceOrFail(timeout: 10)
backButton.forceTap()
}
}
struct OnboardingNameResidenceScreen {
let app: XCUIApplication
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch }
private var nameField: XCUIElement { app.textFields[UITestID.Onboarding.residenceNameField] }
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceContinueButton).firstMatch }
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
title.waitForExistenceOrFail(timeout: timeout)
}
func enterResidenceName(_ value: String) {
nameField.waitUntilHittable(timeout: 10).tap()
nameField.typeText(value)
}
func tapContinue() {
continueButton.waitUntilHittable(timeout: 10).tap()
}
func tapBack() {
backButton.waitForExistenceOrFail(timeout: 10)
backButton.forceTap()
}
}
struct OnboardingCreateAccountScreen {
let app: XCUIApplication
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountTitle).firstMatch }
private var expandEmailButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.emailSignUpExpandButton).firstMatch }
private var createAccountButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch }
func waitForLoad(timeout: TimeInterval = 15) {
title.waitForExistenceOrFail(timeout: timeout)
}
func expandEmailSignup() {
expandEmailButton.waitUntilHittable(timeout: 10).tap()
}
func waitForCreateAccountButton(timeout: TimeInterval = 10) {
createAccountButton.waitForExistenceOrFail(timeout: timeout)
}
}
struct LoginScreenObject {
let app: XCUIApplication
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.usernameField] }
private var passwordSecureField: XCUIElement { app.secureTextFields[UITestID.Auth.passwordField] }
private var passwordVisibleField: XCUIElement { app.textFields[UITestID.Auth.passwordField] }
private var loginButton: XCUIElement { app.buttons[UITestID.Auth.loginButton] }
private var signUpButton: XCUIElement { app.buttons[UITestID.Auth.signUpButton] }
private var forgotPasswordButton: XCUIElement { app.buttons[UITestID.Auth.forgotPasswordButton] }
private var visibilityToggle: XCUIElement { app.buttons[UITestID.Auth.passwordVisibilityToggle] }
func waitForLoad(timeout: TimeInterval = 15) {
usernameField.waitForExistenceOrFail(timeout: timeout)
loginButton.waitForExistenceOrFail(timeout: timeout)
}
func enterUsername(_ username: String) {
usernameField.waitUntilHittable(timeout: 10).tap()
usernameField.typeText(username)
}
func enterPassword(_ password: String) {
if passwordSecureField.exists {
passwordSecureField.tap()
passwordSecureField.typeText(password)
} else {
passwordVisibleField.waitUntilHittable(timeout: 10).tap()
passwordVisibleField.typeText(password)
}
}
func tapPasswordVisibilityToggle() {
visibilityToggle.waitUntilHittable(timeout: 10).tap()
}
func tapSignUp() {
signUpButton.waitUntilHittable(timeout: 10).tap()
}
func tapForgotPassword() {
forgotPasswordButton.waitUntilHittable(timeout: 10).tap()
}
func assertPasswordFieldVisible() {
XCTAssertTrue(passwordVisibleField.waitForExistence(timeout: 5), "Expected visible password text field after toggle")
}
}
struct RegisterScreenObject {
let app: XCUIApplication
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
func waitForLoad(timeout: TimeInterval = 15) {
usernameField.waitForExistenceOrFail(timeout: timeout)
registerButton.waitForExistenceOrFail(timeout: timeout)
}
func fill(username: String, email: String, password: String) {
func advanceToNextField() {
let keys = ["Next", "Return", "return", "Done", "done"]
for key in keys {
let button = app.keyboards.buttons[key]
if button.waitForExistence(timeout: 1) && button.isHittable {
button.tap()
return
}
}
}
usernameField.waitForExistenceOrFail(timeout: 10)
usernameField.forceTap()
usernameField.typeText(username)
advanceToNextField()
emailField.waitForExistenceOrFail(timeout: 10)
if !emailField.hasKeyboardFocus {
emailField.forceTap()
if !emailField.hasKeyboardFocus {
advanceToNextField()
emailField.forceTap()
}
}
emailField.typeText(email)
advanceToNextField()
passwordField.waitForExistenceOrFail(timeout: 10)
if !passwordField.hasKeyboardFocus {
passwordField.forceTap()
}
passwordField.typeText(password)
advanceToNextField()
confirmPasswordField.waitForExistenceOrFail(timeout: 10)
if !confirmPasswordField.hasKeyboardFocus {
confirmPasswordField.forceTap()
}
confirmPasswordField.typeText(password)
}
func tapCancel() {
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
}
}

View 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
}
}

View 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 forgotverifyreset 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)
}
}
}

View 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
}
}

View 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)"
}
}

View File

@@ -0,0 +1,95 @@
import XCTest
enum TestFlows {
@discardableResult
static func navigateToLoginFromOnboarding(app: XCUIApplication) -> LoginScreenObject {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapAlreadyHaveAccount()
let login = LoginScreenObject(app: app)
login.waitForLoad()
return login
}
@discardableResult
static func navigateStartFreshToCreateAccount(
app: XCUIApplication,
residenceName: String = "UI Test Residence"
) -> OnboardingCreateAccountScreen {
let welcome = OnboardingWelcomeScreen(app: app)
welcome.waitForLoad()
welcome.tapStartFresh()
let valueProps = OnboardingValuePropsScreen(app: app)
valueProps.waitForLoad()
valueProps.tapContinue()
let nameResidence = OnboardingNameResidenceScreen(app: app)
nameResidence.waitForLoad()
nameResidence.enterResidenceName(residenceName)
nameResidence.tapContinue()
let createAccount = OnboardingCreateAccountScreen(app: app)
createAccount.waitForLoad()
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 = LoginScreenObject(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) -> RegisterScreenObject {
let login: LoginScreenObject
let loginRoot = app.otherElements[UITestID.Root.login]
if loginRoot.exists || app.textFields[UITestID.Auth.usernameField].exists {
login = LoginScreenObject(app: app)
login.waitForLoad()
} else {
login = navigateToLoginFromOnboarding(app: app)
}
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad()
return register
}
}