Files
honeyDueKMP/iosApp/HoneyDueUITests/Tests/ContractorIntegrationTests.swift
treyt 5c360a2796 Rearchitect UI test suite for complete, non-flaky coverage against live API
- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase
  with seeded admin account and real backend login
- Add 34 accessibility identifiers across 11 app views (task completion, profile,
  notifications, theme, join residence, manage users, forms)
- Create FeatureCoverageTests (14 tests) covering previously untested features:
  profile edit, theme selection, notification prefs, task completion, manage users,
  join residence, task templates
- Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI
  tests) for full cross-user residence sharing lifecycle
- Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs,
  cleanup_test_data.sh script for manual reset via admin API
- Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode,
  getShareCode, listResidenceUsers, removeUser)
- Fix app bugs found by tests:
  - ResidencesListView join callback now uses forceRefresh:true
  - APILayer invalidates task cache when residence count changes
  - AllTasksView auto-reloads tasks when residence list changes
- Fix test quality: keyboard focus waits, Save/Add button label matching,
  Documents tab label (Docs), remove API verification from UI tests
- DataLayerTests and PasswordResetTests now verify through UI, not API calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:32:13 -05:00

335 lines
14 KiB
Swift

import XCTest
/// Integration tests for contractor CRUD against the real local backend.
///
/// Test Plan IDs: CON-002, CON-005, CON-006
/// Data is seeded via API and cleaned up in tearDown.
final class ContractorIntegrationTests: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// MARK: - CON-002: Create Contractor
func testCON002_CreateContractorMinimalFields() {
navigateToContractors()
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
let emptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| contractorList.waitForExistence(timeout: 3)
XCTAssertTrue(loaded, "Contractors screen should load")
if addButton.exists && addButton.isHittable {
addButton.forceTap()
} else {
let emptyAddButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
).firstMatch
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
emptyAddButton.forceTap()
}
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueName = "IntTest Contractor \(Int(Date().timeIntervalSince1970))"
nameField.forceTap()
nameField.typeText(uniqueName)
// Dismiss keyboard before tapping save (toolbar button may not respond with keyboard up)
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
// Save button is in the toolbar (top of sheet)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
saveButton.forceTap()
// Wait for the sheet to dismiss (save triggers async API call + dismiss)
let nameFieldGone = nameField.waitForNonExistence(timeout: longTimeout)
if !nameFieldGone {
// If still showing the form, try tapping save again
if saveButton.exists {
saveButton.forceTap()
_ = nameField.waitForNonExistence(timeout: longTimeout)
}
}
// Pull to refresh to pick up the newly created contractor
sleep(2)
pullToRefresh()
// Wait for the contractor list to show the new entry
let newContractor = app.staticTexts[uniqueName]
if !newContractor.waitForExistence(timeout: defaultTimeout) {
// Pull to refresh again in case the first one was too early
pullToRefresh()
}
XCTAssertTrue(
newContractor.waitForExistence(timeout: defaultTimeout),
"Newly created contractor should appear in list"
)
}
// MARK: - CON-005: Edit Contractor
func testCON005_EditContractor() {
// Seed a contractor via API
let contractor = cleaner.seedContractor(name: "Edit Target Contractor \(Int(Date().timeIntervalSince1970))")
navigateToContractors()
// Pull to refresh until the seeded contractor is visible
let card = app.staticTexts[contractor.name]
pullToRefreshUntilVisible(card)
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Tap the ellipsis menu to reveal edit/delete options
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
if menuButton.waitForExistence(timeout: defaultTimeout) {
menuButton.forceTap()
} else {
// Fallback: last nav bar button
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
if navBarMenu.exists { navBarMenu.forceTap() }
}
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
if !editButton.waitForExistence(timeout: defaultTimeout) {
// Fallback: look for any Edit button
let anyEdit = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit'")
).firstMatch
anyEdit.waitForExistenceOrFail(timeout: 5)
anyEdit.forceTap()
} else {
editButton.forceTap()
}
// Update name clear existing text using delete keys
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
nameField.forceTap()
sleep(1)
// Move cursor to end and delete all characters
let currentValue = (nameField.value as? String) ?? ""
let deleteCount = max(currentValue.count, 50) + 5
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
nameField.typeText(deleteString)
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
nameField.typeText(updatedName)
// Dismiss keyboard before tapping save
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
sleep(1)
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
saveButton.forceTap()
// After save, the form dismisses back to detail view. Navigate back to list.
sleep(3)
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.waitForExistence(timeout: 5) {
backButton.tap()
sleep(1)
}
// Pull to refresh to pick up the edit
pullToRefresh()
let updatedText = app.staticTexts[updatedName]
XCTAssertTrue(
updatedText.waitForExistence(timeout: longTimeout),
"Updated contractor name should appear after edit"
)
}
// MARK: - CON-007: Favorite Toggle
func test20_toggleContractorFavorite() {
// Seed a contractor via API and track it for cleanup
let contractor = cleaner.seedContractor(name: "Favorite Toggle Contractor \(Int(Date().timeIntervalSince1970))")
navigateToContractors()
// Pull to refresh until the seeded contractor is visible
let card = app.staticTexts[contractor.name]
pullToRefreshUntilVisible(card)
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Look for a favorite / star button in the detail view.
// The button may be labelled "Favorite", carry a star SF symbol, or use a toggle.
let favoriteButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Favorite' OR label CONTAINS[c] 'Star' OR label CONTAINS[c] 'favourite'")
).firstMatch
guard favoriteButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Favorite/star button not found on contractor detail view")
return
}
// Capture initial accessibility value / label to detect change
let initialLabel = favoriteButton.label
// First toggle mark as favourite
favoriteButton.forceTap()
// Brief pause so the UI can settle after the API call
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
// The button's label or selected state should have changed
let afterFirstToggleLabel = favoriteButton.label
XCTAssertNotEqual(
initialLabel, afterFirstToggleLabel,
"Favorite button appearance should change after first toggle"
)
// Second toggle un-mark as favourite, state should return to original
favoriteButton.forceTap()
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
let afterSecondToggleLabel = favoriteButton.label
XCTAssertEqual(
initialLabel, afterSecondToggleLabel,
"Favorite button appearance should return to original after second toggle"
)
}
// MARK: - CON-008: Contractor by Residence Filter
func test21_contractorByResidenceFilter() throws {
// Seed a residence and a contractor linked to it
let residence = cleaner.seedResidence(name: "Filter Test Residence \(Int(Date().timeIntervalSince1970))")
let contractor = cleaner.seedContractor(
name: "Residence Contractor \(Int(Date().timeIntervalSince1970))",
fields: ["residence_id": residence.id]
)
navigateToResidences()
// Pull to refresh until the seeded residence is visible
let residenceText = app.staticTexts[residence.name]
pullToRefreshUntilVisible(residenceText)
residenceText.waitForExistenceOrFail(timeout: longTimeout)
residenceText.forceTap()
// Look for a Contractors section within the residence detail.
// The section header text or accessibility element is checked first.
let contractorsSectionHeader = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Contractor'")
).firstMatch
guard contractorsSectionHeader.waitForExistence(timeout: defaultTimeout) else {
throw XCTSkip("Residence detail does not expose a Contractors section — skipping filter test")
}
// Verify the seeded contractor appears in the residence's contractor list
let contractorEntry = app.staticTexts[contractor.name]
XCTAssertTrue(
contractorEntry.waitForExistence(timeout: defaultTimeout),
"Contractor '\(contractor.name)' should appear in the contractors section of residence '\(residence.name)'"
)
}
// MARK: - CON-006: Delete Contractor
func testCON006_DeleteContractor() {
// Seed a contractor via API don't track with cleaner since we'll delete via UI
let deleteName = "Delete Contractor \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createContractor(token: session.token, name: deleteName)
navigateToContractors()
// Pull to refresh until the seeded contractor is visible
let target = app.staticTexts[deleteName]
pullToRefreshUntilVisible(target)
target.waitForExistenceOrFail(timeout: longTimeout)
// Open the contractor's detail view
target.forceTap()
// Wait for detail view to load
let detailView = app.otherElements[AccessibilityIdentifiers.Contractor.detailView]
_ = detailView.waitForExistence(timeout: defaultTimeout)
sleep(2)
// Tap the ellipsis menu button
// SwiftUI Menu can be a button, popUpButton, or image
let menuButton = app.buttons[AccessibilityIdentifiers.Contractor.menuButton]
let menuImage = app.images[AccessibilityIdentifiers.Contractor.menuButton]
let menuPopUp = app.popUpButtons.firstMatch
if menuButton.waitForExistence(timeout: 5) {
menuButton.forceTap()
} else if menuImage.waitForExistence(timeout: 3) {
menuImage.forceTap()
} else if menuPopUp.waitForExistence(timeout: 3) {
menuPopUp.forceTap()
} else {
// Debug: dump nav bar buttons to understand what's available
let navButtons = app.navigationBars.buttons.allElementsBoundByIndex
let navButtonInfo = navButtons.prefix(10).map { "[\($0.identifier)|\($0.label)]" }
let allButtons = app.buttons.allElementsBoundByIndex
let buttonInfo = allButtons.prefix(15).map { "[\($0.identifier)|\($0.label)]" }
XCTFail("Could not find menu button. Nav bar buttons: \(navButtonInfo). All buttons: \(buttonInfo)")
return
}
sleep(1)
// Find and tap "Delete" in the menu popup
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
if deleteButton.waitForExistence(timeout: defaultTimeout) {
deleteButton.forceTap()
} else {
let anyDelete = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
anyDelete.waitForExistenceOrFail(timeout: 5)
anyDelete.forceTap()
}
// Confirm the delete in the alert
let alert = app.alerts.firstMatch
alert.waitForExistenceOrFail(timeout: defaultTimeout)
let deleteLabel = alert.buttons["Delete"]
if deleteLabel.waitForExistence(timeout: 3) {
deleteLabel.tap()
} else {
// Fallback: tap any button containing "Delete"
let anyDeleteBtn = alert.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
if anyDeleteBtn.exists {
anyDeleteBtn.tap()
} else {
// Last resort: tap the last button (destructive buttons are last)
let count = alert.buttons.count
alert.buttons.element(boundBy: count > 0 ? count - 1 : 0).tap()
}
}
// Wait for the detail view to dismiss and return to list
sleep(3)
// Pull to refresh in case the list didn't auto-update
pullToRefresh()
// Verify the contractor is no longer visible
let deletedContractor = app.staticTexts[deleteName]
XCTAssertTrue(
deletedContractor.waitForNonExistence(timeout: longTimeout),
"Deleted contractor should no longer appear"
)
}
}