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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user