Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions FlipcashUITests/Regression/BlockedUsersGatingRegressionTests.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
156 changes: 156 additions & 0 deletions FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
43 changes: 43 additions & 0 deletions FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
51 changes: 51 additions & 0 deletions FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift
Original file line number Diff line number Diff line change
@@ -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())]"
)
}
}
4 changes: 4 additions & 0 deletions FlipcashUITests/Support/Screens/SettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions FlipcashUITests/Support/Screens/TipsUIScreen.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}