Files
honeyDueKMP/iosApp/HoneyDueUITests/Tests/DocumentIntegrationTests.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

375 lines
16 KiB
Swift

import XCTest
/// Integration tests for document CRUD against the real local backend.
///
/// Test Plan IDs: DOC-002, DOC-004, DOC-005
/// Data is seeded via API and cleaned up in tearDown.
final class DocumentIntegrationTests: AuthenticatedTestCase {
override var useSeededAccount: Bool { true }
// MARK: - DOC-002: Create Document
func testDOC002_CreateDocumentWithRequiredFields() {
// Seed a residence so the picker has an option to select
let residence = cleaner.seedResidence(name: "DocTest Residence \(Int(Date().timeIntervalSince1970))")
navigateToDocuments()
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|| emptyState.waitForExistence(timeout: 3)
|| documentList.waitForExistence(timeout: 3)
XCTAssertTrue(loaded, "Documents 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()
}
// Wait for the form to load
sleep(2)
// Select a residence from the picker (required for documents created from Documents tab).
// SwiftUI Picker with menu style: tapping opens a dropdown menu with options as buttons.
let residencePicker = app.buttons[AccessibilityIdentifiers.Document.residencePicker]
let pickerByLabel = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Property' OR label CONTAINS[c] 'Residence' OR label CONTAINS[c] 'Select'")
).firstMatch
let pickerElement = residencePicker.waitForExistence(timeout: defaultTimeout) ? residencePicker : pickerByLabel
if pickerElement.waitForExistence(timeout: defaultTimeout) {
pickerElement.forceTap()
sleep(1)
// Menu-style picker shows options as buttons
let residenceButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] %@", residence.name)
).firstMatch
if residenceButton.waitForExistence(timeout: 5) {
residenceButton.tap()
} else {
// Fallback: tap any hittable option that's not the placeholder
let anyOption = app.buttons.allElementsBoundByIndex.first(where: {
$0.exists && $0.isHittable &&
!$0.label.isEmpty &&
!$0.label.lowercased().contains("select") &&
!$0.label.lowercased().contains("cancel")
})
anyOption?.tap()
}
sleep(1)
}
// Fill in the title field
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "IntTest Doc \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
titleField.typeText(uniqueTitle)
// Dismiss keyboard by tapping Return key (coordinate tap doesn't reliably defocus)
let returnKey = app.keyboards.buttons["Return"]
if returnKey.waitForExistence(timeout: 3) {
returnKey.tap()
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap()
}
sleep(1)
// The default document type is "warranty" (opened from Warranties tab), which requires
// Item Name and Provider/Company fields. Swipe up to reveal them.
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
let itemNameField = app.textFields["Item Name"]
// Swipe up to reveal warranty fields below the fold
for _ in 0..<3 {
if itemNameField.exists && itemNameField.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
sleep(1)
}
if itemNameField.waitForExistence(timeout: 5) {
// Tap directly to get keyboard focus (not forceTap which uses coordinate)
if itemNameField.isHittable {
itemNameField.tap()
} else {
itemNameField.forceTap()
// If forceTap didn't give focus, tap coordinate again
usleep(500000)
itemNameField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
usleep(500000)
itemNameField.typeText("Test Item")
// Dismiss keyboard
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
sleep(1)
}
let providerField = app.textFields["Provider/Company"]
for _ in 0..<3 {
if providerField.exists && providerField.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
sleep(1)
}
if providerField.waitForExistence(timeout: 5) {
if providerField.isHittable {
providerField.tap()
} else {
providerField.forceTap()
usleep(500000)
providerField.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
usleep(500000)
providerField.typeText("Test Provider")
// Dismiss keyboard
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
sleep(1)
}
// Save the document swipe up to reveal save button if needed
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
for _ in 0..<3 {
if saveButton.exists && saveButton.isHittable { break }
if scrollContainer.exists { scrollContainer.swipeUp() }
sleep(1)
}
saveButton.forceTap()
// Wait for the form to dismiss and the new document to appear in the list
let newDoc = app.staticTexts[uniqueTitle]
XCTAssertTrue(
newDoc.waitForExistence(timeout: longTimeout),
"Newly created document should appear in list"
)
}
// MARK: - DOC-004: Edit Document
func testDOC004_EditDocument() {
// Seed a residence and document via API (use "warranty" type since default tab is Warranties)
let residence = cleaner.seedResidence()
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))", documentType: "warranty")
navigateToDocuments()
// Pull to refresh until the seeded document is visible
let card = app.staticTexts[doc.title]
pullToRefreshUntilVisible(card)
card.waitForExistenceOrFail(timeout: longTimeout)
card.forceTap()
// Tap the ellipsis menu to reveal edit/delete options
let menuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
let menuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
if menuButton.waitForExistence(timeout: 5) {
menuButton.forceTap()
} else if menuImage.waitForExistence(timeout: 3) {
menuImage.forceTap()
} else {
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
navBarMenu.waitForExistenceOrFail(timeout: 5)
navBarMenu.forceTap()
}
// Tap edit
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton]
if !editButton.waitForExistence(timeout: defaultTimeout) {
let anyEdit = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Edit'")
).firstMatch
anyEdit.waitForExistenceOrFail(timeout: 5)
anyEdit.forceTap()
} else {
editButton.forceTap()
}
// Update title clear existing text first using delete keys
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
titleField.forceTap()
sleep(1)
// Delete all existing text character by character (use generous count)
let currentValue = (titleField.value as? String) ?? ""
let deleteCount = max(currentValue.count, 50) + 5
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
titleField.typeText(deleteString)
let updatedTitle = "Updated Doc \(Int(Date().timeIntervalSince1970))"
titleField.typeText(updatedTitle)
// Verify the text field now contains the updated title
let fieldValue = titleField.value as? String ?? ""
if !fieldValue.contains("Updated Doc") {
XCTFail("Title field text replacement failed. Current value: '\(fieldValue)'. Expected to contain: 'Updated Doc'")
return
}
// Dismiss keyboard so save button is hittable
let returnKey = app.keyboards.buttons["Return"]
if returnKey.exists { returnKey.tap() }
else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)).tap() }
sleep(1)
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
if !saveButton.isHittable {
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
if scrollContainer.exists { scrollContainer.swipeUp() }
sleep(1)
}
saveButton.forceTap()
// After save, the form pops back to the detail view.
// Wait for form to dismiss, then navigate back to the list.
sleep(3)
// Navigate back: tap the back button in nav bar to return to list
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.waitForExistence(timeout: 5) {
backButton.tap()
sleep(1)
}
// Tap back again if we're still on detail view
let secondBack = app.navigationBars.buttons.element(boundBy: 0)
if secondBack.exists && !app.tabBars.firstMatch.buttons.firstMatch.isSelected {
secondBack.tap()
sleep(1)
}
// Pull to refresh to ensure the list shows the latest data
pullToRefresh()
// Debug: dump visible texts to see what's showing
let visibleTexts = app.staticTexts.allElementsBoundByIndex.prefix(20).map { $0.label }
let updatedText = app.staticTexts[updatedTitle]
XCTAssertTrue(
updatedText.waitForExistence(timeout: longTimeout),
"Updated document title should appear after edit. Visible texts: \(visibleTexts)"
)
}
// MARK: - DOC-007: Document Image Section Exists
// NOTE: Full image-deletion testing (the original DOC-007 scenario) requires a
// document with at least one uploaded image. Image upload cannot be triggered
// via API alone it requires user interaction with the photo picker inside the
// app (or a multipart upload endpoint). This stub seeds a document, opens its
// detail view, and verifies the images section is present so that a human tester
// or future automation (with photo injection) can extend it.
func test22_documentImageSectionExists() throws {
// Seed a residence and a document via API
let residence = cleaner.seedResidence()
let document = cleaner.seedDocument(
residenceId: residence.id,
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))",
documentType: "warranty"
)
navigateToDocuments()
// Pull to refresh until the seeded document is visible
let docText = app.staticTexts[document.title]
pullToRefreshUntilVisible(docText)
docText.waitForExistenceOrFail(timeout: longTimeout)
docText.forceTap()
// Verify the detail view loaded
let detailView = app.otherElements[AccessibilityIdentifiers.Document.detailView]
let detailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|| app.navigationBars.staticTexts[document.title].waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(detailLoaded, "Document detail view should load after tapping the document")
// Look for an images / photos section header or add-image button.
// The exact identifier or label will depend on the document detail implementation.
let imagesSection = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Attachment'")
).firstMatch
let addImageButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Add'")
).firstMatch
let sectionVisible = imagesSection.waitForExistence(timeout: defaultTimeout)
|| addImageButton.waitForExistence(timeout: 3)
// This assertion will fail gracefully if the images section is not yet implemented.
// When it does fail, it surfaces the missing UI element for the developer.
XCTAssertTrue(
sectionVisible,
"Document detail should show an images/photos section or an add-image button. " +
"Full deletion of a specific image requires manual upload first — see DOC-007 in test plan."
)
}
// MARK: - DOC-005: Delete Document
func testDOC005_DeleteDocument() {
// Seed a document via API don't track since we'll delete through UI
let residence = cleaner.seedResidence()
let deleteTitle = "Delete Doc \(Int(Date().timeIntervalSince1970))"
TestDataSeeder.createDocument(token: session.token, residenceId: residence.id, title: deleteTitle, documentType: "warranty")
navigateToDocuments()
// Pull to refresh until the seeded document is visible
let target = app.staticTexts[deleteTitle]
pullToRefreshUntilVisible(target)
target.waitForExistenceOrFail(timeout: longTimeout)
target.forceTap()
// Tap the ellipsis menu to reveal delete option
let deleteMenuButton = app.buttons[AccessibilityIdentifiers.Document.menuButton]
let deleteMenuImage = app.images[AccessibilityIdentifiers.Document.menuButton]
if deleteMenuButton.waitForExistence(timeout: 5) {
deleteMenuButton.forceTap()
} else if deleteMenuImage.waitForExistence(timeout: 3) {
deleteMenuImage.forceTap()
} else {
let navBarMenu = app.navigationBars.buttons.element(boundBy: app.navigationBars.buttons.count - 1)
navBarMenu.waitForExistenceOrFail(timeout: 5)
navBarMenu.forceTap()
}
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton]
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
let anyDelete = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Delete'")
).firstMatch
anyDelete.waitForExistenceOrFail(timeout: 5)
anyDelete.forceTap()
} else {
deleteButton.forceTap()
}
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 deletedDoc = app.staticTexts[deleteTitle]
XCTAssertTrue(
deletedDoc.waitForNonExistence(timeout: longTimeout),
"Deleted document should no longer appear"
)
}
}