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