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