import XCTest /// Base class for all tests that require a logged-in user. class AuthenticatedUITestCase: BaseUITestCase { // MARK: - Configuration (override in subclasses) var testCredentials: (username: String, password: String) { ("testuser", "TestPass123!") } var needsAPISession: Bool { false } var apiCredentials: (username: String, password: String) { ("admin", "Test1234") } // MARK: - API Session private(set) var session: TestSession! private(set) var cleaner: TestDataCleaner! // MARK: - Lifecycle override class func setUp() { super.setUp() guard TestAccountAPIClient.isBackendReachable() else { return } // Ensure both known test accounts exist (covers all subclass credential overrides) if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil { _ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!") } if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil { _ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234") } } /// When `true`, every test in the suite forces a logout → login cycle /// in `setUp`, guaranteeing a freshly-issued auth token on each run. /// /// Default is `false`: tests reuse the existing logged-in session /// from the previous test in the same suite — much faster (one login /// per suite, not one per test) and resilient to suites where the /// current screen has no logout affordance (`UITestHelpers.ensureLoggedOut` /// times out → the test fails before its body runs). /// /// Override to `true` in suites that have observed transient /// `Invalid token` 401s on POST/PATCH while reads continue to work. /// The recipe was added after a 2026-05 incident where the API /// container was rebuilt mid-suite and in-memory tokens went stale. /// In normal CI runs against a stable API + freshly-erased simulator, /// session reuse is the correct default. var forceFreshLoginPerTest: Bool { false } override func setUpWithError() throws { guard TestAccountAPIClient.isBackendReachable() else { throw XCTSkip("Backend not reachable at \(TestAccountAPIClient.baseURL)") } try super.setUpWithError() let tabBar = app.tabBars.firstMatch let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout) // Force-fresh path: log out (if needed) and re-authenticate per // test so every test starts with a freshly-issued JWT. Catches // server-side token invalidation that would otherwise surface // mid-suite as opaque 401s on the first mutation call. if forceFreshLoginPerTest { if alreadyLoggedIn { UITestHelpers.ensureLoggedOut(app: app) } else { UITestHelpers.ensureLoggedOut(app: app) } loginToMainApp() } else if !alreadyLoggedIn { // Legacy session-reuse path: only log in when not already in. UITestHelpers.ensureLoggedOut(app: app) loginToMainApp() } // (When `forceFreshLoginPerTest == false` AND we're already // logged in, fall through with the existing session.) if needsAPISession { guard let apiSession = TestAccountManager.loginSeededAccount( username: apiCredentials.username, password: apiCredentials.password ) else { XCTFail("Could not login API account '\(apiCredentials.username)'") return } session = apiSession cleaner = TestDataCleaner(token: apiSession.token) } } override func tearDownWithError() throws { cleaner?.cleanAll() try super.tearDownWithError() } // MARK: - Login func loginToMainApp() { let creds = testCredentials UITestHelpers.ensureOnLoginScreen(app: app) let login = LoginScreenObject(app: app) login.waitForLoad(timeout: loginTimeout) login.enterUsername(creds.username) login.enterPassword(creds.password) let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] loginButton.waitForExistenceOrFail(timeout: defaultTimeout) loginButton.tap() waitForMainApp() } func waitForMainApp() { let tabBar = app.tabBars.firstMatch let verification = VerificationScreen(app: app) let deadline = Date().addingTimeInterval(loginTimeout) while Date() < deadline { if tabBar.exists { break } if verification.codeField.exists { verification.enterCode(TestAccountAPIClient.debugVerificationCode) verification.submitCode() _ = tabBar.waitForExistence(timeout: loginTimeout) break } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } XCTAssertTrue(tabBar.exists, "Expected tab bar after login with '\(testCredentials.username)'") } // MARK: - Tab Navigation func navigateToTab(_ label: String) { let tabBar = app.tabBars.firstMatch tabBar.waitForExistenceOrFail(timeout: navigationTimeout) let tab = tabBar.buttons.containing( NSPredicate(format: "label CONTAINS[c] %@", label) ).firstMatch tab.waitForExistenceOrFail(timeout: navigationTimeout) tab.tap() // Verify navigation happened — wait for isSelected (best effort, won't fail) // sidebarAdaptable tabs sometimes need a moment let selected = NSPredicate(format: "isSelected == true") let exp = XCTNSPredicateExpectation(predicate: selected, object: tab) let result = XCTWaiter().wait(for: [exp], timeout: navigationTimeout) // If first tap didn't register, tap again if result != .completed { tab.tap() _ = XCTWaiter().wait(for: [XCTNSPredicateExpectation(predicate: selected, object: tab)], timeout: navigationTimeout) } } func navigateToResidences() { navigateToTab("Residences") } func navigateToTasks() { navigateToTab("Tasks") } func navigateToContractors() { navigateToTab("Contractors") } func navigateToDocuments() { navigateToTab("Doc") } func navigateToProfile() { navigateToTab("Profile") } // MARK: - Pull to Refresh func pullToRefresh() { let scrollable = app.collectionViews.firstMatch.exists ? app.collectionViews.firstMatch : app.scrollViews.firstMatch guard scrollable.waitForExistence(timeout: defaultTimeout) else { return } let start = scrollable.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) let end = scrollable.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) start.press(forDuration: 0.3, thenDragTo: end) _ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: navigationTimeout) } func pullToRefreshUntilVisible(_ element: XCUIElement, maxRetries: Int = 3) { for _ in 0..