- 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>
228 lines
8.4 KiB
Swift
228 lines
8.4 KiB
Swift
import XCTest
|
|
|
|
/// Integration tests for residence CRUD against the real local backend.
|
|
///
|
|
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
|
final class ResidenceIntegrationTests: AuthenticatedTestCase {
|
|
|
|
override var useSeededAccount: Bool { true }
|
|
|
|
// MARK: - Create Residence
|
|
|
|
func testRES_CreateResidenceAppearsInList() {
|
|
navigateToResidences()
|
|
|
|
let residenceList = ResidenceListScreen(app: app)
|
|
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
|
|
residenceList.openCreateResidence()
|
|
|
|
let form = ResidenceFormScreen(app: app)
|
|
form.waitForLoad(timeout: defaultTimeout)
|
|
|
|
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
|
|
form.enterName(uniqueName)
|
|
form.save()
|
|
|
|
let newResidence = app.staticTexts[uniqueName]
|
|
XCTAssertTrue(
|
|
newResidence.waitForExistence(timeout: longTimeout),
|
|
"Newly created residence should appear in the list"
|
|
)
|
|
}
|
|
|
|
// MARK: - Edit Residence
|
|
|
|
func testRES_EditResidenceUpdatesInList() {
|
|
// Seed a residence via API so we have a known target to edit
|
|
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
|
|
|
|
navigateToResidences()
|
|
|
|
let residenceList = ResidenceListScreen(app: app)
|
|
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
|
|
// Find and tap the seeded residence
|
|
let card = app.staticTexts[seeded.name]
|
|
card.waitForExistenceOrFail(timeout: longTimeout)
|
|
card.forceTap()
|
|
|
|
// Tap edit button on detail view
|
|
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
editButton.forceTap()
|
|
|
|
let form = ResidenceFormScreen(app: app)
|
|
form.waitForLoad(timeout: defaultTimeout)
|
|
|
|
// Clear and re-enter name
|
|
let nameField = form.nameField
|
|
nameField.waitUntilHittable(timeout: 10).tap()
|
|
nameField.press(forDuration: 1.0)
|
|
let selectAll = app.menuItems["Select All"]
|
|
if selectAll.waitForExistence(timeout: 2) {
|
|
selectAll.tap()
|
|
}
|
|
|
|
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
|
|
nameField.typeText(updatedName)
|
|
form.save()
|
|
|
|
let updatedText = app.staticTexts[updatedName]
|
|
XCTAssertTrue(
|
|
updatedText.waitForExistence(timeout: longTimeout),
|
|
"Updated residence name should appear after edit"
|
|
)
|
|
}
|
|
|
|
// MARK: - RES-007: Primary Residence
|
|
|
|
func test18_setPrimaryResidence() {
|
|
// Seed two residences via API; the second one will be promoted to primary
|
|
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
|
|
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
|
|
|
|
navigateToResidences()
|
|
|
|
let residenceList = ResidenceListScreen(app: app)
|
|
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
|
|
// Open the second residence's detail
|
|
let secondCard = app.staticTexts[secondResidence.name]
|
|
secondCard.waitForExistenceOrFail(timeout: longTimeout)
|
|
secondCard.forceTap()
|
|
|
|
// Tap edit
|
|
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
|
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
editButton.forceTap()
|
|
|
|
let form = ResidenceFormScreen(app: app)
|
|
form.waitForLoad(timeout: defaultTimeout)
|
|
|
|
// Find and toggle the "is primary" toggle
|
|
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
|
|
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
|
|
// Toggle it on (value "0" means off, "1" means on)
|
|
if (isPrimaryToggle.value as? String) == "0" {
|
|
isPrimaryToggle.forceTap()
|
|
}
|
|
|
|
form.save()
|
|
|
|
// After saving, a primary indicator should be visible — either a label,
|
|
// badge, or the toggle being on in the refreshed detail view.
|
|
let primaryIndicator = app.staticTexts.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
|
).firstMatch
|
|
|
|
let primaryBadge = app.images.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
|
).firstMatch
|
|
|
|
let indicatorVisible = primaryIndicator.waitForExistence(timeout: longTimeout)
|
|
|| primaryBadge.waitForExistence(timeout: 3)
|
|
|
|
XCTAssertTrue(
|
|
indicatorVisible,
|
|
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
|
|
)
|
|
|
|
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
|
|
_ = firstResidence
|
|
}
|
|
|
|
// MARK: - OFF-004: Double Submit Protection
|
|
|
|
func test19_doubleSubmitProtection() {
|
|
navigateToResidences()
|
|
|
|
let residenceList = ResidenceListScreen(app: app)
|
|
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
|
|
residenceList.openCreateResidence()
|
|
|
|
let form = ResidenceFormScreen(app: app)
|
|
form.waitForLoad(timeout: defaultTimeout)
|
|
|
|
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
|
|
form.enterName(uniqueName)
|
|
|
|
// Rapidly tap save twice to test double-submit protection
|
|
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
|
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
|
saveButton.forceTap()
|
|
// Second tap immediately after — if the button is already disabled this will be a no-op
|
|
if saveButton.isHittable {
|
|
saveButton.forceTap()
|
|
}
|
|
|
|
// Wait for the form to dismiss (sheet closes, we return to the list)
|
|
let formDismissed = saveButton.waitForNonExistence(timeout: longTimeout)
|
|
XCTAssertTrue(formDismissed, "Form should dismiss after save")
|
|
|
|
// Back on the residences list — count how many cells with the unique name exist
|
|
let matchingTexts = app.staticTexts.matching(
|
|
NSPredicate(format: "label == %@", uniqueName)
|
|
)
|
|
|
|
// Allow time for the list to fully load
|
|
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
|
|
|
|
XCTAssertEqual(
|
|
matchingTexts.count, 1,
|
|
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
|
|
)
|
|
|
|
// Track the created residence for cleanup
|
|
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
|
|
if let created = residences.first(where: { $0.name == uniqueName }) {
|
|
cleaner.trackResidence(created.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete Residence
|
|
|
|
func testRES_DeleteResidenceRemovesFromList() {
|
|
// Seed a residence via API — don't track it since we'll delete through the UI
|
|
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
|
|
TestDataSeeder.createResidence(token: session.token, name: deleteName)
|
|
|
|
navigateToResidences()
|
|
|
|
let residenceList = ResidenceListScreen(app: app)
|
|
residenceList.waitForLoad(timeout: defaultTimeout)
|
|
|
|
// Find and tap the seeded residence
|
|
let target = app.staticTexts[deleteName]
|
|
target.waitForExistenceOrFail(timeout: longTimeout)
|
|
target.forceTap()
|
|
|
|
// Tap delete button
|
|
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
|
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
|
deleteButton.forceTap()
|
|
|
|
// Confirm deletion in alert
|
|
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
|
let alertDelete = app.alerts.buttons.containing(
|
|
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
|
).firstMatch
|
|
|
|
if confirmButton.waitForExistence(timeout: shortTimeout) {
|
|
confirmButton.tap()
|
|
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
|
|
alertDelete.tap()
|
|
}
|
|
|
|
let deletedResidence = app.staticTexts[deleteName]
|
|
XCTAssertTrue(
|
|
deletedResidence.waitForNonExistence(timeout: longTimeout),
|
|
"Deleted residence should no longer appear in the list"
|
|
)
|
|
}
|
|
}
|