diff --git a/FlipcashUITests/Regression/BlockedUsersGatingRegressionTests.swift b/FlipcashUITests/Regression/BlockedUsersGatingRegressionTests.swift new file mode 100644 index 00000000..32d4b417 --- /dev/null +++ b/FlipcashUITests/Regression/BlockedUsersGatingRegressionTests.swift @@ -0,0 +1,44 @@ +// +// BlockedUsersGatingRegressionTests.swift +// FlipcashUITests +// + +import XCTest + +/// Guards the beta gate on the Blocking feature: with `enableBlocking` off, the +/// "Blocked" row must not appear in Settings › My Account. Under `--ui-testing` +/// the app resets every beta flag from the launch argument, so an empty +/// `enabledBetaFlags` deterministically leaves blocking disabled regardless of +/// any prior run's on-disk flag cache. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account only needs to be +/// loggable in. +@MainActor +final class BlockedUsersGatingRegressionTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + // No enableBlocking — the launch-argument reset forces it off. + + /// My Account renders its always-present rows but omits the beta-gated + /// Blocked row when Blocking is disabled. + func testBlockedRow_hiddenWhenBlockingDisabled() { + let settings = SettingsUIScreen(app: app) + + assertMainScreenReached() + + settings.open(from: self) + settings.navigateToMyAccount(from: self) + + // Anchor on an always-present row so the absence check runs only once My + // Account has actually rendered — otherwise a not-yet-loaded screen would + // pass the negative assertion for the wrong reason. + XCTAssertTrue( + settings.accessKeyRow.waitForExistence(timeout: 30), + "Expected My Account to render its always-present Access Key row" + ) + XCTAssertFalse( + settings.blockedRow.exists, + "Expected the Blocked row to be hidden when the enableBlocking beta flag is off" + ) + } +} diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift new file mode 100644 index 00000000..41dbfe4f --- /dev/null +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -0,0 +1,156 @@ +// +// BlockUnblockSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// End-to-end round-trip for the Blocking feature: block a user from their tip +/// DM, confirm the conversation disappears from the Tips list, then unblock them +/// from Settings and confirm the block is cleared. +/// +/// **Fixture (non-mutating by design).** The Block affordance only exists on a +/// tip-DM conversation, so the test drives whatever tip DM the standing +/// `FLIPCASH_UI_TEST_ACCESS_KEY` account already has — it never sends a tip and +/// captures the counterpart's name dynamically. With no tip DM in the feed the +/// test skips rather than fabricating one. The block/unblock round-trip restores +/// the account's blocklist to its starting state; `tearDown` re-runs the unblock +/// best-effort if an assertion fails after the block lands, so a failed run never +/// leaves the shared account with a user blocked. +/// +/// **Prerequisites:** the standing account needs a tip profile and at least one +/// tip DM in its Tips list. +@MainActor +final class BlockUnblockSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + // Tips tab needs `enableTips`; the Block affordance needs `enableBlocking`. + override var enabledBetaFlags: [String] { ["enableTips", "enableBlocking"] } + + /// The counterpart blocked during the test, cleared once unblocked. Non-nil + /// in `tearDown` means the round-trip failed after the block — clean it up. + private var blockedName: String? + + override func setUp() async throws { + try await super.setUp() + // The full round-trip crosses several screens and two sheets. + executionTimeAllowance = 600 + } + + /// Blocks the first tip-DM counterpart, asserts their chat leaves the Tips + /// list, then unblocks them from Settings and asserts the block is gone. + func testBlock_hidesTipConversation_thenUnblockRestores() throws { + let tips = TipsUIScreen(app: app) + let settings = SettingsUIScreen(app: app) + let blocked = BlockedUsersUIScreen(app: app) + + assertMainScreenReached() + + // MARK: Reach a tip DM (skip when the account has none). + tips.open(from: self) + guard let row = tips.firstConversationRow() else { + throw XCTSkip("No tip DM in the standing account's Tips list — skipping the block/unblock round-trip") + } + // The row label is the counterpart's display name, plus an ", unread + // messages" suffix when unread. Strip it to the bare name, which the + // conversation title, the block dialog, and the blocked-list row share. + let name = row.label.replacingOccurrences(of: ", unread messages", with: "") + XCTAssertFalse(name.isEmpty, "Expected the tip conversation row to carry the counterpart's name") + row.tap() + + // MARK: Open the counterpart's profile from the conversation. + // The tip DM shows Send Cash — wait for it so the transcript has loaded + // before reaching for the title. + XCTAssertTrue( + app.buttons["send-cash-button"].waitForExistence(timeout: 30), + "Expected the tip DM conversation to open with Send Cash" + ) + // The nav-title item is a button (label = the name) only when blocking is + // enabled and the tip counterpart resolves — both hold here. + let titleButton = app.buttons[name].firstMatch + XCTAssertTrue( + titleButton.waitForExistence(timeout: 15), + "Expected the conversation title to be a tappable profile button for '\(name)'" + ) + titleButton.tap() + + // MARK: Block from the profile screen. + waitAndTap(app.buttons["Block"], timeout: 30, "Expected the Block row on the profile screen") + let blockDialog = app.otherElements + .matching(NSPredicate(format: "identifier BEGINSWITH %@", "Block ")) + .firstMatch + XCTAssertTrue(blockDialog.waitForExistence(timeout: 10), "Expected the block confirmation dialog") + blockDialog.buttons["Block"].tap() + blockedName = name + + // MARK: The chat leaves the Tips list. + // Block returns to the Tips root; the reconcile hides the conversation, + // and there is no empty-state label, so assert the row's absence. + let hiddenRow = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch + XCTAssertTrue( + hiddenRow.waitForNonExistence(timeout: 20), + "Expected '\(name)' tip conversation to disappear from the Tips list after blocking" + ) + + // MARK: Unblock from Settings › My Account › Blocked. + tips.close(from: self) + settings.open(from: self) + settings.navigateToMyAccount(from: self) + waitAndTap(settings.blockedRow) + blocked.assertLoaded(from: self) + + let blockedRow = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", name)).firstMatch + XCTAssertTrue( + blockedRow.waitForExistence(timeout: 20), + "Expected '\(name)' to appear in the Blocked list" + ) + blockedRow.tap() + + let unblockDialog = app.otherElements + .matching(NSPredicate(format: "identifier BEGINSWITH %@", "Unblock ")) + .firstMatch + XCTAssertTrue(unblockDialog.waitForExistence(timeout: 10), "Expected the unblock confirmation dialog") + unblockDialog.buttons["Unblock"].tap() + + // MARK: The block is cleared. + XCTAssertTrue( + blockedRow.waitForNonExistence(timeout: 20), + "Expected '\(name)' to leave the Blocked list after unblocking" + ) + blockedName = nil + } + + override func tearDown() async throws { + // A block that landed but wasn't undone (an assertion failed mid-flow) + // would leave the shared account dirty. Relaunch clean and remove it + // best-effort; never assert, so teardown can't mask the real failure. + if let name = blockedName { + blockedName = nil + app.launch() + try? loginTestAccount() + bestEffortUnblock(named: name) + } + try await super.tearDown() + } + + /// Navigates Settings › My Account › Blocked and unblocks `name` if present, + /// tolerating every step so a failed test's teardown stays quiet. + private func bestEffortUnblock(named name: String) { + let settings = SettingsUIScreen(app: app) + guard app.buttons["Settings"].waitForExistence(timeout: 30) else { return } + app.buttons["Settings"].tap() + guard settings.myAccountRow.waitForExistence(timeout: 10) else { return } + settings.myAccountRow.tap() + guard settings.blockedRow.waitForExistence(timeout: 10) else { return } + settings.blockedRow.tap() + + let row = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", name)).firstMatch + guard row.waitForExistence(timeout: 15) else { return } + row.tap() + let dialog = app.otherElements + .matching(NSPredicate(format: "identifier BEGINSWITH %@", "Unblock ")) + .firstMatch + guard dialog.waitForExistence(timeout: 10) else { return } + dialog.buttons["Unblock"].tap() + } +} diff --git a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift new file mode 100644 index 00000000..c0f3707e --- /dev/null +++ b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift @@ -0,0 +1,43 @@ +// +// BlockedUsersSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Smoke tests for the Blocked users list reached from Settings › My Account › +/// Blocked, with the `enableBlocking` beta flag on. +/// +/// **Scope.** These cover the navigation into the list and that the screen loads +/// — the parts most likely to regress from a routing or beta-gate change. The +/// block/unblock *action* itself is intentionally not driven here: it requires a +/// live tip-DM conversation fixture (the Block affordance only appears on a tip +/// DM's profile) and committing it would mutate the standing account's server +/// blocklist, which the tip smoke tests deliberately never do. That logic is +/// covered by the unit `BlocklistControllerTests` against a fake backend. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account only needs to be +/// loggable in; the test does not depend on its blocklist contents. +@MainActor +final class BlockedUsersSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + override var enabledBetaFlags: [String] { ["enableBlocking"] } + + /// Main → Settings → My Account → Blocked lands on the Blocked list and it + /// loads its state (empty or populated) without hanging. + func testBlockedUsers_reachableFromMyAccount() { + let settings = SettingsUIScreen(app: app) + let blocked = BlockedUsersUIScreen(app: app) + + assertMainScreenReached() + + settings.open(from: self) + settings.navigateToMyAccount(from: self) + + // The Blocked row is gated on `enableBlocking`, which this test enables. + waitAndTap(settings.blockedRow) + + blocked.assertLoaded(from: self) + } +} diff --git a/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift new file mode 100644 index 00000000..dd5d111c --- /dev/null +++ b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift @@ -0,0 +1,51 @@ +// +// BlockedUsersUIScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for `BlockedUsersScreen` — the Settings › My Account › Blocked +/// list. Reachable only when the `enableBlocking` beta flag is on. +@MainActor +struct BlockedUsersUIScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// The screen's inline navigation title. + var navigationBar: XCUIElement { app.navigationBars["Blocked"] } + + /// The empty-state heading, shown once the blocklist loads with no entries. + var emptyStateHeading: XCUIElement { app.staticTexts["No One Blocked"] } + + /// The list of blocked users, present only when at least one user is blocked. + var list: XCUIElement { app.scrollViews.firstMatch } + + // MARK: - Assertions + + /// Asserts the screen was reached and its `refresh()` settled into a + /// terminal state — either the empty-state copy or a populated list — rather + /// than hanging or crashing. Resilient to whatever the account's blocklist + /// holds at run time (the standing account is never mutated by this test). + func assertLoaded(timeout: TimeInterval = 20, from testCase: BaseUITestCase) { + XCTAssertTrue( + navigationBar.waitForExistence(timeout: timeout), + "Expected the Blocked screen (navigation title 'Blocked')" + ) + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if emptyStateHeading.exists || list.exists { return } + Thread.sleep(forTimeInterval: 0.25) + } + XCTFail( + "Blocked screen never settled: expected the empty state or a populated list. On screen: [\(testCase.visibleText())]" + ) + } +} diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index 65da219c..47c25fe8 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -25,6 +25,10 @@ struct SettingsUIScreen { var addMoneyButton: XCUIElement { app.buttons["Add Money"] } var applicationLogsRow: XCUIElement { app.buttons["Application Logs"] } + /// The My Account row that opens the Blocked list. Present only when the + /// `enableBlocking` beta flag is on. + var blockedRow: XCUIElement { app.buttons["Blocked"] } + // MARK: - Actions /// Opens Settings from the main screen. diff --git a/FlipcashUITests/Support/Screens/TipsUIScreen.swift b/FlipcashUITests/Support/Screens/TipsUIScreen.swift new file mode 100644 index 00000000..4228f80f --- /dev/null +++ b/FlipcashUITests/Support/Screens/TipsUIScreen.swift @@ -0,0 +1,68 @@ +// +// TipsUIScreen.swift +// FlipcashUITests +// + +import XCTest + +/// Page object for the Tips sheet — the list of tip-DM conversations reached +/// from the ScanBottomBar's Tips tab (gated by the `enableTips` beta flag). +@MainActor +struct TipsUIScreen { + + private let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + /// The Tips tab on the ScanBottomBar. + var tab: XCUIElement { app.buttons["scan-tips-button"] } + + /// The always-present call to action at the top of the list — its presence + /// means the Tips list has rendered. + var tipcardButton: XCUIElement { app.buttons["show-my-tipcard-button"] } + + /// The Tips sheet's Close button. + var closeButton: XCUIElement { app.navigationBars["Tips"].buttons["Close"] } + + /// The tip-conversation rows. The list's first cell is the "Show My Tipcard" + /// row; every cell after it is a conversation, so the row buttons are the + /// cells' buttons past index 0. + private var conversationCells: [XCUIElement] { + Array(app.cells.allElementsBoundByIndex.dropFirst()) + } + + // MARK: - Actions + + /// Opens the Tips sheet from the main screen and waits for the list to load. + func open(from testCase: BaseUITestCase) { + testCase.waitAndTap(tab) + XCTAssertTrue( + tipcardButton.waitForExistence(timeout: 30), + "Expected the Tips list (the 'Show My Tipcard' button)" + ) + } + + /// The first tip conversation's row button, once at least one exists. Polls + /// because the conversations hydrate asynchronously after the sheet opens. + /// Returns `nil` when the account has no tip DM — the caller skips. + func firstConversationRow(timeout: TimeInterval = 15) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let cell = conversationCells.first { + let button = cell.buttons.firstMatch + if button.exists { return button } + } + Thread.sleep(forTimeInterval: 0.5) + } + return nil + } + + /// Closes the Tips sheet, returning to the main screen. + func close(from testCase: BaseUITestCase) { + testCase.waitAndTap(closeButton) + } +}