Files
honeyDueKMP/iosApp/CaseraUITests/Framework/AuthenticatedTestCase.swift
treyt fc0e0688eb 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>
2026-02-24 15:37:56 -06:00

160 lines
5.8 KiB
Swift

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