diff --git a/.claude/rules/codestyle/design-system.md b/.claude/rules/codestyle/design-system.md new file mode 100644 index 0000000000..99b4534e6b --- /dev/null +++ b/.claude/rules/codestyle/design-system.md @@ -0,0 +1,172 @@ +--- +description: Design-system generations (DS1/DS2/DS3), component pattern, KDoc & API conventions, storybook +paths: + - "core/ui/src/main/java/com/tangem/core/ui/ds2/**" + - "core/ui/src/main/java/com/tangem/core/ui/ds/**" + - "core/ui/src/main/java/com/tangem/core/ui/components/**" + - "features/tester/**" +--- + +# Design System + +The app currently hosts **three generations of the design system (DS)** side by side. They differ by +folder, token set (colors / typography / dimensions), and the `@Preview` wrapper. Knowing which +generation a component belongs to is essential so you don't mix tokens or pull the wrong building blocks. + +## Three generations + +| Generation | Folder | Colors | Typography | Dimensions | Preview wrapper | +|---|---|---|---|---|---| +| **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` | +| **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | +| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | + +> Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**. +> The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`). + +- **DS1** — the entire current app is built on it. Do **not** add new components here. +- **DS2** — redesign components. A transitional generation; don't write new components in it, only + maintain what already exists. +- **DS3** — the newest design system; **the whole app is being migrated to it**. Build new DS + components here. + +## Using DS3 in features + +**All DS3 components (folder `ds2`) may be used in features starting from app version 6.0.** Before +6.0 they must not be used on product screens. + +If a needed component does not yet exist in DS3, **add it by analogy with the existing ones** (see the +pattern below). + +## DS3 component pattern + +Study the existing components as references: +- Simple: `ds2/checkbox/TangemCheckmark.kt` — single file, a public `@Composable` function + `@Preview`. +- Composite: `ds2/button/` — `TangemButton.kt` (public API), `TangemButtonInternal.kt` (private inner + layout), `TangemButtonExt.kt` (variant / size tokens). + +Pattern rules: + +1. **Package & location.** `com.tangem.core.ui.ds2.`, folder + `core/ui/.../ds2//`. The component name is `Tangem`. +2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`, + dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors + are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`). +3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first + among the optional params or right after the required ones). Express variants/sizes via a nested + `enum` in `object Tangem` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags. +4. **Accessibility.** Pass `contentDescription`, set the `Role`, mark `disabled()` in `semantics`, and + handle focus/press state via `interactionSource`. +5. **KDoc + Figma link.** Above the public function — KDoc describing behavior, every parameter, and a + link to the Figma node (see the KDoc requirements below). +6. **Previews.** Two `@Preview`s (Light + Dark via `UI_MODE_NIGHT_YES`), wrapped in + `TangemThemePreviewRedesign { ... }`, with `TangemTheme.colors3.bg.primary` as the background. + Preview helpers (`PreviewRow`, `Section`, etc.) are private in the same file. +7. **Composite components** (many variants / heavy layout) are split into 3 files like the button: + public `Tangem.kt`, private `TangemInternal.kt`, tokens `TangemExt.kt`. + +## API conventions + +### Public properties live in the `object` + +Any public type the component exposes — variant/size/role/align enums, status classes, constants — +is declared inside the namesake `object Tangem`, **not** as a top-level type. This keeps a single +`Tangem.Variant` / `Tangem.Size` / `Tangem.Role` namespace at the call site and +avoids polluting the package. + +```kotlin +object TangemTopNavigation { + /** Horizontal alignment of the center content slot. */ + enum class ContentAlign { Start, Center } +} +// usage: TangemTopNavigation.ContentAlign.Center +``` + +References: `TangemTopNavigation.ContentAlign`, `TangemNavigationText.Role`, `TangemButton.Variant` / +`TangemButton.Size`. + +### Provide convenient overloads + +A component should ship ergonomic overloads so callers don't assemble boilerplate for the common case. +Two acceptable shapes: + +1. **Additional `@Composable fun` overloads** with simpler parameters that delegate to the base one. + `TangemTopNavigation` has a low-level slot-based overload (`startButton`/`endButton`/`contentColumn` + lambdas) plus several high-level overloads taking `title` / `subtitle` / `onBack` / `onClose` that + wire the predefined buttons and the title/subtitle center for you. +2. **Extension functions on the `object`** for named presets — e.g. `@Composable fun TangemButton.Back(…)` + and `TangemButton.Close(…)` in `TangemButtonExt.kt` expose ready-made button presets while reading + as `TangemButton.Back { … }` at the call site. + +Each overload keeps the same rules as the base component (`modifier` first among optionals, DS3 tokens, +its own KDoc — see below). + +### Sub-components are first-class + +Internal building blocks that are themselves public (e.g. `TangemNavigationText`, used for the +`TangemTopNavigation` title/subtitle slots) follow the **exact same rules** as a top-level component: +DS3 tokens only, `modifier: Modifier = Modifier`, public properties in their own `object` +(`TangemNavigationText.Role`), full KDoc, and their own Storybook entry where it makes sense. Don't +treat "helper" composables as second-class — if a feature can call it, it is a documented DS component. + +## KDoc requirements for components + +Every public DS component (and any non-trivial public composable) must carry a KDoc block. Use +`ds2/button/TangemButton.kt` and `ds2/checkbox/TangemCheckmark.kt` as the canonical examples. + +A component KDoc must contain, in order: + +1. **Summary line.** One sentence stating what the component is and which generation it belongs to — + start with `Design-system v2 …` for DS3 components (matches the existing wording). +2. **Figma link.** A markdown link to the exact Figma node: + `[Figma](https://www.figma.com/design/…?node-id=…)`. A component without a Figma reference is not + review-ready. +3. **Behavior notes** (when behavior is non-obvious). A short prose paragraph or a bulleted + `Behavior notes:` list covering state-dependent rendering — loading, disabled/enabled, icon-only + vs. labeled, focus ring, animations, what overrides what. Describe *observable behavior*, not the + implementation. +4. **`@param` for every parameter.** No parameter may be left undocumented — including `modifier` + when its effect is non-trivial (e.g. "Pass `Modifier.fillMaxWidth()` to switch to fixed-width + layout"). Each `@param` states the meaning **and** the consequences of notable values + (`null` → non-interactive, `false` → dimmed & clicks ignored, etc.). +5. **Accessibility guidance** where relevant — e.g. when `contentDescription` should be supplied + (icon-only buttons, loading state, disabled state) and what it announces. + +Additional rules: + +- Document the **nested `enum`s** (`Variant`, `Size`, `Status`, …) too: a short KDoc on the enum and, + where the options aren't self-explanatory, a one-line description per entry (see `TangemButton.Variant`). +- Keep KDoc about **contract and behavior**, not internals. Implementation comments explaining *why* + a specific approach was taken belong to inline `//` comments inside the body, not the KDoc. +- Reference other DS types with `[TangemSurface]` / `[TangemButton.Variant]` link syntax so they + resolve in the IDE. +- Detekt enforces missing-KDoc-on-public-API style checks on `core:ui`; run `./gradlew :core:ui:detektMain`. + +## Storybook + +Add every DS3 component to the **Storybook** (module `features/tester`) — a live on-device/emulator +component gallery (Tester → Storybook → DS Components). + +Use the **`add-storybook-component`** skill — it wires the entity, the Build factory, the Composable +page, and registers it in the correct list. Run: `/add-storybook-component TangemCheckmark (DS)`. +Page layout guidelines live in +`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md`. + +## Checklist: adding a new DS3 component + +- [ ] Component created under `core/ui/.../ds2//`, package `com.tangem.core.ui.ds2.`. +- [ ] Named `Tangem`; first optional parameter is `modifier: Modifier = Modifier`. +- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews. +- [ ] Variants/sizes expressed as an `enum` inside `object Tangem` (not a set of boolean flags). +- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem`. +- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets). +- [ ] Public sub-components (e.g. `TangemNavigationText`) follow the same rules + KDoc as a full component. +- [ ] States handled: enabled/disabled, press/focus (`interactionSource`), loading (if applicable). +- [ ] Accessibility: `contentDescription`, `Role`, `disabled()` in `semantics`. +- [ ] KDoc per the requirements above (summary + Figma link + behavior notes + every `@param` + a11y). +- [ ] Two `@Preview`s (Light/Dark) in `TangemThemePreviewRedesign`, background `colors3.bg.primary`. +- [ ] Heavy component split into `Tangem.kt` / `…Internal.kt` / `…Ext.kt`. +- [ ] Storybook page added (`add-storybook-component` skill). +- [ ] Detekt passes: `./gradlew :core:ui:detektMain` (plus + `./gradlew :features:tester:impl:assembleGoogleDebug` if you touched the Storybook). +- [ ] Use in product features only from app version **6.0** onward. \ No newline at end of file diff --git a/.claude/rules/unit-testing.md b/.claude/rules/unit-testing.md new file mode 100644 index 0000000000..bf8adfe0b6 --- /dev/null +++ b/.claude/rules/unit-testing.md @@ -0,0 +1,214 @@ +# Unit Testing Rules + +This document covers **unit tests** only — sources under `src/test`, running on the JVM via JUnit 5 (Jupiter). UI / instrumentation tests (`src/androidTest`, Kaspresso + Espresso on the JUnit 4 on-device runner) are a separate concern and out of scope here. + +## Stack + +| Purpose | Library | Version source | +|---|---|---| +| Test runner | JUnit 5 (Jupiter) | `deps.test.junit5` | +| Mocking | MockK | `deps.test.mockk` | +| Flow testing | Turbine | `deps.test.turbine` | +| Assertions | Google Truth | `deps.test.truth` | +| Coroutines | `kotlinx-coroutines-test` | `deps.test.coroutine` | + +All unit tests run on JUnit 5. JUnit 4 (`deps.test.junit` = `junit:junit`) is **not** used in `src/test` at all — it survives only in `src/androidTest` instrumentation. Don't add new JUnit 4 unit tests. + +Versions live in `gradle/dependencies.toml`. Do not hardcode library coordinates in module build scripts — always go through the catalog. + +## Shared test modules + +Depend on these via `testImplementation(projects.*)` — never copy their utilities inline. + +Build test fixtures with **factory functions that default every argument** (`createXxx(id = 1, name = "Cat", … )`) rather than calling bloated constructors at each call site. A test then overrides only the fields relevant to it, so the intent stays visible and adding a model field doesn't churn every test. This is the idiom behind the `Mock*Factory` classes below — extend them instead of hand-rolling fixtures. + +### `:test:core` (pure JVM) +`test/core/src/main/java/com/tangem/test/core/`. Re-exports as `api`: `test.coroutine`, `test.junit5`, `test.mockk`, `test.truth`, `test.turbine`. Use it as the one-line entry point to pull the whole unit-testing stack into a JVM module. Depends on `domain:core` and `arrow.core` (so its utilities can reference domain abstractions like `FlowProducer`). + +Utilities: +- `TestCoroutineExt.getEmittedValues(flow)` — collect a `Flow` into a `List` from a `TestScope`. +- `TestFlowProducerTools(scope, dispatcher)` — test double for `FlowProducerTools` that mirrors production `DefaultFlowProducerTools` (retryWhen + fallback + `distinctUntilChanged` + `shareIn`) on a caller-provided test scope/dispatcher, without analytics/logging. Pass `TestScope.backgroundScope` + a dispatcher built from `testScheduler` so the 2s retry delay is virtual-time-controllable. Use it for `FlowProducer` tests instead of mocking `FlowProducerTools`. +- `@ProvideTestModels` — meta-annotation over JUnit 5 `@MethodSource("provideTestModels")` for parameterized tests. +- `TruthArrowExt` — `assertEither`, `assertEitherRight`, `assertEitherLeft`, `assertSome`, `assertNone` for Arrow types. + +### `:common:test` (Android library — legacy, being retired) +`common/test/src/main/java/com/tangem/common/test/`. Factories and fakes for domain/data models. Being phased out in favour of `:test:core` (JVM mechanisms) and `:test:mock` (mock factories); don't add new utilities here. + +- `TestAppCoroutineScope(testScope)` — test implementation of `AppCoroutineScope`. +- `MockStateDataStore` — in-memory `DataStore` for tests. +- `Mock*Factory` classes for `CryptoCurrency`, `UserWallet`, `NetworkStatus`, `ScanResponse`, `YieldDTO`, `QuoteResponse`, `UpdateWalletManagerResult` etc. + +### `:test:mock` +`test/mock/`. Mock data for models not yet covered elsewhere (currently `MockAccounts`). Add to this module rather than creating new ad-hoc mock files. + +## Dispatchers + +Never use `Dispatchers.Main`/`IO`/`Default` directly in production code — always inject `CoroutineDispatcherProvider` from `core/utils`. + +In tests, override with `TestingCoroutineDispatcherProvider` (defined in `core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt`). By default `main`/`mainImmediate`/`io`/`default` are `Dispatchers.Unconfined`, while `single` is a single-thread `Executors.newFixedThreadPool(1)` dispatcher. + +For `Model`-layer tests inside features, construct it with a single `StandardTestDispatcher(testScheduler)` for all five roles (built from the enclosing `TestScope`) so `advanceUntilIdle()` controls execution. See any `features/*/impl` model test for the `TestScope.createTestingCoroutineDispatcherProvider()` helper. + +## Naming & placement + +- **Test class**: `FooTest` (singular noun). Not `FooSpec`, not `FooBehavior`, not `FooTests`. +- **Test method**: backtick-quoted sentence that **must** follow `GIVEN … WHEN … THEN …` (uppercase). The name states the behaviour under test — precondition, action, expected outcome — not the implementation. + ```kotlin + @Test + fun `GIVEN currency status emitted WHEN model created THEN analytics sent`() = runTest { … } + ``` + A part may collapse when trivial (e.g. `GIVEN no wallets WHEN load THEN returns empty`), but all three keywords stay present. +- **Test body**: if the body is more than a one-liner (i.e. has distinct setup / action / check phases), it **must** be marked with `// Arrange`, `// Act`, `// Assert` comments. GWT names the behaviour from the outside; AAA structures the code inside. +- **Location**: mirrored packages under `src/test/kotlin/`. No `src/testFixtures/` — shared helpers go to the modules above. + +## Unit-test skeleton (JUnit 5) + +```kotlin +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FooTest { + + private val barUseCase: BarUseCase = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val foo = Foo(barUseCase, dispatchers) + + @BeforeEach + fun resetMocks() { + clearMocks(barUseCase) + } + + @Test + fun `GIVEN bar returns right WHEN invoke THEN emits value`() = runTest { + // Arrange + coEvery { barUseCase(any()) } returns Either.Right(expected) + + // Act + val actual = foo.invoke(input) + + // Assert + assertThat(actual).isEqualTo(expected) + coVerify(exactly = 1) { barUseCase(input) } + } +} +``` + +- `@TestInstance(Lifecycle.PER_CLASS)` is **opt-in per class, not the project default.** Add it only where you need a non-static `@MethodSource`/`provideTestModels` provider or want to share expensive setup across methods (~half of test classes do). The JUnit default stays `PER_METHOD` (a fresh instance per test). Beware: `PER_CLASS` reuses one instance across all methods, so mutable fields leak between tests — reset them in `@BeforeEach`. +- **Group by method under test.** When a class/file exposes several functions and each accumulates many tests, don't keep one flat list — give each function its own `@Nested @TestInstance(Lifecycle.PER_CLASS) inner class`. The nesting maps the test structure onto the production API and keeps per-function setup local to its group. + ```kotlin + internal class DesignControllerTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetDesigns { + @Test fun `GIVEN … WHEN getDesigns THEN all fields included`() { … } + @Test fun `GIVEN limit WHEN getDesigns THEN list is capped`() { … } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class DeleteDesign { + @Test fun `GIVEN existing id WHEN deleteDesign THEN removed from db`() { … } + } + } + ``` +- You do **not** declare `useJUnitPlatform()` per module — the `configuration` convention plugin applies it (and the JUnit 5 engine) to every module. See "Gradle wiring" below. + +## MockK conventions + +- Field-level init: `private val x: T = mockk()`; use `mockk(relaxed = true)` only when stubs are not the subject of the test. +- Stub coroutines with `coEvery { … } returns …` / `returnsMany(...)`; verify with `coVerify { … }`, `coVerify(exactly = n) { … }`, `coVerifyOrder { … }`. +- Create mocks once as `val` fields and reset them with `clearMocks(...)` in `@BeforeEach` — recreating mocks (`x = mockk()` inside `@BeforeEach`) every test is measurably expensive (MockK instantiation dominates the runtime of small tests). Only recreate a field when the subject-under-test itself holds mutable state that must be fresh per test. +- For companion/top-level objects use `mockkObject(Obj)` and pair with `unmockkObject(Obj)` in teardown. + +## Flow testing + +- Default to `TestScope.getEmittedValues(flow)` (from `:test:core`) when you just want the list of values produced during the test scope — this is the most common approach in the codebase. +- Use **Turbine** (`flow.test { … }`) when you specifically need to assert on the emission *sequence* (ordering, intermediate items, completion/error timing), or for hot `SharedFlow`s where you must control collection start/stop. +- Drive hot sources via `MutableSharedFlow` / `MutableStateFlow` and `advanceUntilIdle()` between emission and assertion. +- For `FlowProducer` tests (retry/fallback/shareIn semantics), inject `TestFlowProducerTools` from `:test:core` and use Turbine + `advanceTimeBy(2001); runCurrent()` to step over the 2s retry window deterministically. + +## Parameterized tests + +When the same behaviour is exercised over a set of inputs, write **one parameterized test** — not several near-identical methods, and not one method with a stack of `assertThat(...)` calls over different inputs. Repeated asserts hide *which* input failed and stop at the first failure; a parameterized test reports each case separately. Add a new case = add a row to the provider. + +Use the project's `@ProvideTestModels` annotation — it wires `@MethodSource("provideTestModels")` for you. + +```kotlin +@ParameterizedTest +@ProvideTestModels +fun create(model: CreateModel) = runTest { … } + +private data class CreateModel(val input: Input, val expected: Either) + +private fun provideTestModels() = listOf( + CreateModel(input = …, expected = Either.Right(…)), + CreateModel(input = …, expected = Either.Left(Error.Foo)), +) +``` + +`provideTestModels` is a non-static instance method, so the class needs `@TestInstance(Lifecycle.PER_CLASS)` (or a `@JvmStatic` provider in a companion). + +## Assertions + +- Default to Truth: `assertThat(actual).isEqualTo(expected)`, `.isInstanceOf(T::class.java)`, `.hasMessageThat().isEqualTo(…)`, `.isNull()`. +- **Assert whole objects, not field-by-field.** When the type is a `data class`, build the expected instance and compare with one `isEqualTo(expected)` — the structural `equals`/`toString` gives a self-explanatory diff. For collections use `.containsExactly(…)` (add `.inOrder()` when order matters). Prefer this over a series of `assertThat(actual.id)…`, `assertThat(actual.name)…` checks, which produce opaque failures and miss unexpected fields. +- For Arrow `Either`/`Option`, prefer `assertEither`, `assertEitherLeft`, `assertEitherRight`, `assertSome`, `assertNone` from `:test:core`. +- Exception testing: `runCatching { … }.exceptionOrNull()` + Truth, not `assertThrows`. + +## Feature model tests + +`features/*/impl` Decompose models share a heavy dependency graph — extract a `XxxModelTestBase` with pre-built mocks/fixtures and inherit per-scenario test classes from it (see `features/staking/impl/.../presentation/model/StakingModelTestBase` as reference). + +Lifecycle: +```kotlin +val model = createModel(testScope = this) +advanceUntilIdle() +// assertions… +model.onDestroy() +``` + +## Running tests + +```bash +./gradlew unitTest # all JVM + debug/googleDebug unit tests (root aggregator) +./gradlew ::testDebugUnitTest # single Android library module +./gradlew :app:testGoogleDebugUnitTest # app module +./gradlew ::test # pure JVM module +./gradlew ::testDebugUnitTest --tests "com.tangem.Test" # single class +``` + +The `unitTest` aggregator lives in the root `build.gradle.kts`; it is wired automatically for every `com.android.application`, `com.android.library`, and pure `org.jetbrains.kotlin.jvm` subproject — no need to touch it when adding a new module. + +## Gradle wiring for a new test-bearing module + +The `configuration` convention plugin (`configureUnitTests` in `plugins/configuration/.../TestConfigurations.kt`) centralizes the JUnit 5 setup for **every** module: + +1. `useJUnitPlatform()` on all `Test` tasks — so Jupiter tests are discovered (without it the default JUnit 4 runner runs zero Jupiter tests). +2. `testRuntimeOnly()` — the Jupiter runtime engine. The platform without the engine silently runs **zero** tests, so these two are paired in one place. +3. Test logging (full exception format, standard streams, PASSED/SKIPPED/FAILED events, per-task summary). + +So a test module must **not** re-declare `useJUnitPlatform()`, the engine, or `testLogging { … }`. It only needs the Jupiter **API** (provided transitively by `:test:core`, or declared explicitly): + +```kotlin +// Any module (JVM or Android library) — plugin already supplies platform + engine + logging +plugins { + alias(deps.plugins.kotlin.jvm) // or the android-library convention + id("configuration") +} + +dependencies { + testImplementation(projects.test.core) // junit5 (api) + mockk + turbine + truth + coroutine-test + testImplementation(projects.common.test) // add only if the tests need legacy model factories / fakes +} +``` + +If a module doesn't want the full `:test:core` bundle, declare the Jupiter API directly with `testImplementation(deps.test.junit5)` — the engine still comes from the plugin, so never add `testRuntimeOnly(deps.test.junit5.engine)` per module. + +## Module type vs. layer + +The domain layer is **not** uniformly pure-JVM: domain modules are split roughly evenly between `org.jetbrains.kotlin.jvm` (pure JVM) and `com.android.library` modules. Don't assume the layer dictates the module type — check the `plugins { }` block to pick the right test task: + +- `kotlin.jvm` (pure JVM) → `./gradlew ::test` +- `com.android.library` / `com.android.application` → `./gradlew ::testDebugUnitTest` (`:app` → `testGoogleDebugUnitTest`) + +`./gradlew unitTest` runs the right task for every module regardless of type. \ No newline at end of file diff --git a/.claude/skills/add-storybook-component/SKILL.md b/.claude/skills/add-storybook-component/SKILL.md new file mode 100644 index 0000000000..1a85fa944c --- /dev/null +++ b/.claude/skills/add-storybook-component/SKILL.md @@ -0,0 +1,150 @@ +--- +name: add-storybook-component +description: Add a component showcase page to the Tangem storybook (in features/tester). Wires the entity, Build factory, Composable page, and registers it either in the "DS Components" sub-list (first/default target — for design-system components under core.ui.ds2.*) or in the root storybook list (second target — for any other component). Use when asked to "add a storybook page/story", "add to the storybook", "сделай сторибук для <компонент>", "добавь стори/историю в storybook", or to showcase a DS component in the tester. +allowed-tools: Read, Grep, Glob, Bash, Edit, Write +argument-hint: [component to add, e.g. "TangemCheckbox (DS)" or "MyLegacyCard"] +--- + +Add a new component page to the Tangem storybook. The storybook lives in +`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/` +and renders interactive DS/component showcases on a device or emulator. + +This is an **interactive** skill: read the real production component first to get its actual +parameters, enums, and package — never guess the API. Then mirror the closest existing story. + +## Two placement targets — pick one + +| Target | Use for | List screen | Page dir | Entity supertype | +|---|---|---|---|---| +| **1. DS Components (default)** | Design-system components under `com.tangem.core.ui.ds2.*` (the newest "DS3"/redesign components: `TangemButton`, `TangemBadge`, `TangemRow`, `TangemLoader`, …) | `page/ds/DsComponentsListScreen.kt` → `buildDsStories()` | `page/ds//` | `DsStoryBookPage` | +| **2. Other components** | Anything else (legacy/cross-cutting components, backgrounds, effects, typography demos) | `ui/StoryBookListScreen.kt` → `buildStories()` | `page//` | `StoryBookPage` | + +**Default to Target 1 (DS Components)** when the component lives under `core.ui.ds2.*` or the user +mentions "DS"/"ds3"/"design system". Only the **list screen** and **page directory** differ between +the two targets — everything else (entity declaration file, `StoryBookScreen.kt` routing, factory +pattern) is identical. + +> The ONLY behavioral difference of `DsStoryBookPage` vs `StoryBookPage`: `StoryBookViewModel.onBackClick` +> routes a `DsStoryBookPage` back to the DS sub-list, while a plain `StoryBookPage` routes back to the +> root list. That's it. + +## Reference + +`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md` is the canonical doc — read it for the **design guidelines** (mandatory +page layout: single live preview pinned at top + one control per parameter below, chip-selector pattern, +colors, realistic text). This skill covers the *wiring*; STORYBOOK.md covers the *look*. + +Best reference implementations to mirror: +- **Stateful DS page with many controls:** `page/ds/button/` (TangemButton — variant/size/background + selectors, toggles, text-scale slider, blur backdrop). Read all three files: `Build.kt`, + `TangemButtonStory.kt`, and the `TangemButtonStory` entity in `entity/StoryBookPage.kt`. +- **Simple stateful page:** `page/ds/loader/` (TangemLoader — single size selector). +- **Stateless page (no params):** a `data object` sibling such as `ButtonsStory`. + +## Workflow + +1. **Read the production component.** Grep `core/ui/src/main/java/com/tangem/core/ui/ds2//` + (or wherever it lives) for the composable signature, its `enum`s (Variant/Size/Status/…), and + required vs optional params. The set of parameters becomes the set of controls. +2. **Decide stateless vs stateful:** + - **Stateless** (`data object`) — ONLY if the component has no configurable parameters at all. + - **Stateful** (`data class`) — the normal case: one field per parameter the user can change, each + paired with an `onXxxChange`/`onXxxToggle` lambda. +3. **Pick the target** (see table above) and **mirror the closest sibling**. +4. **Do the 4 edits + 1 new dir** (Steps A–E below). +5. **Verify it compiles** (see Build). + +## The edits + +Assume component `Foo` rendered by `com.tangem.core.ui.ds2.foo.TangemFoo` with a `Variant` enum and an +`isEnabled` flag. Adjust names to the real component. `` = +`page/ds/foo/` for Target 1, or `page/foo/` for Target 2. + +### A. Declare the entity in `entity/StoryBookPage.kt` + +Stateful (normal): +```kotlin +internal data class TangemFooStory( + val variant: TangemFoo.Variant, + val isEnabled: Boolean, + val onVariantChange: (TangemFoo.Variant) -> Unit, + val onEnabledToggle: () -> Unit, +) : DsStoryBookPage // <- StoryBookPage for Target 2 +``` +Stateless: `internal data object TangemFooStory : DsStoryBookPage` (or `StoryBookPage`). + +Add the matching import for the production type at the top of the file. + +### B. Create `/Build.kt` + +Stateful — uses `storyPageFactory` + `StateUpdater`: +```kotlin +internal fun StateUpdater.build(): TangemFooStory { + return TangemFooStory( + variant = TangemFoo.Variant.Primary, + isEnabled = true, + onVariantChange = { v -> updateStory { it.copy(variant = v) } }, + onEnabledToggle = { updateStory { it.copy(isEnabled = !it.isEnabled) } }, + ) +} + +internal val tangemFooStoryFactory + get() = storyPageFactory(StateUpdater::build) +``` +Stateless: `internal val tangemFooStoryFactory: StoryPageFactory = StoryPageFactory { TangemFooStory }` + +### C. Create `/TangemFooStory.kt` + +`@Composable internal fun TangemFooStory(state: TangemFooStory, modifier: Modifier = Modifier)` +(drop `state` for stateless). Follow STORYBOOK.md design guidelines: live preview pinned at the top +in a `Column`, controls scrolling below. Reuse the chip-selector / toggle-row patterns from +`page/ds/button/TangemButtonStory.kt` (its `Section`, `ChipGrid`, `Chip`, `ToggleRow` are private — +copy the ones you need into the new file). Use representative text, not "Btn". + +### D. Register routing in `ui/StoryBookScreen.kt` + +Add both imports (entity + page composable share the simple name — Kotlin resolves them by position): +```kotlin +import com.tangem.feature.tester.presentation.storybook.entity.TangemFooStory +import com.tangem.feature.tester.presentation.storybook.page.ds.foo.TangemFooStory +``` +Add a branch to the `when (storyState)`: +```kotlin +is TangemFooStory -> TangemFooStory(state = storyState) // stateless: TangemFooStory -> TangemFooStory() +``` + +### E. Register in the list screen (target-specific) + +- **Target 1 (DS):** in `page/ds/DsComponentsListScreen.kt` add the factory import and a row to + `buildDsStories()`: + ```kotlin + DsStoryItem(title = "🔘 TangemFoo", factory = tangemFooStoryFactory), + ``` +- **Target 2 (other):** in `ui/StoryBookListScreen.kt` add the factory import and a row to + `buildStories()`: + ```kotlin + StoryItem(title = "🔘 Foo", factory = tangemFooStoryFactory), + ``` + +**Every title must start with an emoji** matching the component category (🔘 buttons, 🏷️ badge, +📋 row, ⏳ loader, 🔤 typography, 🔍 search, 🧭 navigation, 💀 placeholder, ✨ effects, 🪙 token…). + +## Build + +```bash +./gradlew :features:tester:impl:assembleGoogleDebug +``` +Detekt runs via the convention plugin; keep `@file:Suppress("MagicNumber")` on showcase files that use +literal dp/colors (the button story does this). Then run the app, open Tester → Storybook → (DS +Components →) your entry, and confirm the preview + every control works. + +## Checklist + +- [ ] Read the real component; every meaningful parameter has a control. +- [ ] Entity in `StoryBookPage.kt` extends the correct supertype (`DsStoryBookPage` for DS, else `StoryBookPage`). +- [ ] `Build.kt` factory name is `StoryFactory`. +- [ ] Page composable shares the entity's simple name; both imported in `StoryBookScreen.kt`. +- [ ] `when` branch added in `StoryBookScreen.kt` (`is` prefix for stateful, bare for stateless). +- [ ] Registered in the correct list screen with an emoji-prefixed title. +- [ ] Live preview pinned at top, controls below (STORYBOOK.md layout rule). +- [ ] `:features:tester:impl:assembleGoogleDebug` passes. \ No newline at end of file diff --git a/.claude/skills/analyze-logs/SKILL.md b/.claude/skills/analyze-logs/SKILL.md index 4ecc6e0104..f26b769abd 100644 --- a/.claude/skills/analyze-logs/SKILL.md +++ b/.claude/skills/analyze-logs/SKILL.md @@ -2,7 +2,7 @@ name: analyze-logs description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation. allowed-tools: Read, Grep -argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] +argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit] --- Analyze the Tangem app user log file at path: `$ARGUMENTS` @@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev - `MainActivity.*onNewIntent` — deep link or push notification - `CardSDK_Session.*start card session` — NFC session starts +**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):** + +Skip this entire group if `--no-secrets-audit` is in arguments. + +- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}` +- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}` +- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+` +- Authorization header: `(?i)authorization:\s*\S+` +- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"` +- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"` +- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}` +- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}` +- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b` +- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken + **Error filtering:** When processing error results, skip these noisy matches: - `java.io.IOException: Canceled` — normal request cancellation - `HttpException(code=304` — HTTP "Not Modified" - Bare stacktrace lines starting with `\tat` - `<-- HTTP FAILED: java.io.IOException: Canceled` +### Step 6.5: Masking Consistency Check + +Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep). + +1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked +2. For each unique `host + path-prefix-before-mask`, derive the prefix string +3. For each prefix, Grep the prefix followed by a non-`*` character (`[^*\s]`, head_limit: 20) + - If hits found → masking inconsistency: same endpoint has both masked and unmasked variants + - Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number + ### Step 7: Deep Dive For each significant error found above: @@ -207,6 +232,37 @@ Structure your report EXACTLY as follows: |------|-------|---------| (chronological: app starts, card sessions, navigation, errors, notable API calls) +## Secrets & PII Audit + +Omit this section entirely if `--no-secrets-audit` was passed. + +### Health Check +- Total masked tokens (`******`) in log: **N** +- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken" + +### Confirmed Leaks (CRITICAL / HIGH) +| Line | Severity | Type | Matched (first 16 chars + `…`) | Context | +|------|----------|------|--------------------------------|---------| + +### Masking Inconsistencies +| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line | +|-----------------|-------------|---------------|---------------------| + +### Suspected Leaks (MEDIUM / LOW) +| Line | Severity | Type | Pattern Matched | Why Suspect | +|------|----------|------|-----------------|-------------| + +**Severity legend:** +- **CRITICAL** — private key / mnemonic / xprv in clear text +- **HIGH** — API key / bearer / JWT / card_public_key visible +- **MEDIUM** — push token, card_id, persistent identifiers +- **LOW** — heuristic patterns that may be false positives (tx hash, content hash) + +**Output rules:** +- Never include the full matched value — always truncate to 16 chars + `…` +- For LOW severity, add a "Why Suspect" column explaining typical false positives +- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto` + ## Analysis Summary (2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations. If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.) diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md new file mode 100644 index 0000000000..7508749a1f --- /dev/null +++ b/.claude/skills/write-ui-test/SKILL.md @@ -0,0 +1,160 @@ +--- +name: write-ui-test +description: Write a Kaspresso/Compose instrumentation UI test for the Tangem Android app following project conventions — test class shape, page-object locations, Allure step naming, WireMock scenario setup, synchronization, and meaningful assertions. Covers known Compose traps (PullToRefreshBox swipe, TangemHoldToConfirmButton, Decompose lifecycle, hot-wallet access code) and the build/run/debug flow. Use when the user asks to write, add, port, or fix an instrumentation / androidTest / UI test, a page object, or a test scenario ("напиши UI-тест", "добавь инструментальный тест", "напиши тест в androidTest", "page object", "автотест на экран"). +allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Agent +argument-hint: [screen/flow or TC# to cover, e.g. "TangemPay freeze card"] +--- + +Write an instrumentation (androidTest) UI test for the Tangem Android app. These conventions are +enforced by reviewers (tnagmetulla, dpodoynikov) — applying them up front skips a review round. + +This is an **interactive** skill: if scope is ambiguous (which screen, which flow, what the final +assertion should verify), ask before writing. Do not invent UI text or test tags — read the real +production source and reuse existing patterns. + +## When to use + +Use for instrumented UI tests under `app/src/androidTest/` (Kaspresso + Kakao-Compose), page objects, +and test scenarios. **Not** for JVM/Robolectric unit tests (`testDebugUnitTest`) — those follow a +different setup. + +## Workflow + +1. **Clarify scope.** Which screen/flow, which Allure TC#, and what the *final assertion* verifies. + Ask if any of these is unclear. +2. **Find an existing sibling test to mirror.** Grep `app/src/androidTest/` for a test on a similar + screen (e.g. `SendViaSwapTest`). Match its structure rather than inventing one. Read the real + production composable to get the actual `testTag`s and string resources — never guess UI text. +3. **Locate / extend page objects** in `com/tangem/screens/` (see Locations). Add new ones there, + never inside the scenario or test file. +4. **Set up WireMock scenarios** in the *test body* if the flow depends on backend state + (see `reference/running-and-debugging.md`). +5. **Write the test** per Conventions below. +6. **Build BOTH APKs, install, run, and classify the result** correctly — Allure post-run hook + failures are not test failures (see `reference/running-and-debugging.md`). + +## Porting a test from iOS + +When the user asks to **port** an iOS test to Android: + +- **Default to the sibling iOS repo `../tangem-app-ios/`** (next to `tangem-app-android`). If that path + doesn't exist, **ask the user** where the iOS repo is — don't guess. +- iOS UI tests live under `TangemUITests/`; look there for the source test, its page objects + (`*Screen`), and accessibility identifiers (`*AccessibilityIdentifiers`). +- Port the *intent and steps*, not the API. Map the iOS stack to the Android one: + XCUITest/accessibility identifiers → Compose `testTag`; iOS `*Screen` page objects → Kotlin page + objects in `com/tangem/screens/`; XCTest assertions → Kaspresso/Truth assertions. Re-derive the real + Android `testTag`s and string resources from production source — never reuse iOS identifier strings. +- **Card/wallet mock mapping — where iOS uses `wallet2`, Android uses the default `Wallet`** + (`openMainScreen()` with no `productType` → `ProductType.Wallet`). Do NOT port iOS `.wallet2` to + `ProductType.Wallet2`. Other cards map directly: iOS `.twin` → `ProductType.Twins`, `.xrpNote` → + `ProductType.Note`, `.four12` → `Firmware412MockContent` (via the `mockContent` param). `ProductType.Wallet2` + exists but is a distinct Wallet-2.0-card case, not the iOS-`wallet2` analog. +- **A wallet has no balance until you sync.** The default `Wallet` mock starts with missing derivations + ("Some addresses are missing"); the fiat balance shows `—` until you call `synchronizeAddresses()` after + `openMainScreen()` (mirror `TotalBalanceUpdateTest`). Any test asserting a balance/fiat-equivalent must + sync first, then `waitUntil` the value loads — balances re-load asynchronously (e.g. after an app-currency + change the equivalent repaints with a delay). +- The WireMock scenarios are usually shared across platforms, but the branch may differ + (see `reference/running-and-debugging.md`). + +## Conventions (must-follow) + +### Test class shape + +- **Test method names are camelCase and always end with `Test`** (e.g. `groupTokensTest()`, + `renameWalletTest()`). Never use `snake_case` and never the unit-test `GIVEN … WHEN … THEN …` + backtick phrasing — that GWT convention is for JVM unit tests only, not instrumentation tests. +- **Scenario state setup goes in the test body**, not inside the open-the-feature helper. Each test + starts with explicit `step("Set WireMock scenario '$name' to '$state'") { setWireMockScenarioState(name, state) }` + calls, then calls a thin helper (e.g. `openTangemPay()`) that only opens the screen. Mirror the + `SendViaSwapTest` pattern. +- **Open-the-feature helpers stay thin** — no scenarios-as-parameters, no scenario juggling inside. +- **Every scenario name + state is a `val`** at the top of the test method. Reviewers reject magic + strings inside `step(...)`. +- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which + click failed in the Allure report. +- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests + (e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a + private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a + `step(...)` like any scenario. +- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario + itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones + detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside + scenario extension functions; only `flakySafely` is restricted to the `TestCase` body. Caveat: don't + wrap a *mutating* scenario (e.g. one that long-clicks to sign+send) in `flakySafely` — a retry would + re-fire the action; rely on the assertion's own built-in retry instead. +- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert is displayed` / `is not displayed`. + Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` / + `is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it). +- **No conditional `if (foo.isDisplayedSafely()) foo.performClick()`** for elements that are + deterministically present after `pm clear` — the `if` is dead code. Use a straight `performClick()`. + +### Locations + +| What | Where | +|------|-------| +| Page objects | `app/src/androidTest/kotlin/com/tangem/screens/…` — **always** | +| Common test helpers | `app/src/androidTest/kotlin/com/tangem/common/utils/` | +| Feature scenarios | `app/src/androidTest/kotlin/com/tangem/scenarios/` | +| Cross-feature helper (e.g. `confirmSwapByHolding`) | the **feature-of-origin** scenarios file (e.g. `SwapScenarios.kt`), not the consumer's | + +Scenario files orchestrate flows; they must not define page objects or duplicate generic helpers. + +### Strings + +- **No hardcoded UI text** in matchers. Use `getResourceString(R.string.foo)` from + `com.tangem.core.res.R` or `com.tangem.core.ui.R`. The Detekt rule `UnsafeStringResourceUsage` + enforces this for production code; reviewers extend it to test code informally. + +### Allure IDs + +- **Every test method gets its own unique `@AllureId`.** Never reuse the same id across two test methods — + not even for two variants of one manual case. If a manual case is split into multiple automated tests + (e.g. a positive and a negative variant), each test must be linked to its own distinct Allure case/id. + (Note: iOS sometimes shares one id across methods — do NOT mirror that here.) + +### Assertions + +- **Never** use Kotlin's built-in `assert(...)` — Android instrumentation runs don't enable JVM + assertions, so `assert(false)` is a silent no-op. Use Truth / JUnit / Kaspresso / Kakao assertions. +- **Clipboard checks**: `assertClipboardTextEquals(expected, context)` from `common/utils/ClipboardUtils.kt`. + Read displayed text via `KNode.extractText()` first if you need to compare against UI state. +- **Every test ends with a meaningful assertion**, not just an action. A test whose last step is + "Click Submit" without verifying the result gets rejected. + +### Waits and synchronization + +- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even + if a bot reviewer suggests one. +- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of + uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`. +- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not + your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI + isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start + timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or + that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data + -memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean + 10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot. +- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the + same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for + appearance, `{ a exists || b exists }` for either/or). + +### Comment hygiene + +This repo enforces "no comments unless WHY is non-obvious", in test code too. One line max, WHY-only — +encode a hidden constraint, not what the code does. Example that earns its keep: +`// Create+confirm screens share ACCESS_CODE_INPUT — gate on confirm-screen title.` +Delete anything explaining WHAT a step does. + +## Reference docs + +- **`reference/compose-traps.md`** — read when the screen uses `PullToRefreshBox`, + `TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, a hot-wallet import with + an access code, or a target inside a **LazyColumn/LazyRow that may be below the fold** (use a + `KLazyListNode` matcher that auto-scrolls — never a manual swipe). These have silent failure modes that + look like passing tests. +- **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator + vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using + `@Ignore`, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails + silently (the app log in `files/log.txt`, and the WireMock journal). \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md new file mode 100644 index 0000000000..96e51a4384 --- /dev/null +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -0,0 +1,246 @@ +# Compose UI test traps + +Each of these has a **silent** failure mode: the gesture/action appears to run, the test stays green +(or fails for the wrong reason), but the intended behavior never fired. Diagnose with logcat network +traces or a semantics-tree snapshot, not by visually watching the swipe. + +## Material3 `PullToRefreshBox` + UiAutomator swipe = silent no-op + +`androidx.compose.material3.pulltorefresh.PullToRefreshBox` reacts to overscroll deltas via Compose's +`NestedScrollConnection` from the inner `LazyColumn`. UiAutomator's `device.swipe(x1,y1,x2,y2,steps)` +dispatches platform `MotionEvent`s; the `LazyColumn` receives them as an ordinary scroll, never +produces overscroll, and `onRefresh` never fires — regardless of `steps=30` (fling) or `steps=1000` +(slow drag). Confirmed by `NetworkLogs`: zero refresh calls after the UiAutomator swipe, vs. one +immediate call via the Compose Test API. + +**Use the Compose Test API:** + +```kotlin +composeTestRule.onNode(hasTestTag(SOME_TAG_INSIDE_THE_BOX)) + .performTouchInput { + swipeDown(startY = 0f, endY = visibleSize.height.toFloat() * 6f, durationMillis = 800) + } +``` + +The shared `pullToRefresh()` in `common/extensions/UiDeviceExt.kt` is UiAutomator-based and works for +*some* screens (a different refresh container), but **not** for Material3 `PullToRefreshBox`. When +porting a test, verify with a logcat network trace, not visual inspection. + +## `TangemHoldToConfirmButton` semantics are minimal + +The component exposes ONLY `TestTag`, `IsContainer`, `Shape` in Compose semantics — no `Disabled`, +`Role`, or `OnClick`. `assertIsEnabled()` / `assertHasClickAction()` are useless on it. + +`Modifier.holdToConfirmGestures(enabled, ...)` early-returns from `pointerInput` when `enabled=false`, +so the hold gesture is silently swallowed: the button looks fine, the user holds, nothing happens, +`onConfirm` never fires. + +**Diagnose "silently disabled" from a test:** +1. Snapshot the Compose semantics tree before the hold. +2. Perform the hold: `performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }`. +3. Snapshot again — byte-identical trees mean `onConfirm` didn't run. +4. Or check WireMock request stats for the downstream API call expected after `onConfirm`. + +## Asserting enabled/disabled on a `Modifier.clickable` row + +When a settings/list row puts `Modifier.clickable(enabled = isClickable, ...)` on the row **container** +(not the title `Text`), the enabled/disabled state lives on that container; the child Texts only carry +`testTag`/text. So `assertIsEnabled()` / `assertIsNotEnabled()` must target the container, matched by a +descendant text — not the title node itself. + +Match the container in BOTH states with **click-action OR disabled-semantics**. Do NOT rely on +`hasClickAction()` alone: depending on the Compose version a `clickable(enabled = false)` row may not +expose an onClick action, so a `hasClickAction()`-only matcher finds no node and `assertIsNotEnabled()` +fails with "No node found". + +```kotlin +import androidx.compose.ui.test.hasClickAction as withClickAction +import androidx.compose.ui.test.isNotEnabled as withDisabled + +val row: KNode = child { + addSemanticsMatcher(withClickAction() or withDisabled()) // matches enabled AND disabled rows + hasAnyDescendant(withText(getResourceString(R.string.row_title))) // narrows to the specific row + useUnmergedTree = true +} +// enabled card: row.assertIsEnabled() ; disabled card: row.assertIsNotEnabled() +``` + +## `assertTextContains(x)` defaults to exact-segment match, not substring + +`SemanticsNodeInteraction.assertTextContains(value, substring = false, ignoreCase = false)` defaults to +`substring = false` — it asserts that some text **segment of the node equals `value` exactly**. Matching +a symbol or fragment inside a larger string (e.g. `"€"` against a balance `"€108,474.21"`) silently +never matches and times out inside a `waitUntil`. Pass `substring = true`: + +```kotlin +totalBalanceText.assertTextContains("€", substring = true) +``` + +Reference tests that pass the *full* string (`assertTextContains("€108,474.21")`) work with the default, +which is why a copy-pasted matcher can mislead. + +## Kakao-Compose `child { }`: use DSL matchers, not raw Compose matcher aliases + +Inside a `child { … }` / `ComposeScreen` element builder, call the DSL methods (`hasText(...)`, +`hasTestTag(...)`, `hasAnyDescendant(...)`). A common alias is `import androidx.compose.ui.test.hasText +as withText` — but `withText(x)` as a **bare statement** inside the builder just creates a +`SemanticsMatcher` and discards it, registering nothing → `ViewBuilderException: Please set matchers for +your Element!` at run time. `withText`/raw matchers are only valid as *arguments* to a DSL method +(`hasAnyDescendant(withText(name))`), never as a standalone line. + +```kotlin +// WRONG — no matcher registered +fun walletNameValue(name: String) = child { withText(name); useUnmergedTree = true } +// RIGHT +fun walletNameValue(name: String) = child { hasText(name); useUnmergedTree = true } +``` + +## LazyList item below the fold: plain `child { }` finds it but can't click it + +A `child { hasTestTag(ITEM); hasAnyDescendant(withText(name)) }` matcher resolves the semantics node +even when the item is composed **off-screen** (LazyColumn keeps a few items past the viewport). But the +node isn't displayed, so `clickWithAssertion()` (`assertIsDisplayed()` first) fails, or `performClick()` +taps nothing. Symptom: the test passes when the item happens to be near the top and fails for items +lower in the list — and a manual swipe "fixes" it. Do **not** patch with a swipe (flaky, the +`clickableSingle` 500ms debounce can also eat fast programmatic clicks). + +**Whenever a target lives in a LazyColumn/LazyRow and might be below the fold, build a `KLazyListNode` +matcher up front** — `childWith` scrolls the list to the item before returning it: + +```kotlin +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode + +private val tokensList = KLazyListNode( + semanticsProvider = semanticsProvider, // primary-ctor param is in scope in initializers + viewBuilderAction = { hasTestTag(SomeScreenTestTags.LAZY_LIST) }, // the LazyColumn's OWN tag + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) }, +) + +@OptIn(ExperimentalTestApi::class) +fun tokenWithTitle(title: String): LazyListItemNode = + tokensList.childWith { + hasTestTag(SomeScreenTestTags.LAZY_LIST_ITEM) + hasText(title) + useUnmergedTree = true + } +``` + +Non-obvious points that bite: + +- **`childWith` searches the MERGED tree** (it scopes via the list's `viewBuilderAction`, whose + `useUnmergedTree` defaults to `false`). So match the item by `hasText(title)` — on a `MergeDescendants` + item the child texts aggregate onto the item node. `hasAnyDescendant(withText(...))` does **not** match + there. (`useUnmergedTree = true` on the item matcher is inert for the scroll/filter but harmless; keep + it to mirror existing page objects.) +- **The list needs its OWN `testTag` on the `LazyColumn`.** If production tags only the *items* (e.g. + `MARKETS_TOKENS_LIST_ITEM`) and not the container, add a tag to the `LazyColumn` modifier in the + production composable. Reuse the screen's existing `…TestTags.LAZY_LIST` constant when one fits. +- **Scope to the right list when several coexist.** Multiple LazyColumns with the same *item* tag can be + composed at once (e.g. the Add-Funds `ChooseTokenScreen` list AND the main-screen markets sheet, both + using `MARKETS_TOKENS_LIST_ITEM`). A bare top-level `child { hasTestTag(ITEM); … }` is then ambiguous + and may match the wrong screen. `childWith` (and `tokensList.child { … }`) scope through the container + tag via `onNode(LAZY_LIST)` / `hasAnyAncestor(LAZY_LIST)`, so they pick the intended list. Prefer a + unique container tag over hoping the item text is unique. +- **`childWith` returns a `LazyListItemNode`, not a `KNode`.** `clickWithAssertion()` was a `KNode` + extension; it's been generalized to `fun BaseNode<*>.clickWithAssertion()` (in + `common/extensions/KNode.kt`) so it works on both. Both types extend `BaseNode`, and + `assertIsDisplayed()`/`performClick()` live on `BaseNode`. +- `positionMatcher` is only used by `childAt(index)` / `hasLazyListItemPosition`. For `childWith` + (match-by-content) the items don't need to expose `LazyListItemPositionSemantics` — pass the matcher + anyway since the constructor requires it. + +Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). + +## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet) + +When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's +**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead — +expanding the sheet over the content, so the next click lands on the wrong element. + +- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a + `ScrollToIndex` action that does NOT engage nested scroll. +- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires + when already expanded, races the press, and back often falls through and quits the app. + +## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake + +Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an +auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so +`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or +`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a +slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not +by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator. + +For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most +are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's +server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test. +(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with +`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree +(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists +only in the *unmerged* tree — will never match it; poll through the page object instead. + +## Decompose model lifecycle vs. data refresh + +Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning +to a screen via `router::pop` does NOT re-fetch. A test that switches WireMock scenarios between an +action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the +stale in-memory data wins. + +## Terminal screen never reaches Compose-idle: self-feeding `StateFlow` loop + +A screen whose model writes a fresh state back into the same `StateFlow` it observes will recompose +forever, so **any** Compose/Espresso assertion on it times out with `ComposeNotIdleException` +(`autoAdvance=true`) or `AppNotIdleException` "last message = DispatchedContinuation target=Handler" +(`autoAdvance=false`). The classic shape (hit on the send-v2 `ConfirmSuccess` screen, [REDACTED_TASK_KEY]): + +```kotlin +combine(uiState, currentRoute) + .onEach { (state, _) -> callback.onResult(state.copy(navigationUM = NavigationUM.Content(onClick = { … }))) } + // callback writes back into uiState → emits again → onEach again → ∞ +``` + +`NavigationUM.Content` is a `data class` whose fields are **lambdas**, recreated every pass → `equals` +is always false → `StateFlow` never dedups → unthrottled loop. No test-side workaround helps (it's an +app loop): not `flakySafely`, not longer timeouts, not mocking external sources, not UiAutomator +(touching the window mid-async-signing aborts the send). **Fix is app-side** — emit once (guard the +`filter`/`distinctUntilChanged` so the self-induced field is ignored). If you see `ComposeNotIdle` on a +*static-looking* success/result screen, suspect this before blaming background polling. + +## Animation-gated content via `delay()` never appears under the test clock + +Compose UI tests run inside `runTest` — **virtual time**. A `LaunchedEffect { delay(600); visible = true }` +that gates the screen body behind `AnimatedVisibility(visible)` will *never* reveal it once the +composition is otherwise idle: `waitForIdle` sees no pending frame-clock awaiters, so it stops without +advancing the virtual clock to the delay's deadline. The body stays empty (you see only the parent +chrome, e.g. a top-bar close icon), the `testTag` is absent, and `assertIsDisplayed` fails as +"not displayed" — **after** burning the full wall-clock timeout. `flakySafely(LONG)` does NOT help: +it retries in wall-clock time while virtual time stays frozen. + +Distinguish from the loop trap above: a `delay`-gate gives a clean `AssertionError: … not displayed` +(idle is reached, node just isn't there); the loop gives a `ComposeNotIdle`/`AppNotIdle` timeout. + +Fixes: (a) app-side — drop the pre-`delay`, let the enter transition (`slideIn`/`fadeIn`) play on the +frame clock (which `autoAdvance` *does* pump); or (b) put the asserted `testTag` on a node **outside** +the `AnimatedVisibility` so the container exists from frame 0. A plain coroutine `delay` is not a +frame-clock awaiter, so advancing frames won't fire it — only `advanceTimeBy` (with `autoAdvance=false`) +would, which is fragile. Prefer the app-side fix. + +## Hot wallet imports with access code + +- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt` + handles both flows via the optional param — DO NOT introduce a parallel `importHotWalletWithAccessCode`. +- Access-code **create** and **confirm** screens share the same `ACCESS_CODE_INPUT` testTag. Gate the + confirm-screen action on the confirm-screen's unique title: + + ```kotlin + composeTestRule.waitUntilAtLeastOneExists( + hasText(getResourceString(CoreUiR.string.access_code_confirm_title)), + timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG, + ) + ``` + +- Tangem Pay eligibility (`PaeraCustomer`) rejects hot wallets with `authType=NoPassword` — those tests + must use the access-code path. \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md new file mode 100644 index 0000000000..8f73aaa83c --- /dev/null +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -0,0 +1,183 @@ +# Building, running, and debugging instrumentation tests + +## Both APKs matter + +Instrumentation tests need TWO APKs: + +- `:app:assembleGoogleMocked` → `app-google-mocked.apk` — production code under test +- `:app:assembleGoogleMockedAndroidTest` → `app-google-mocked-androidTest.apk` — the test code + +If you change production code and rebuild only the test APK, **the installed main APK stays old** and +your fix doesn't take effect. Symptom: "the fix doesn't help" — except it does, you just ran the +unfixed build. + +```bash +# Build both +./gradlew :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest +# Install each +adb install -r -t +adb install -r -t +``` + +## Run a single test (manual) + +```bash +adb shell pm clear com.tangem.wallet.mocked +curl -X POST http://localhost:8081/__admin/scenarios/reset +adb shell am instrument -w \ + -e class "com.tangem.tests.tangempay.TangemPayTest#freezeUnfreezeCard_TogglesCardState" \ + com.tangem.wallet.mocked.test/com.tangem.common.HiltTestRunner +``` + +## Harness: orchestrator vs. raw `am instrument` + +The app is configured `execution = "ANDROIDX_TEST_ORCHESTRATOR"` (`app/build.gradle.kts`). The orchestrator +runs **each test method in its own process** (and can clear app data between them). It is still 100% +local — it runs on the same emulator; nothing remote about it. + +Raw `adb shell am instrument` runs **all selected tests in one shared process**, which has two failure +modes that look like test bugs but aren't: + +- Running several tests in one invocation → `IllegalStateException: There are multiple DataStores active + for the same file` mid-run. Run them one at a time (with `pm clear` between) if you must use raw + `am instrument`. +- Tests that re-scan the card inside **Card/Device Settings** (the "Scan card or ring" gate) → + `IllegalStateException: Tangem SDK is null after re-registering with foreground activity`. The existing + `ResetCardTest` crashes identically under raw `am instrument`. These only pass via the orchestrator. + +**Prefer the orchestrator** (it's what CI/Marathon use). Run a class or method through Gradle: + +```bash +./gradlew :app:connectedGoogleMockedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest +# or a single method: ...class=com.tangem.tests.DetailsTest#someTest +# or several classes: ...class=com.tangem.tests.DetailsTest,com.tangem.tests.SecurityModeTest +``` + +Gradle installs both APKs, runs via the orchestrator, then **uninstalls them** — so a following raw +`am instrument` reports `Unable to find instrumentation info`; reinstall both APKs first. Read results +from the JUnit XML (authoritative pass/fail counts), not just stdout: + +```bash +ls -t app/build/outputs/androidTest-results/connected/mocked/flavors/google/*.xml | head -1 +# inspect tests="…" failures="…" errors="…" skipped="…" and the / nodes +``` + +## Running against local WireMock + +Every instrumentation test runs with `ApiEnvironment.MOCK` (forced in `BaseTestCase.setupHooks`), so the +app's API base URLs point at `wiremock.tests-d.com` — i.e. tests **always** talk to WireMock, never the +real backend. By default that's the **remote** WireMock at `wiremock.tests-d.com`. To use a **local** +WireMock instead, pass `wiremockBaseUrl`: `WireMockRedirectInterceptor` then rewrites every +`wiremock.tests-d.com` request to your local instance. + +Emulator addressing matters — `localhost` inside an emulator is the **emulator itself**, not your host: + +- Use the host alias **`http://10.0.2.2:8081`** (no extra setup), **or** +- `http://localhost:8081` **with** `adb reverse tcp:8081 tcp:8081` run first. + +Pass it through the orchestrator (recommended): + +```bash +curl -s -X POST http://localhost:8081/__admin/scenarios/reset # start clean +./gradlew :app:connectedGoogleMockedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest \ + -Pandroid.testInstrumentationRunnerArguments.wiremockBaseUrl=http://10.0.2.2:8081 +``` + +(Raw `am instrument` equivalent: `-e wiremockBaseUrl http://10.0.2.2:8081` — subject to the harness +caveats above.) + +**If a screen hangs / you get `ComposeNotIdleException` (infinite recomposition):** that usually means a +request the app made wasn't served (endless retry/loading), *not* a test bug. Ask WireMock what it +didn't match — this is the smoking gun: + +```bash +curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(.method) \(.url)"' +``` + +`unmatched: 0` means the URL plumbing is correct and local WireMock served everything — look elsewhere +(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the +local instance is missing. + +## When the UI fails silently, the cause is usually app-side — two places to look + +A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays +disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it: + +- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a + file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced + `IllegalStateException: No native currency found` → a native coin missing from the mock): + ```bash + adb exec-out run-as cat files/log.txt | grep -iE "Error|Exception|" + ``` +- **The WireMock journal separates "mock missing" from "app never asked"** — + `/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request + is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) → + fix the mock data or the app, not the mappings. + +## "UiAutomationService already registered" — retry, it's not a failure + +Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already +registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by +spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw +`am instrument`.) + +## Classify the result — Allure noise vs. real failure + +After `pm clear`, `/data/user/0//files/original_screenshots` doesn't exist → +`AllureResultsHack.testRunFinished` throws `NoSuchFileException` → reported as +`Tests run: 1, Failures: 1` with a stack trace starting at `AllureResultsHack`. **This is a post-run +hook failure, NOT a test logic failure.** + +Distinguish: +- First stack frame is `AllureResultsHack.testRunFinished` → infra hook noise; ignore it. +- Kaspresso step logs show all `SUCCEED` for steps 1..N → the test passed. +- A REAL failure shows `java.lang.AssertionError` inside the test's own classes + (e.g. `at com.tangem.tests.X.foo$lambda…`). When auto-classifying CLI output, key off the presence + of `java.lang.AssertionError` vs. only `original_screenshots`. + +## `@Ignore` on instrumentation tests + +- Pattern: `@Ignore("https://tangem.atlassian.net/browse/AND-XXXXX")` above `@Test`. +- When ignored, `am instrument -e class …` reports `OK (0 tests)` with `Tests run: 0` + (NOT `Skipped: 1`). Auto-detection should match the zero-test count. + +## WireMock cheatsheet + +Without a `wiremockBaseUrl` arg the app hits the **remote** WireMock (`wiremock.tests-d.com`); pass the +arg to redirect to a local instance (see "Running against local WireMock"). Default local port: `8081`. + +```bash +# Set a scenario state — PUT, not POST +curl -X PUT http://localhost:8081/__admin/scenarios//state \ + -H "Content-Type: application/json" -d '{"state":""}' + +# Reset all scenarios +curl -X POST http://localhost:8081/__admin/scenarios/reset + +# Inspect +curl http://localhost:8081/__admin/mappings | jq +curl http://localhost:8081/__admin/scenarios | jq '.scenarios[] | {name, state}' +``` + +- Mocks repo: default to the sibling directory `../tangem-api-mocks/` (i.e. next to + `tangem-app-android`). If that path doesn't exist, **ask the user** where the mocks repo is rather + than guessing. +- The repo is **branch-per-suite** — dozens of feature branches (e.g. `send-via-swap-p1`, + `account-creation`, `swap-express-mocks`, `android-tangem-pay-mocks`). There is no universal + default branch; check out the one the suite under test expects. If it's unclear which branch holds + the mappings for your flow, ask the user. Mappings live under `mocks/mappings/`, response bodies + under `mocks/__files/`. +- **State transitions are atomic per `requiredScenarioState`.** If a scenario defines an `AfterDeposit` + mapping for `/customer/balance` but not `/customer/me`, a request to `/customer/me` after switching + to `AfterDeposit` falls through. Check *both* endpoints when an "after" assertion fails. + +## Misc + +- `./gradlew unitTest` aggregates all debug/googleDebug + JVM-module tests — faster than per-module + tasks for verifying a broad change (but it's for *unit* tests, not instrumentation). +- Detekt config lives in the `tangem-android-tools` git submodule — look there before assuming a local + `.detekt.yml`. +- Path discipline: stay at the repo root; `cd` into the mocks repo only when needed and prefer absolute + paths (the shell session resets cwd). \ No newline at end of file diff --git a/.mcp.json b/.mcp.json index 009df155b7..3721a9071a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -9,6 +9,11 @@ "type": "stdio", "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"] + }, + "notion": { + "type": "stdio", + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"] } } } \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22f93e0530..c307bb3f9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,16 +107,12 @@ configurations.all { configurations.androidTestImplementation { exclude(module = "protobuf-lite") } - -tasks.withType().configureEach { - useJUnitPlatform() -} - dependencies { implementation(projects.domain.legacy) implementation(projects.libs.blockchainSdk) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.addressBook) implementation(projects.domain.models) implementation(projects.domain.core) api(projects.domain.common) @@ -236,6 +232,8 @@ dependencies { implementation(projects.common.ui) /** Features */ + implementation(projects.features.addressBook.api) + implementation(projects.features.addressBook.impl) implementation(projects.features.rating.impl) implementation(projects.features.referral.impl) implementation(projects.features.referral.domain) @@ -255,8 +253,8 @@ dependencies { implementation(projects.features.tokendetails.impl) implementation(projects.features.manageTokens.api) implementation(projects.features.manageTokens.impl) - implementation(projects.features.sendV2.api) - implementation(projects.features.sendV2.impl) + implementation(projects.features.send.api) + implementation(projects.features.send.impl) implementation(projects.features.qrScanning.api) implementation(projects.features.qrScanning.impl) implementation(projects.features.staking.api) @@ -283,6 +281,8 @@ dependencies { implementation(projects.features.onboardingV2.impl) implementation(projects.features.stories.api) implementation(projects.features.stories.impl) + implementation(projects.features.survey.api) + implementation(projects.features.survey.impl) implementation(projects.features.txhistory.api) implementation(projects.features.txhistory.impl) implementation(projects.features.biometry.api) @@ -319,6 +319,12 @@ dependencies { implementation(projects.features.tangempay.main.impl) implementation(projects.features.tangempay.onboarding.api) implementation(projects.features.tangempay.onboarding.impl) + implementation(projects.features.virtualAccounts.onboarding.impl) + implementation(projects.features.virtualAccounts.onboarding.api) + implementation(projects.features.virtualAccounts.main.impl) + implementation(projects.features.virtualAccounts.main.api) + implementation(projects.features.virtualAccounts.details.impl) + implementation(projects.features.virtualAccounts.details.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) implementation(projects.features.yieldSupply.api) @@ -422,8 +428,6 @@ dependencies { /** Testing libraries */ testImplementation(projects.test.core) testImplementation(projects.common.test) - testImplementation(deps.test.junit) - testRuntimeOnly(deps.test.junit5.engine) androidTestImplementation(deps.test.junit.android) androidTestImplementation(deps.test.espresso) androidTestImplementation(deps.test.espresso.intents) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 8b307f4571..95ccc3e96a 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -24,6 +24,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.datasource.utils.WireMockRedirectInterceptor +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.tap.MainActivity @@ -63,6 +64,9 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase + @Inject + lateinit var singleAccountListSupplier: SingleAccountListSupplier + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -183,9 +187,42 @@ abstract class BaseTestCase : TestCase( "GASLESS_APPROVAL_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true, + "ASSETS_DISCOVERY_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true, - "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, + // Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual + // build even when the app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < x.xx would + // disable them). On the releases/6.0 branch every toggle with version <= 6.0 ships enabled. + // 5.37 + "HEDERA_ERC20_ENABLED" to true, + // 5.39 + "STAKING_ETH_ENABLED" to true, + "DYNAMIC_ADDRESSES_ENABLED" to true, + "SOLANA_TX_HISTORY_ENABLED" to true, + "SOLANA_SCALED_UI_AMOUNT_ENABLED" to true, + "SWAP_AB_ENABLED" to true, "AND_15310_ADD_FUNDS_STAGE1" to true, + "AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true, + "AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, + "AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true, + "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true, + "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true, + "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true, + // 5.39.2 + "AND_15154_YIELD_PROMO_ENABLED" to true, + // 5.40 + "TWI_1377_MANAGE_FUNDS" to true, + // 6.0 + "APP_REDESIGN_ENABLED" to true, + "TWI_1326_YIELD_MODE_SWAP_ENABLED" to true, + "AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED" to true, + "AND_15120_SWAP_INTEGRATED_APPROVE" to true, + "AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED" to true, + "AND_15258_QUICK_TOP_UP_ENABLED" to true, + "AND_15368_VISA_PAY_REDESIGN" to true, + "AND_15364_VISA_PAY_CARD_CLOSE" to true, + "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED" to true, + "AND_15235_VISA_MULTIPLE_CARDS" to true, + "AND_15715_SWAP_BEST_DEX_RATE_ENABLED" to true, ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index fc4d42796b..e9cf254d43 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -48,6 +48,10 @@ object TestConstants { const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val REFERRAL_API_SCENARIO = "referral_api" const val QUOTES_API_SCENARIO = "quotes_api" + const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api" + const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api" + const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api" + const val PROVIDERS_API_SCENARIO = "networks_providers" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + @@ -60,6 +64,9 @@ object TestConstants { "bread much nature basic fun iron benefit egg error prosper" const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" + const val SEED_PHRASE_HAPPY_PATH = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility" const val TANGEM_PAY_ACCESS_CODE = "517384" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt index 87db4a23e4..309b7c2fa0 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/CustomAssertsExt.kt @@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? { private fun parseVolume(node: SemanticsNode): Double? { val text = extractText(node) ?: return null - return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() + val multiplier = when { + text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0 + text.contains('B', ignoreCase = true) -> 1_000_000_000.0 + text.contains('M', ignoreCase = true) -> 1_000_000.0 + text.contains('K', ignoreCase = true) -> 1_000.0 + else -> 1.0 + } + val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null + return number * multiplier } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt index 3e5c61cf6b..04c925b312 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -4,13 +4,19 @@ import android.os.SystemClock import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule +import io.github.kakaocup.compose.node.core.BaseNode import io.github.kakaocup.compose.node.element.KNode -fun KNode.clickWithAssertion() { +fun BaseNode<*>.clickWithAssertion() { assertIsDisplayed() performClick() } +fun KNode.clickWhenEnabled() { + assertIsEnabled() + performClick() +} + fun KNode.assertTextContainsSafe( text: String, substring: Boolean = false, diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt index 2ec823d1e8..00a0858c12 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/UiDeviceExt.kt @@ -4,8 +4,8 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG -import com.tangem.wallet.R -import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT + fun BaseTestCase.swipeVertical( direction: SwipeDirection, @@ -31,21 +31,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) { ) } -fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) { - val searchBarText = device.uiDevice - .findObject(By.textContains(getResourceString(R.string.markets_search_header_title))) - val bounds = searchBarText.visibleBounds - - val centerX = bounds.centerX() - val startY = bounds.centerY() - val endY = when (direction) { - SwipeDirection.UP -> 50 - SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100 - } - - device.uiDevice.swipe(centerX, startY, centerX, endY, 100) -} - fun BaseTestCase.openTheAppFromRecents() { device.uiDevice.waitForIdle() @@ -113,6 +98,12 @@ fun BaseTestCase.restartApp(packageName: String) { waitForIdle() } +fun BaseTestCase.clickOnSystemButton(buttonName: String) { + device.uiDevice.wait(Until.hasObject(By.text(buttonName)), WAIT_UNTIL_TIMEOUT_SHORT) + device.uiDevice.findObject(By.text(buttonName))?.click() + ?: throw AssertionError("System '$buttonName' button not found") +} + enum class SwipeDirection { UP, DOWN } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/DerivationPathHelper.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/DerivationPathHelper.kt new file mode 100644 index 0000000000..3e06a795f9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/DerivationPathHelper.kt @@ -0,0 +1,20 @@ +package com.tangem.common.utils + +/** + * Helper for inspecting individual nodes of a BIP-44-style derivation path string + * (e.g. one read from `Network.derivationPath` of a token in the domain account model). + */ +object DerivationPathHelper { + + /** + * Returns the [index1Based]-th node of a derivation path, ignoring the leading `m`. + * For "m/44'/0'/1'/0/0": node 1 = "44'", node 3 = "1'", node 5 = "0". + */ + fun nodeAt(derivationPath: String, index1Based: Int): String { + val nodes = derivationPath.removePrefix("m/").split("/") + require(index1Based in 1..nodes.size) { + "Node #$index1Based is out of range for path '$derivationPath' (${nodes.size} nodes)" + } + return nodes[index1Based - 1] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt index 7320642036..c8ecf3a61f 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt @@ -1,14 +1,25 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.account.Account import com.tangem.screens.accounts.onAccountDetailsScreen +import com.tangem.screens.accounts.onAccountInfoEditorScreen import com.tangem.screens.accounts.onArchivedAccountsScreen import com.tangem.screens.onDetailsScreen import com.tangem.screens.onDialog import com.tangem.screens.onMainScreenTopBar import com.tangem.screens.onWalletSettingsScreen +import com.tangem.utils.logging.TangemLogger +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +private const val ACCOUNT_POLL_INTERVAL_MS = 500L fun BaseTestCase.openWalletSettingsScreen() { step("Open 'Wallet details' screen") { @@ -19,6 +30,15 @@ fun BaseTestCase.openWalletSettingsScreen() { } } +fun BaseTestCase.startAccountCreation() { + step("Click on 'Add account' button") { + onWalletSettingsScreen { addAccountButton.clickWithAssertion() } + } + step("Assert 'Account info editor' screen is displayed") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } +} + fun BaseTestCase.openAccountDetails(accountName: String) { step("Click on account: '$accountName'") { onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() } @@ -28,6 +48,46 @@ fun BaseTestCase.openAccountDetails(accountName: String) { } } +fun BaseTestCase.checkUnsavedChangesCreationModal() { + step("Assert 'Unsaved changes' alert is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert 'Unsaved changes' alert has proper title") { + onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) } + } + step("Assert 'Unsaved changes' alert has proper description for account creation") { + onDialog { + text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_create)) + } + } + step("Assert 'Keep editing' button is displayed in alert with proper text") { + onDialog { keepEditButton.assertIsDisplayed() } + } + step("Assert 'Discard' button is displayed in alert") { + onDialog { discardButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.assertUnsavedChangesEditionModal() { + step("Assert 'Unsaved changes' alert is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert 'Unsaved changes' alert has proper title") { + onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) } + } + step("Assert 'Unsaved changes' alert has proper description for account creation") { + onDialog { + text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_edit)) + } + } + step("Assert 'Keep editing' button is displayed in alert with proper text") { + onDialog { keepEditButton.assertIsDisplayed() } + } + step("Assert 'Discard' button is displayed in alert") { + onDialog { discardButton.assertIsDisplayed() } + } +} + fun BaseTestCase.archiveAccount() { step("Assert 'Archive' button is displayed") { onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } @@ -99,4 +159,53 @@ fun BaseTestCase.restoreArchivedAccount(accountName: String) { .restoreButton.clickWithAssertion() } } -} \ No newline at end of file +} + +/** + * Polls [singleAccountListSupplier] for the selected wallet until a [Account.CryptoPortfolio] with the given + * [derivationIndex] appears with a non-empty token list, then returns it. + * + * Per-account token derivation paths live in the domain account model + * ([Account.CryptoPortfolio.cryptoCurrencies] → [com.tangem.domain.models.network.Network.derivationPath]), + * not in the tester-menu "Addresses info" (which reads from the account-agnostic wallet managers store and + * only ever shows main/base derivations). Reading the model directly is the reliable source for asserting + * per-account derivations. + */ +fun BaseTestCase.awaitCryptoPortfolioAccount(derivationIndex: Int): Account.CryptoPortfolio { + val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId + ?: error("No selected wallet found") + + var account: Account.CryptoPortfolio? = null + runBlocking { + withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + while (true) { + val candidate = singleAccountListSupplier.getSyncOrNull(walletId) + ?.accounts + ?.filterIsInstance() + ?.firstOrNull { it.derivationIndex.value == derivationIndex } + + if (candidate != null && candidate.cryptoCurrencies.isNotEmpty()) { + TangemLogger.i( + "Account with derivation index $derivationIndex resolved: " + + "${candidate.cryptoCurrencies.size} token(s)", + ) + account = candidate + return@withTimeout + } + + delay(ACCOUNT_POLL_INTERVAL_MS) + } + } + } + + return requireNotNull(account) { + "Account with derivation index $derivationIndex was not found for wallet $walletId" + } +} + +/** + * Returns all derivation paths of tokens whose name equals [tokenName] (case-insensitive) within this account. + */ +fun Account.CryptoPortfolio.derivationPathsForToken(tokenName: String): List = cryptoCurrencies + .filter { it.name.equals(tokenName, ignoreCase = true) } + .mapNotNull { it.network.derivationPath.value } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 911b7526d1..31b41c8bea 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -2,8 +2,6 @@ package com.tangem.scenarios import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.waitUntilAtLeastOneExists import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion @@ -142,12 +140,15 @@ fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessC fun BaseTestCase.synchronizeAddresses( balance: String? = null, - isBalanceAvailable: Boolean = true + isBalanceAvailable: Boolean = true, + assertBalance: Boolean = true, ) { step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } + if (!assertBalance) return + when { !isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } @@ -175,7 +176,10 @@ fun BaseTestCase.openDeviceSettingsScreen() { onDetailsScreen { walletNameButton.performClick() } } step("Click on 'Device settings' button") { - onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } + onWalletSettingsScreen { + scrollToDeviceSettings() + deviceSettingsButton.clickWithAssertion() + } } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index 75e7af6d4d..9ad0c60297 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -6,105 +6,41 @@ import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.checkSingleCurrencyMainScreen( - cardBlockchain: String, - cardTitle: String, - withTransactions: Boolean = false, - withWalletImage: Boolean = true -) { +fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) { step("Assert card title equal '$cardTitle'") { onMainScreen { walletNameText.assertTextEquals(cardTitle) } } - if (withWalletImage) { - step("Assert card image is displayed") { //TODO: create assertion method for checking images - onMainScreen { walletImage.assertIsDisplayed() } - } - } else { - step("Assert card image is not displayed") { - onMainScreen { walletImage.assertIsNotDisplayed() } - } + step("Assert 'Add funds' button is displayed") { + onMainScreen { addFundsButton.assertIsDisplayed() } } - step("Assert 'Receive' button is displayed") { - onMainScreen { receiveButton.assertIsDisplayed() } - } - step("Assert 'Buy' button is displayed") { - onMainScreen { buyButton.assertIsDisplayed() } - } - step("Assert 'Send' button is displayed") { - onMainScreen { sendButton.assertIsDisplayed() } - } - step("Assert 'Sell' button is displayed") { - onMainScreen { sellButton.assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onMainScreen { transferButton.assertIsDisplayed() } } step("Assert 'Swap' button is not displayed") { onMainScreen { swapButton.assertIsNotDisplayed() } } - step("Assert 'Market Price' on single card main screen is displayed") { - onMainScreen { marketPriceBlock().assertIsDisplayed() } - } - step("Assert 'Market Price' title equals $cardBlockchain Market Price") { - onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") } - } step("Swipe up") { swipeVertical(SwipeDirection.UP) } - if (withTransactions) { - step("Assert 'Transactions' block is displayed") { - onMainScreen { transactionsExplorerText.assertIsDisplayed() } - } - step("Assert 'Transactions' title is displayed") { - onMainScreen { transactionsTitle.assertIsDisplayed() } - } - step("Assert 'Explorer' icon is displayed") { - onMainScreen { transactionsExplorerIcon.assertIsDisplayed() } - } - } else { - step("Assert empty 'Transactions' block is displayed") { - onMainScreen { emptyTransactionBlock.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block icon is displayed") { - onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block text is displayed") { - onMainScreen { emptyTransactionBlockText.assertIsDisplayed() } - } - step("Assert empty 'Transactions' block 'Explore' button is displayed") { - onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() } - } - } step("Assert 'Add & Manage' button is not displayed") { onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() } } } fun BaseTestCase.checkMultiCurrencyMainScreen( - devicesCount: String, cardTitle: String, - withWalletImage: Boolean = true ) { step("Assert card title equal '$cardTitle'") { onMainScreen { walletNameText.assertTextEquals(cardTitle) } } - if (withWalletImage) { - step("Assert card image is displayed") { - onMainScreen { walletImage.assertIsDisplayed() } - } - } else { - step("Assert card image is not displayed") { - onMainScreen { walletImage.assertIsNotDisplayed() } - } - } - step("Assert devices count equal to '$devicesCount'") { - onMainScreen { walletDevicesCount.assertTextContains(devicesCount) } - } step("Assert 'Add funds' button is displayed") { onMainScreen { addFundsButton.assertIsDisplayed() } } step("Assert 'Swap' button is displayed") { onMainScreen { swapButton.assertIsDisplayed() } } - step("Assert 'Sell' button is displayed") { - onMainScreen { sellButton.assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onMainScreen { transferButton.assertIsDisplayed() } } step("Assert 'Send' button is not displayed") { onMainScreen { sendButton.assertIsNotDisplayed() } @@ -125,8 +61,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean = step("Assert 'Swap' button is enabled") { onMainScreen { swapButton.assertIsEnabled() } } - step("Assert 'Sell' button is enabled") { - onMainScreen { sellButton.assertIsEnabled() } + step("Assert 'Transfer' button is enabled") { + onMainScreen { transferButton.assertIsEnabled() } } } else { step("Assert 'Add funds' button is not enabled") { @@ -135,8 +71,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean = step("Assert 'Swap' button is not enabled") { onMainScreen { swapButton.assertIsNotEnabled() } } - step("Assert 'Sell' button is not enabled") { - onMainScreen { sellButton.assertIsNotEnabled() } + step("Assert 'Transfer' button is not enabled") { + onMainScreen { transferButton.assertIsNotEnabled() } } } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt index d58f6ba8e3..41afda9061 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/DeviceSetingsScenarios.kt @@ -5,6 +5,12 @@ import com.tangem.common.extensions.clickWithAssertion import com.tangem.screens.onDeviceSettingsScreen import io.qameta.allure.kotlin.Allure.step +fun BaseTestCase.scanCardInDeviceSettings() { + step("Click on 'Scan card or ring' button") { + onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() } + } +} + fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) { step("Click on 'Scan card or ring' button") { onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt new file mode 100644 index 0000000000..90ef47eb41 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt @@ -0,0 +1,139 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.screens.* +import io.qameta.allure.kotlin.Allure.step + +/** + * From the recipient step: fill the address and advance to the 'Send confirm' screen. + * Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in extensions on [BaseTestCase]. + */ +fun BaseTestCase.enterRecipientAndOpenSendConfirm(recipientAddress: String) { + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button until 'Send confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } + } +} + +/** Enter the send amount, then fill the recipient and open the 'Send confirm' screen. */ +fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, recipientAddress: String) { + step("Type '$amount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + enterRecipientAndOpenSendConfirm(recipientAddress) +} + +/** + * On the 'Send confirm' screen, open the network-fee selector and switch the fee token from the + * native coin to the given (stablecoin) token — the core gasless action repeated across the suite. + */ +fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) { + step("Click on 'Network fee' block") { + onSendConfirmScreen { + feeSelectorBlock.assertIsDisplayed() + feeSelectorBlock.performClick() + } + } + step("Click on '$coinName' fee token to open 'Choose token'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(coinName).performClick() } }.isSuccess + } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } +} + +/** + * Open an existing hot wallet (gasless signing needs a hot wallet, not the mock card), set the + * portfolio and quotes mocks, and reach the send amount input for the given token. + */ +fun BaseTestCase.openGaslessSendScreenWithHotWallet( + seedPhrase: String, + tokenName: String, + userTokensState: String, + quotesState: String, +) { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } + } +} + +/** + * Open an existing hot wallet, select the token to send and choose the swap target token/network — + * the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body. + */ +fun BaseTestCase.openSendViaSwapScreenWithHotWallet( + seedPhrase: String, + tokenName: String, + swapTokenName: String, + networkName: String, + networkType: String? = null, +) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select '$swapTokenName' as the token to receive via swap") { + selectTokenToSendViaSwap( + swapTokenName = swapTokenName, + networkName = networkName, + networkType = networkType, + ) + } +} + +/** + * Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting + * until it becomes enabled once the swap quote loads), then fill the recipient and open the + * 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in + * extensions on [BaseTestCase]. + */ +fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) { + step("Type amount '$amount' in input field") { + onSendScreen { amountInputTextField.performTextReplacement(amount) } + } + step("Click on 'Next' button") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + }.isSuccess + } + } + enterRecipientAndOpenSendConfirm(recipientAddress) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index cda67fda49..df5b36514e 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -1,28 +1,28 @@ package com.tangem.scenarios +import androidx.compose.ui.test.ExperimentalTestApi import com.tangem.common.BaseTestCase -import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import com.tangem.screens.onMarketsTokenDetailsScreen import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) { +fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) { step("Open 'Markets' screen") { - onMainScreen { searchThroughMarketPlaceholder.performClick() } + onMainScreen { marketsSheetDragHandle.clickWithAssertion() } waitForIdle() } - step("Click on 'Search' placeholder") { - onMarketsScreen { searchThroughMarketPlaceholder.performClick() } - } step("Click on $blockchainName blockchain") { waitForIdle() onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() } } - step("Click on $tokenName token") { + step("Click on 'In your portfolio' block") { + waitForIdle() + onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() } + } + step("Click on $tokenName token in 'Your portfolio' bottom sheet") { waitForIdle() onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() } } @@ -54,11 +54,12 @@ fun BaseTestCase.openMarketsScreen() { synchronizeAddresses() } step("Open 'Markets' screen") { - onMainScreen { searchThroughMarketPlaceholder.performClick() } + onMainScreen { marketsSheetDragHandle.clickWithAssertion() } waitForIdle() } } +@OptIn(ExperimentalTestApi::class) fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) { openMarketsScreen() if (shouldClickSeeAllButton) @@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } waitForIdle() } - step("Scroll down") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) + step("Scroll to 'Listed on exchanges' block") { + onMarketsScreen { scrollToListedOnBlock() } } step("Click on 'Listed on exchanges' block") { onMarketsScreen { listedOnBlockContainer.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt new file mode 100644 index 0000000000..dc3ef704c7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -0,0 +1,98 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.isDisplayedSafely +import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.qameta.allure.kotlin.Allure.step + +/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */ +fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { + step("Click 'More' button on TopBar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + MockProvider.setMocks(mockContent) + step("Click on 'Add Wallet' button (scans a new hardware wallet)") { + onDetailsScreen { addWalletButton.clickWithAssertion() } + } + // Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed". + step("Assert 'Main' screen is displayed with the new wallet") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess + } + } + // The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to. + step("Synchronize the new card wallet's addresses") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var shown = false + onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() } + if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) } + shown + } + // Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce. + waitForIdle() + onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + // The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs). + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } + } +} + +fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { + step("Click on token '$tokenName' on the visible wallet") { + onMainScreen { clickDisplayedToken(tokenName) } + } +} + +fun BaseTestCase.switchToPreviousWallet() { + step("Swipe wallet card to the previous wallet") { + onMainScreen { swipeToAdjacentWallet(toPrevious = true) } + } +} + +/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */ +fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Select wallet tab '$walletName'") { + onBuyTokenScreen { walletTab(walletName).performClick() } + } + step("Click on token with name '$token'") { + onBuyTokenScreen { tokenWithTitle(token).performClick() } + } +} + +/** Adds [token] to [recipientWalletName] which lacks it, via market search. */ +fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Type '$token' in search field") { + onSwapSelectTokenScreen { + searchBarBlock.performClick() + searchBarBlock.performTextInput(token) + } + } + step("Click on market token '$token'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess + } + } + // The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it). + step("Assert recipient wallet '$recipientWalletName' is selected") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess + } + } + step("Click on 'Add' button") { + onAddToPortfolioScreen { addButton.performClick() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index 5277c76ba8..4b54adb33a 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -6,11 +6,14 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG -import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.extractText import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext import com.tangem.screens.* import com.tangem.tap.domain.sdk.mocks.MockContent +import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.openSendScreen( @@ -34,8 +37,11 @@ fun BaseTestCase.openSendScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } } @@ -91,11 +97,11 @@ fun BaseTestCase.openSendAddressScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Assert 'Send' button is not dimmed") { - onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { @@ -109,6 +115,13 @@ fun BaseTestCase.openSendAddressScreen( step("Assert 'Send Address' container is displayed") { onSendAddressScreen { container.assertIsDisplayed() } } + step("Wait for recipient list to load") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() } + }.isSuccess + } + } } fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) { @@ -193,6 +206,20 @@ fun BaseTestCase.openSendConfirmScreenViaNextButton() { } } +fun BaseTestCase.openSendConfirmScreenViaContinueButton() { + step("Click on 'Continue' button") { + onSendAddressScreen { + addressesShimmer.assertIsNotDisplayed() + continueButton.assertIsDisplayed() + continueButton.assertIsEnabled() + continueButton.performClick() + } + } + step("Assert 'Send' button on 'Send confirm' screen is displayed") { + onSendConfirmScreen { sendButton.assertIsDisplayed() } + } +} + fun BaseTestCase.openSendSuccessScreenViaLongClickOnSendButton() { step("Long click on 'Send' button") { onSendConfirmScreen { @@ -239,13 +266,100 @@ fun BaseTestCase.checkSendViaSwapSuccessScreen() { } } +/** From the token details screen, open the transfer bottom sheet and reach the send amount input. */ +fun BaseTestCase.openSendFromTokenDetails() { + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } + } +} + +/** Open an existing hot wallet and reach the send amount input for [tokenName]. */ +fun BaseTestCase.openSendScreenWithHotWallet(seedPhrase: String, tokenName: String) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + openSendFromTokenDetails() +} + +fun BaseTestCase.getNetworkFeeAmount(): String { + var fee = "" + step("Read current network fee amount") { + onSendConfirmScreen { fee = feeAmount.extractText() } + } + return fee +} + +fun BaseTestCase.switchFeeToFastAndApply() { + val fastOption = getResourceString(R.string.common_fee_selector_option_fast) + step("Click on fee selector icon") { + onSendConfirmScreen { feeSelectorIcon.performClick() } + } + // Selecting a non-custom speed auto-applies and closes the fee selector — no 'Done' step. + step("Click on '$fastOption' fee option") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastOption).performClick() } + } +} + +fun BaseTestCase.assertNetworkFeeChanged(previousFee: String) { + step("Assert network fee changed from '$previousFee'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + var current = previousFee + onSendConfirmScreen { current = feeAmount.extractText() } + current != previousFee + }.getOrDefault(false) + } + } +} + +/** Reads the network fee amount on the 'Send confirm' screen (empty while it shows a loading shimmer). */ +fun BaseTestCase.readNetworkFeeAmount(): String { + var fee = "" + onSendConfirmScreen { fee = feeAmount.extractText() } + return fee +} + +/** + * Wait until the network fee value stops changing across two checks — the send button stays disabled + * (and the hold-to-confirm gesture is swallowed) until the fee re-fetch settles. The hold button has + * no enabled/disabled semantics, so waiting on the fee value is the only reliable readiness signal. + */ +fun TestContext.waitUntilNetworkFeeIsStable(readFee: () -> String) { + step("Wait for the network fee to finish loading") { + var previousFee: String? = null + flakySafely(timeoutMs = WAIT_UNTIL_TIMEOUT_LONG, intervalMs = FEE_STABILITY_INTERVAL_MS) { + val currentFee = readFee() + val isStable = currentFee.isNotEmpty() && currentFee == previousFee + previousFee = currentFee + if (!isStable) throw AssertionError("Network fee is still settling (current='$currentFee')") + } + } +} + +private const val FEE_STABILITY_INTERVAL_MS = 750L + +fun BaseTestCase.assertNetworkFeeContains(currencySymbol: String) { + step("Assert network fee contains '$currencySymbol'") { + onSendConfirmScreen { feeAmount.assertTextContains(currencySymbol, substring = true) } + } +} + fun BaseTestCase.selectTokenToSendViaSwap( swapTokenName: String, networkName: String, networkType: String? = null, ) { - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Click on 'Swap to another token' button") { onSendScreen { swapToAnotherTokenButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index d3933b0416..4dc9ab8da6 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -9,13 +9,18 @@ import androidx.compose.ui.test.performTouchInput import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.assertVisibility +import com.tangem.common.extensions.clickAndWaitFor +import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.extractText import com.tangem.common.extensions.isDisplayedSafely import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step import com.tangem.common.ui.R as CommonUiR @@ -43,8 +48,8 @@ fun BaseTestCase.openSwapScreen( } SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") { - onTokenDetailsScreen { swapButton().performClick() } - } + onTokenDetailsScreen { swapButton.clickWhenEnabled() } + } SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") { onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } @@ -167,7 +172,7 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) { when (feeType) { FeeType.Market -> { step("Click on 'Market' item") { - onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.performClick() } + onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.clickWithAssertion() } } step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") { onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } @@ -175,7 +180,7 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) { } FeeType.Fast -> { step("Click on 'Fast' item") { - onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.performClick() } + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() } } step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") { onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } @@ -184,49 +189,23 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) { } } -fun BaseTestCase.selectFeeTypeWithGasless(feeType: FeeType, selectedFeeAmount: String) { +fun BaseTestCase.selectFeeTypeAndReadFee(feeType: FeeType): String { step("Click on 'Select fee' icon") { onSwapTokenScreen { selectFeeIcon.performClick() } } - - when (feeType) { - FeeType.Market -> selectMarketFee(selectedFeeAmount) - FeeType.Fast -> selectFastFee(selectedFeeAmount) - } -} - -private fun BaseTestCase.selectMarketFee(selectedFeeAmount: String) { - step("Deselect current fee and select 'Market'") { + step("Click on '$feeType' item") { onSwapSelectNetworkFeeBottomSheet { - if (fastSelectorItem.isDisplayedSafely()) { - fastSelectorItem.performClick() + when (feeType) { + FeeType.Market -> marketSelectorItem.clickWithAssertion() + FeeType.Fast -> fastSelectorItem.clickWithAssertion() } - marketSelectorItem.performClick() } } - step("Click on 'Apply' button") { - onSwapSelectNetworkFeeBottomSheet { applyButton.performClick() } - } - step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") { - onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } - } -} - -private fun BaseTestCase.selectFastFee(selectedFeeAmount: String) { - step("Deselect current fee and select 'Fast'") { - onSwapSelectNetworkFeeBottomSheet { - if (marketSelectorItem.isDisplayedSafely()) { - marketSelectorItem.performClick() - } - fastSelectorItem.performClick() - } - } - step("Click on 'Apply' button") { - onSwapSelectNetworkFeeBottomSheet { applyButton.performClick() } - } - step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") { - onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } + var fee = "" + step("Read displayed '$feeType' fee amount") { + onSwapTokenScreen { fee = feeAmount.extractText() } } + return fee } fun BaseTestCase.chackUnableToCoverFeeNotification(networkName: String, currencySymbol: String) { @@ -284,6 +263,112 @@ fun BaseTestCase.checkSwapWarning( } } +/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */ +fun BaseTestCase.openSwapForTokenInAccount( + tokenName: String, + fromAccountName: String = "Account 1", + mockContent: MockContent? = null, +) { + step("Open 'Main' screen") { + openMainScreen(mockContent = mockContent) + } + step("Synchronize addresses") { + synchronizeAddresses(assertBalance = false) + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) +} + +/** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */ +fun BaseTestCase.openSwapInTransferMode( + tokenName: String, + fromAccountName: String = "Account 1", + toAccountName: String = "Account 2", + mockContent: MockContent? = null, +) { + openSwapForTokenInAccount(tokenName, fromAccountName, mockContent) + step("Choose identical receive token '$tokenName' from '$toAccountName'") { + chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) + } +} + +/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */ +fun BaseTestCase.openSwapInTransferModeWithHotWallet( + tokenName: String, + seedPhrase: String, + fromAccountName: String = "Account 1", + toAccountName: String = "Account 2", +) { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Generate missing addresses") { + generateMissingHotWalletAddresses() + } + step("Wait for addresses to be generated") { + waitForAddressesGenerated() + } + navigateToSwapForToken(tokenName, fromAccountName) + step("Choose identical receive token '$tokenName' from '$toAccountName'") { + chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName) + } +} + +private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) { + step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") { + onMainScreen { scrollToAccount(fromAccountName) } + } + step("Expand account '$fromAccountName' and reveal token '$tokenName'") { + onMainScreen { + findAccountSectionByName(fromAccountName).clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() } + }, + ) + } + } + step("Click on token with name: '$tokenName'") { + onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() } + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } +} + +// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted. +fun BaseTestCase.generateMissingHotWalletAddresses() { + var notificationShown = false + onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() } + if (notificationShown) { + onMainScreen { synchronizeAddressesButton.performClick() } + } +} + +// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does. +fun BaseTestCase.waitForAddressesGenerated() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } +} + +/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */ +fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Expand account '$receiveAccountName' in receive selector") { + onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + fun BaseTestCase.chooseReceiveToken(tokenName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } @@ -293,6 +378,95 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) { } } +/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */ +fun BaseTestCase.changeReceiveToken(tokenName: String) { + step("Open receive token selector") { + onSwapTokenScreen { receiveSelectTokenIcon.performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + +/** + * From a clean start: open the main screen (cold by default, or an existing hot wallet when + * [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and + * enter [amount]. Scenario states stay in the test body. + */ +fun BaseTestCase.openSwapAmountScreen( + fromTokenName: String, + receiveTokenName: String, + amount: String, + seedPhrase: String? = null, +) { + if (seedPhrase == null) { + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + } else { + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + } + step("Click on token with name: '$fromTokenName'") { + onMainScreen { tokenWithTitleAndAddress(fromTokenName).clickWithAssertion() } + } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails) + } + step("Choose receive token '$receiveTokenName'") { + chooseReceiveToken(receiveTokenName) + } + step("Input swap amount '$amount'") { + waitForIdle() + onSwapTokenScreen { + textInput.clickWithAssertion() + textInput.performTextReplacement(amount) + } + } + step("Wait for the receive amount to load") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { receiveAmount.assertIsDisplayed() } }.isSuccess + } + } +} + +/** + * Opens the swap 'Network fee' bottom sheet, retrying the click until the fee selector shows. + * Single action without its own step — wrap the call in a `step(...)`. + */ +fun BaseTestCase.openSwapNetworkFeeSelector() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onSwapTokenScreen { networkFeeBlock.performClick() } } + runCatching { onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } }.isSuccess + } +} + +/** + * Opens the fee selector and switches the fee-paying token from [currentFeeToken] to [newFeeToken], + * then applies. Works both ways — coin -> stablecoin and back. + */ +fun BaseTestCase.switchFeeTokenAndApply(currentFeeToken: String, newFeeToken: String) { + step("Open the 'Network fee' bottom sheet") { + openSwapNetworkFeeSelector() + } + step("Click on '$currentFeeToken' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(currentFeeToken).performClick() } + } + step("Select '$newFeeToken' as the fee-paying token") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(newFeeToken).performClick() } }.isSuccess + } + } + step("Click on 'Apply' button") { + waitForIdle() + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } +} + /** Holds the last BASE_BUTTON; enters [accessCode] if a hot wallet prompts for it. */ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { val buttonMatcher = hasTestTag(BaseButtonTestTags.BUTTON) @@ -316,6 +490,15 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } +// Caller asserts the outcome — transfer mode has no in-progress marker to wait on. +fun BaseTestCase.holdToConfirmTransfer() { + composeTestRule.onNode( + hasTestTag(BaseButtonTestTags.BUTTON) and + hasText(getResourceString(CoreUiR.string.swapping_transfer_action)), + ).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + waitForIdle() +} + sealed class SwapEntryPoint { object MainScreen : SwapEntryPoint() object TokenDetails : SwapEntryPoint() @@ -328,4 +511,35 @@ enum class FeeType { Fast } +fun BaseTestCase.inputAmount(amount: String) { + // No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle. + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess + } + onSwapTokenScreen { + textInput.clickWithAssertion() + textInput.performTextReplacement(amount) + } +} + +// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase. +fun BaseTestCase.assertTransferReady() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess + } + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } +} + +fun BaseTestCase.waitForFeeDisplayed() { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess + } +} + +fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean { + var current = "" + onSwapTokenScreen { current = feeAmount.extractText() } + return current.isNotEmpty() && current != previousFee +} + diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt new file mode 100644 index 0000000000..abb099d1a1 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt @@ -0,0 +1,80 @@ +package com.tangem.screens + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.test.MarketsTestTags +import com.tangem.core.ui.test.TokenActionsTestTags +import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText + +class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { + + val buyButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_buy))) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true + } + + val receiveButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_receive))) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val buyTokenButton: KNode = child { + hasTestTag(TokenActionsTestTags.BUY_ACTION) + useUnmergedTree = true + } + + fun userTokenWithTitle(tokenTitle: String): KNode = child { + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + hasAnyDescendant(withTestTag(TokenElementsTestTags.TOKEN_TITLE)) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true + } + + private val trendingTokensList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BuyTokenScreenTestTags.LAZY_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) + }, + ) + + @OptIn(ExperimentalTestApi::class) + fun trendingTokenWithTitle(tokenTitle: String): LazyListItemNode = + trendingTokensList.childWith { + hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt new file mode 100644 index 0000000000..9046abeb11 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt @@ -0,0 +1,27 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasClickAction +import com.tangem.common.BaseTestCase +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun walletName(walletName: String): KNode = child { + hasText(walletName) + useUnmergedTree = true + } + + val addButton: KNode = child { + hasText(getResourceString(R.string.common_add)) + hasClickAction() + } +} + +internal fun BaseTestCase.onAddToPortfolioScreen(function: AddToPortfolioPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt index fc864f40e2..61a833964a 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddTokenBottomSheetPageObject.kt @@ -11,18 +11,32 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { val title: KNode = child { hasTestTag(BaseBottomSheetTestTags.TITLE) hasText(getResourceString(R.string.common_add_token)) } + val closeButton: KNode = child { + hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + val addButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) hasText(getResourceString(R.string.common_add)) useUnmergedTree = true } + + val laterButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.common_later)) + useUnmergedTree = true + } } internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AppCurrencySelectorPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AppCurrencySelectorPageObject.kt new file mode 100644 index 0000000000..11b69d0e53 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AppCurrencySelectorPageObject.kt @@ -0,0 +1,30 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.AppCurrencySelectorScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import androidx.compose.ui.test.hasText as withText + +class AppCurrencySelectorPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val searchActionButton: KNode = child { + hasTestTag(AppCurrencySelectorScreenTestTags.TOP_BAR_ACTION_BUTTON) + } + + val searchField: KNode = child { + hasTestTag(AppCurrencySelectorScreenTestTags.SEARCH_FIELD) + } + + fun currencyItem(code: String): KNode = child { + hasTestTag(AppCurrencySelectorScreenTestTags.CURRENCY_ITEM) + hasAnyDescendant(withText(code, substring = true)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAppCurrencySelectorScreen(function: AppCurrencySelectorPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt new file mode 100644 index 0000000000..240f3470e9 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt @@ -0,0 +1,20 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.AppSettingsScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val currencyButton: KNode = child { + hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt index 1dbe1d3ba3..c3b236391e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt @@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } } + + fun walletTab(walletName: String): KNode = child { + hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) + hasAnyDescendant(withText(walletName)) + useUnmergedTree = true + } + + @OptIn(ExperimentalTestApi::class) + fun tokenWithTitle(tokenTitle: String): LazyListItemNode = lazyList.childWith { + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } } internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt similarity index 53% rename from app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt index 28ebe26a4a..f0618bcd82 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt @@ -2,29 +2,38 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.test.TokenElementsTestTags -import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import com.tangem.core.res.R as CoreResR /** - * "You receive" token chooser opened from the main-screen "Add funds" button. + * Token chooser bottom sheet opened from the main-screen "Add funds" button. + * + * After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet + * (centered title + close icon), not a full screen with a top app bar. */ -class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { +class ChooseTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { - val topAppBarTitle: KNode = child { - hasTestTag(TopAppBarTestTags.TITLE) + val title: KNode = child { + hasText(getResourceString(CoreResR.string.common_choose_token)) useUnmergedTree = true } val searchBar: KNode = child { hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) + useUnmergedTree = true } fun tokenWithTitle(tokenTitle: String): KNode = child { @@ -35,5 +44,5 @@ class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider } } -internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) = +internal fun BaseTestCase.onChooseTokenBottomSheet(function: ChooseTokenBottomSheetPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt index 69a1652732..110188ecbc 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -22,17 +23,13 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(getResourceString(R.string.wallet_connect_title)) } - private val walletBlock: KNode = child { - hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) + val walletNameButton: KNode = child { + hasTestTag(DetailsScreenTestTags.USER_WALLET_ITEM) + useUnmergedTree = true } - val walletNameButton: KNode = walletBlock.child { - hasClickAction() - hasPosition(0) - } - - val scanCardButton: KNode = walletBlock.child { - hasText(getResourceString(R.string.scan_card_settings_button)) + val addWalletButton: KNode = child { + hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON) } val buyTangemButton: KNode = child { @@ -58,6 +55,12 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(DetailsScreenTestTags.VERSION_NAME) useUnmergedTree = true } + + fun walletNameValue(name: String): KNode = child { + hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) + hasAnyDescendant(withText(name)) + useUnmergedTree = true + } } internal fun BaseTestCase.onDetailsScreen(function: DetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt index 7822f4b34e..f390b77959 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DeviceSettingsPageObject.kt @@ -13,6 +13,9 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasClickAction as withClickAction +import androidx.compose.ui.test.hasText as withText +import androidx.compose.ui.test.isNotEnabled as withDisabled class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -46,6 +49,19 @@ class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi useUnmergedTree = true } + val securityModeRowTitle: KNode = child { + hasTestTag(DeviceSettingsScreenTestTags.ITEM_TITLE) + hasText(getResourceString(R.string.card_settings_security_mode)) + useUnmergedTree = true + } + + // Match the row container (not the title Text): enabled exposes a click action, disabled exposes disabled semantics. + val securityModeRow: KNode = child { + addSemanticsMatcher(withClickAction() or withDisabled()) + hasAnyDescendant(withText(getResourceString(R.string.card_settings_security_mode))) + useUnmergedTree = true + } + fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child { hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE) useUnmergedTree = true diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 87e72b5b05..b3efafdd81 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -25,6 +26,17 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseDialogTestTags.TEXT) } + val inputField: KNode = child { + hasSetTextAction() + hasAnyAncestor(withTestTag(BaseDialogTestTags.TEXT_INPUT_FIELD)) + useUnmergedTree = true + } + + val gotItButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_got_it)) + } + val cancelButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_cancel)) @@ -45,6 +57,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(getResourceString(R.string.account_details_archive_action)) } + val discardButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.account_unsaved_dialog_action_second)) + } + + val keepEditButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.account_unsaved_dialog_action_first)) + } + val continueButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_continue)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index db62a6786a..c6b343f37b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -1,10 +1,7 @@ package com.tangem.screens import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.* import com.tangem.common.BaseTestCase import com.tangem.common.extensions.getQuantityString import com.tangem.common.extensions.hasLazyListItemPosition @@ -22,7 +19,7 @@ import androidx.compose.ui.test.hasText as withText import com.tangem.core.res.R as CoreResR import com.tangem.core.ui.R as CoreUiR -class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : +class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { private val lazyList = KLazyListNode( @@ -49,32 +46,38 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) val buyButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_buy)) + hasAnyDescendant(withText(getResourceString(R.string.common_buy))) + useUnmergedTree = true } val addFundsButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_add_funds)) + hasAnyDescendant(withText(getResourceString(R.string.common_add_funds))) + useUnmergedTree = true } val sendButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_send)) + hasAnyDescendant(withText(getResourceString(R.string.common_send))) + useUnmergedTree = true } val receiveButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_receive)) + hasAnyDescendant(withText(getResourceString(R.string.common_receive))) + useUnmergedTree = true } - val sellButton: KNode = child { + val transferButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_sell)) + hasAnyDescendant(withText(getResourceString(R.string.common_transfer))) + useUnmergedTree = true } val swapButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_swap)) + hasAnyDescendant(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true } val walletNameText: KNode = child { @@ -87,13 +90,68 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } - val walletDevicesCount: KNode = child { - hasTestTag(MainScreenTestTags.DEVICES_COUNT) + /** + * Collapses the collapsing header via a touch-based swipe so that items near the bottom + * of the lazy list fall within screen bounds before programmatic childWith scroll. + * Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which + * pushes lower list items off-screen when the header is expanded. + */ + private fun collapseHeader() { + screenContainer { + performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) } + } + } + + /** Scrolls to [accountName] via ScrollToIndex semantics, not a touch swipe — a bottom-edge drag is stolen by the Markets sheet's nested scroll. */ + @OptIn(ExperimentalTestApi::class) + fun scrollToAccount(accountName: String) { + semanticsProvider.onNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER)) + .performScrollToNode( + withTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) and hasAnyDescendant(withText(accountName)), + ) + } + + // Wallet pager keeps the adjacent page composed (beyondViewportPageCount=1), so the token is mounted on two pages — click the displayed copy. + fun clickDisplayedToken(tokenName: String) { + val matcher = withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenName)) + val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = true) + for (i in 0 until nodes.fetchSemanticsNodes().size) { + if (runCatching { nodes[i].assertIsDisplayed(); nodes[i].performClick() }.isSuccess) return + } + error("Token '$tokenName' is not displayed on the current wallet page") + } + + // Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen. + fun swipeToAdjacentWallet(toPrevious: Boolean) { + val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) + for (i in 0 until nodes.fetchSemanticsNodes().size) { + val swiped = runCatching { + nodes[i].assertIsDisplayed() + nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() } + }.isSuccess + if (swiped) return + } + } + + val restoringProgressText: KNode = child { + hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) + useUnmergedTree = true + } + + val walletImportedBanner: KNode = child { + hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + useUnmergedTree = true + } + + val walletImportedBannerCheckHereButton: KNode = child { + hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)) + hasText(getResourceString(CoreResR.string.main_manage_tokens)) useUnmergedTree = true } @OptIn(ExperimentalTestApi::class) fun marketPriceBlock(): LazyListItemNode { + collapseHeader() return lazyList.childWith { hasTestTag(MarketPriceBlockTestTags.BLOCK) useUnmergedTree = true @@ -225,6 +283,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + /** + * Empty-tokens placeholder shown under an expanded account that has no tokens. + */ + val emptyAccountTokensPlaceholder: KNode = child { + hasTestTag(MainScreenTestTags.EMPTY_TOKENS_PLACEHOLDER) + useUnmergedTree = true + } + + /** + * 'Add tokens' button inside the empty-account placeholder. Click opens manage tokens for that account. + */ + val emptyAccountAddTokensButton: KNode = child { + hasTestTag(MainScreenTestTags.EMPTY_TOKENS_ADD_BUTTON) + useUnmergedTree = true + } + /** * Main account header on the main screen. Click to expand/collapse its tokens list. */ @@ -236,6 +310,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) */ @OptIn(ExperimentalTestApi::class) fun accountWithName(name: String): LazyListItemNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasAnyDescendant(withText(name)) @@ -243,11 +318,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + @OptIn(ExperimentalTestApi::class) + fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode { + return lazyList.childWith { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } + } + /** * Find token list item with title and address */ @OptIn(ExperimentalTestApi::class) fun tokenWithTitleAndAddress(tokenTitle: String): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) @@ -260,6 +345,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) @@ -272,6 +358,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun addAndManageButton(): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) }.child { @@ -287,11 +374,17 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } val searchThroughMarketPlaceholder: KNode = child { - hasText(getResourceString(R.string.markets_search_header_title)) + hasText(getResourceString(R.string.markets_search_title_placeholder)) + useUnmergedTree = true + } + + val marketsSheetDragHandle: KNode = child { + hasTestTag(MainScreenTestTags.MARKETS_SHEET_DRAG_HANDLE) useUnmergedTree = true } fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { + collapseHeader() return lazyList.child { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasAnyChild(withText(tokenNetwork)) @@ -301,6 +394,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) @OptIn(ExperimentalTestApi::class) fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { + collapseHeader() return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasText(tokenTitle) @@ -312,6 +406,46 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } } + /** + * Account row on the main screen. Tappable — click to expand/collapse its tokens. + */ + @OptIn(ExperimentalTestApi::class) + fun findAccountSectionByName(accountName: String): KNode { + return lazyList.child { + hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) + hasAnyDescendant(withText(accountName)) + useUnmergedTree = true + } + } + + /** + * Scrolls the account row into view and collapses the top bar so the account's tokens (or the + * empty placeholder) land within screen bounds after expansion. Click via [findAccountSectionByName]. + */ + @OptIn(ExperimentalTestApi::class) + fun scrollToAccountSection(accountName: String) { + collapseHeader() + lazyList.childWith { + hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) + hasAnyDescendant(withText(accountName)) + useUnmergedTree = true + } + } + + /** + * Find a token row on the main screen by token name. Tokens belonging to collapsed accounts + * are hidden from the semantics tree, so expanding a single account before calling this + * effectively scopes the lookup to that account's tokens. + */ + @OptIn(ExperimentalTestApi::class) + fun findTokenInAnyAccountByName(tokenName: String): KNode { + return lazyList.child { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasAnyDescendant(withText(tokenName)) + useUnmergedTree = true + } + } + fun KNode.assertIsUnreachable() { this { hasAnyAncestor(withText(getResourceString(R.string.common_unreachable))) @@ -324,16 +458,25 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) * Tests will fail if assertIsNotDisplayed() or assertDoesNotExist() are used instead. */ fun assertTokenDoesNotExist(tokenTitle: String) { - try { - tokenWithTitleAndAddress(tokenTitle).assertExists() - throw AssertionError("Token with title '$tokenTitle' should not exist but was found") - } catch (e: AssertionError) { - if (e.message?.contains("No node found") == true) { - return - } else { - throw e - } - } + lazyList.child { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true + }.assertDoesNotExist() + } + + fun assertTokensCount(expectedCount: Int) { + semanticsProvider + .onAllNodes(withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM), useUnmergedTree = true) + .assertCountEquals(expectedCount) + } + + fun assertTokenExists(tokenTitle: String) { + lazyList.child { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true + }.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ManageTokensPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ManageTokensPageObject.kt index 2d9882fde8..c91348bcb6 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ManageTokensPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ManageTokensPageObject.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.ManageTokensScreenTestTags import com.tangem.core.ui.test.SwitchTestTags +import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode @@ -20,6 +21,16 @@ import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val topAppBarBackButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + } + + val topAppBarTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(com.tangem.core.ui.R.string.add_tokens_title)) + useUnmergedTree = true + } + val searchField: KNode = child { hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt index 00d7f17067..2ae6458a3b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsExchangesPageObject.kt @@ -2,11 +2,9 @@ package com.tangem.screens import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasTestTag import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.TokenElementsTestTags -import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.features.onramp.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions fun allExchangeTypeNodes(): List = provider - .onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)))) + .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)) .fetchSemanticsNodes() fun allTrustScoreNodes(): List = provider - .onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))) + .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)) .fetchSemanticsNodes() val exchangesTitle: KNode = child { - hasTestTag(TopAppBarTestTags.TITLE) hasText(getResourceString(R.string.markets_token_details_exchanges_title)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt index 1eefd12486..dd24c4d3ca 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsPageObject.kt @@ -1,6 +1,8 @@ package com.tangem.screens +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasTestTag import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX import com.tangem.core.ui.test.BaseButtonTestTags @@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { - val addToPortfolioButton: KNode = child { + val addButton: KNode = child { hasTestTag(BaseButtonTestTags.TEXT) - hasText(getResourceString(R.string.common_add_to_portfolio)) + hasText(getResourceString(R.string.common_add)) useUnmergedTree = true } @@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : } val searchThroughMarketPlaceholder: KNode = child { - hasText(getResourceString(R.string.markets_search_header_title)) + hasText(getResourceString(R.string.markets_search_title_placeholder)) + useUnmergedTree = true + } + + val tokenDetailsContent: KNode = child { + hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT) useUnmergedTree = true } @@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : } val listedOnBlockContainer: KNode = child { - hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true) + hasTestTag(MarketsTestTags.LISTED_ON_BLOCK) + useUnmergedTree = true } val listedOnEmptyText: KNode = child { @@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(title) } } + + @ExperimentalTestApi + fun scrollToListedOnBlock() { + tokenDetailsContent { + performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)) + } + } } internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt index 237a0f183b..cdd4f4843c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MarketsTokenDetailsPageObject.kt @@ -3,14 +3,13 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags -import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.features.onramp.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import com.tangem.core.ui.R as CoreUiR class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions hasText(getResourceString(R.string.common_swap), substring = true) } + val inYourPortfolioBlock: KNode = child { + hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true) + useUnmergedTree = true + } + fun tokenWithTitle(title: String): KNode = child { - hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM)) - hasTestTag(TokenElementsTestTags.TOKEN_TITLE) - hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON)) hasAnyChild(withText(title)) + hasClickAction() useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt index 1d5bd3418b..2ea02089e6 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/OrganizeTokensPageObject.kt @@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi useUnmergedTree = true } - private val topBarGroupButton: KNode = child { - hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) + val organizeMenuButton: KNode = child { + hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON) useUnmergedTree = true } - val groupButton: KNode = topBarGroupButton.child { + val groupButton: KNode = child { hasText(getResourceString(R.string.organize_tokens_group)) useUnmergedTree = true } - val ungroupButton: KNode = topBarGroupButton.child { - hasText(getResourceString(R.string.organize_tokens_ungroup)) - useUnmergedTree = true - } - val sortByBalanceButton: KNode = child { - hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON) + hasText(getResourceString(R.string.organize_tokens_sort_by_balance)) useUnmergedTree = true } // endregion TopBar @@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { return lazyList.child { hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM) - hasAnyChild(withText(tokenNetwork)) + hasAnyDescendant(withText(tokenNetwork)) useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SecurityModePageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SecurityModePageObject.kt new file mode 100644 index 0000000000..3b668e21d0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SecurityModePageObject.kt @@ -0,0 +1,27 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class SecurityModePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + // Description only appears on the Security Mode screen — unambiguous "screen opened" signal. + val longTapOptionDescription: KNode = child { + hasText(getResourceString(R.string.details_manage_security_long_tap_description)) + useUnmergedTree = true + } + + val saveChangesButton: KNode = child { + hasText(getResourceString(R.string.common_save_changes)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onSecurityModeScreen(function: SecurityModePageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt index 85d6640b23..e16444e9c7 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendAddressPageObject.kt @@ -6,7 +6,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.FooterTestTags import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags -import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode @@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider ): KNode = child { hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM) hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON)) - hasAnyDescendant(withText(recipientAddress)) + hasAnyDescendant(withText(recipientAddress, substring = true)) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) useUnmergedTree = true if (description != null) { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 6f857ab540..cba37da5a0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + fun warningMessageContaining(textPart: String): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(textPart, substring = true) + useUnmergedTree = true + } + + fun warningTitleContaining(textPart: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(textPart, substring = true) + useUnmergedTree = true + } + fun warningIcon(message: String): KNode = child { hasTestTag(NotificationTestTags.ICON) hasAnySibling(withText(message)) @@ -96,6 +108,16 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + val recipientBlock: KNode = child { + hasTestTag(SendConfirmScreenTestTags.RECIPIENT_BLOCK) + useUnmergedTree = true + } + + val recipientMemo: KNode = child { + hasTestTag(SendConfirmScreenTestTags.RECIPIENT_MEMO) + useUnmergedTree = true + } + val provider: KNode = child { hasText(getResourceString(R.string.express_provider)) useUnmergedTree = true @@ -145,6 +167,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + fun feeBlockCurrency(symbol: String): KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK) + hasAnyDescendant(withText(symbol)) + useUnmergedTree = true + } + val refreshButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(CoreUiR.string.warning_button_refresh)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt new file mode 100644 index 0000000000..20d5703c90 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt @@ -0,0 +1,65 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags +import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.wallet.R +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +/** + * Gasless fee selector modal: the `NetworkFee` route (fee-paying token row + selected speed) and the + * `ChooseToken` route. The `ChooseSpeed` route is covered by [SendSelectNetworkFeeBottomSheetPageObject]. + */ +class SendFeeSelectorBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val networkFeeTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.common_network_fee_title)) + useUnmergedTree = true + } + + val chooseTokenTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.fee_selector_choose_token_title)) + useUnmergedTree = true + } + + val feeTokenRow: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_TITLE) + useUnmergedTree = true + } + + fun feeTokenItem(tokenName: String): KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_TITLE) + hasAnyChild(withText(tokenName)) + useUnmergedTree = true + } + + fun feeSpeedItemTitle(speed: String): KNode = child { + hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE) + hasText(speed) + useUnmergedTree = true + } + + val applyButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_apply))) + useUnmergedTree = true + } + + val notEnoughFundsError: KNode = child { + hasText(getResourceString(R.string.gasless_not_enough_funds_to_cover_token_fee)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onSendFeeSelectorBottomSheet(function: SendFeeSelectorBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt index dc63b4a2ed..eb85e56e78 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendPageObject.kt @@ -9,7 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString -import com.tangem.features.send.v2.impl.R as SendR +import com.tangem.features.send.impl.R as SendR import androidx.compose.ui.test.hasText as withText class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt index 3ba2a38384..0dace29264 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt @@ -20,6 +20,13 @@ class SwapSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + // App Transfers reuses the swap success screen; in transfer mode its title is "Transfer in progress". + val transferInProgressTitle: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TITLE) + hasText(getResourceString(R.string.transfer_in_progress_title)) + useUnmergedTree = true + } + val closeButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasAnyDescendant(withText(getResourceString(R.string.common_close))) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index d58c82d8df..c123dbb27c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -54,6 +54,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + fun feeBlockCurrency(symbol: String): KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK) + hasAnyDescendant(withText(symbol)) + useUnmergedTree = true + } + val receiveAmountShimmer: KNode = child { hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER) } @@ -72,6 +78,11 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + val bestRateText: KNode = child { + hasText(getResourceString(R.string.express_provider_best_rate)) + useUnmergedTree = true + } + val errorNotificationTitle: KNode = child { hasTestTag(NotificationTestTags.TITLE) useUnmergedTree = true @@ -119,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + fun insufficientFeeForTransferNotificationTitle(feeCoinName: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCoinName)) + useUnmergedTree = true + } + fun warningTitle(title: String): KNode = child { hasTestTag(NotificationTestTags.TITLE) hasText(title) @@ -147,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_swap)) } + val transferButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.swapping_transfer_action)) + } + + val transferTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.common_transfer)) + useUnmergedTree = true + } + + // PercentPill testTag is the PredefinedPercentAmount enum name; MAX == "MAX". + val maxAmountButton: KNode = child { + hasTestTag("MAX") + useUnmergedTree = true + } + val youSwapBlock: KNode = child { hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER) hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2))) @@ -178,6 +212,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) val swapFiatAmount: KNode = child { hasTestTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT) + useUnmergedTree = true } val swapSelectTokenIcon: KNode = child { @@ -210,6 +245,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(R.string.common_choose_token)) useUnmergedTree = true } + + // Transfer mode auto-fills the memo/destination tag — the manual Send-address field must never render here. + val destinationTagField: KNode = child { + hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD) + useUnmergedTree = true + } } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 4996f7db80..149aaf1d89 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -1,20 +1,17 @@ package com.tangem.screens -import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags -import com.tangem.core.ui.utils.LazyListItemPositionSemantics import com.tangem.features.tokendetails.impl.R import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode -import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText @@ -36,18 +33,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - val availableStakingBlockTitle: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE) - useUnmergedTree = true - } - - val availableStakingBlockText: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT) - useUnmergedTree = true - } - - val availableStakingBlockCurrencyIcon: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON) + fun availableStakingBlockText(apy: String): KNode = child { + hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy)) useUnmergedTree = true } @@ -62,69 +49,45 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - val stakingDot: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT) - useUnmergedTree = true - } - val stakingTokenAmount: KNode = child { hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) useUnmergedTree = true } - val stakingChevronIcon: KNode = child { - hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON) - useUnmergedTree = true + val stakingTitle: KNode = child { + hasText(getResourceString(R.string.common_staking)) } - val stakingTitle: KNode = child { - hasText(getResourceString(R.string.staking_native)) + val stakingEnabledTitle: KNode = child { + hasText(getResourceString(R.string.staking_enabled)) } val title: KNode = child { hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) } - private val horizontalActionChips = KLazyListNode( - semanticsProvider = semanticsProvider, - viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) }, - itemTypeBuilder = { itemType(::LazyListItemNode) }, - positionMatcher = { position -> - SemanticsMatcher.expectValue( - LazyListItemPositionSemantics, - position - ) - } - ) - - @OptIn(ExperimentalTestApi::class) - fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith { - hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_receive)) + val fiatBalance: KNode = child { + hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT)) + addSemanticsMatcher(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text)) + useUnmergedTree = true } - @OptIn(ExperimentalTestApi::class) - fun swapButton(): LazyListItemNode = horizontalActionChips.childWith { + val addFundsButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_swap)) + hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds))) + useUnmergedTree = true } - @OptIn(ExperimentalTestApi::class) - fun sellButton(): LazyListItemNode = horizontalActionChips.childWith { + val swapButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_sell)) + hasAnyDescendant(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true } - @OptIn(ExperimentalTestApi::class) - fun buyButton(): LazyListItemNode = horizontalActionChips.childWith { + val transferButton: KNode = child { hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_buy)) - } - - @OptIn(ExperimentalTestApi::class) - fun sendButton(): LazyListItemNode = horizontalActionChips.childWith { - hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) - hasText(getResourceString(R.string.common_send)) + hasAnyDescendant(withText(getResourceString(R.string.common_transfer))) + useUnmergedTree = true } fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { @@ -204,7 +167,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) - hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON)) useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt new file mode 100644 index 0000000000..f8dd42807f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TransferBottomSheetPageObject.kt @@ -0,0 +1,41 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) }, + ) { + + val sendButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_send))) + useUnmergedTree = true + } + + val swapButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_swap))) + useUnmergedTree = true + } + + val sellButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_sell))) + useUnmergedTree = true + } + + val closeButton: KNode = child { + hasAnyChild(withText(getResourceString(R.string.common_close))) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt new file mode 100644 index 0000000000..3ce45be03f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt @@ -0,0 +1,37 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.TransactionHistoryItemTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import androidx.compose.ui.test.hasText as withText + +class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun transactionItem(title: String): KNode = child { + hasTestTag(TransactionHistoryItemTestTags.ITEM) + hasAnyDescendant(withText(title)) + useUnmergedTree = true + } + + fun transactionAmount(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.AMOUNT) + useUnmergedTree = true + } + + fun transactionCurrency(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.CURRENCY) + useUnmergedTree = true + } + + fun transactionConfirmedStatus(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index 5b84dda406..43e4d9058a 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -1,5 +1,6 @@ package com.tangem.screens +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.test.TopAppBarTestTags @@ -9,11 +10,16 @@ import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val screenContainer: KNode = child { + hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER) + } + val topAppBarBackButton: KNode = child { hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) } @@ -22,6 +28,31 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM) } + // The Accounts section loads async and can push rows below the fold — scroll before asserting/clicking. + private val scrollableContainer: KNode = child { + hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER) + } + + @OptIn(ExperimentalTestApi::class) + fun scrollToText(text: String) = scrollableContainer { performScrollToNode(withText(text)) } + + @OptIn(ExperimentalTestApi::class) + fun scrollToDeviceSettings() = scrollToText(getResourceString(R.string.card_settings_title)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToLinkMoreCards() = scrollToText(getResourceString(R.string.details_row_title_create_backup)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToReferralProgram() = scrollToText(getResourceString(R.string.details_referral_title)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToForgetWallet() = scrollToText(getResourceString(R.string.settings_forget_wallet)) + + @OptIn(ExperimentalTestApi::class) + fun scrollToRenameButton() = scrollableContainer { + performScrollToNode(withTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON)) + } + val linkMoreCardsButton: KNode = walletSettingsItem.child { hasText(getResourceString(R.string.details_row_title_create_backup)) } @@ -38,6 +69,16 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi hasText(getResourceString(R.string.settings_forget_wallet)) } + val renameWalletButton: KNode = child { + hasTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON) + useUnmergedTree = true + } + + fun walletNameValue(name: String): KNode = walletSettingsItem.child { + hasText(name) + useUnmergedTree = true + } + val accountsListContainer: KNode = walletSettingsItem.child { hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER) } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt index 56bb30983e..a2d7a6c358 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WarningBottomSheetPageObject.kt @@ -3,7 +3,6 @@ package com.tangem.screens import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase import com.tangem.core.ui.R -import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.WarningBottomSheetTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -31,27 +30,23 @@ class WarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsP } val okGotItButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY) hasText(getResourceString(R.string.warning_button_ok)) - useUnmergedTree = true } val gotItButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY) hasText(getResourceString(R.string.common_got_it)) - useUnmergedTree = true } val cancelButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY) hasText(getResourceString(R.string.common_cancel)) - useUnmergedTree = true } val connectAnywayButton: KNode = child { - hasTestTag(BaseButtonTestTags.TEXT) + hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY) hasText(getResourceString(R.string.wc_alert_connect_anyway)) - useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountInfoEditorPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountInfoEditorPageObject.kt new file mode 100644 index 0000000000..5250db790d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountInfoEditorPageObject.kt @@ -0,0 +1,45 @@ +package com.tangem.screens.accounts + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode + +class AccountInfoEditPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val screenContainer: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.ACCOUNT_DETAILS_CONTAINER) + } + + val accountNameField: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.NAME_FIELD) + } + + val accountCurrentIcon: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.SELECTED_ICON) + } + + val accountColorOption: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.COLOR_OPTION) + } + + val accountTypeOption: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.TYPE_OPTION) + } + + val saveAccountButton: KNode = child { + hasTestTag(AccountInfoEditScreenTestTags.SAVE_ACCOUNT_BUTTON) + } + + val crossButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + } + +} + +internal fun BaseTestCase.onAccountInfoEditorScreen(function: AccountInfoEditPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt index b9e4b888dd..5d43dec5e8 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt @@ -26,7 +26,7 @@ class TangemPayCardPagePageObject(semanticsProvider: SemanticsNodeInteractionsPr } val showDetailsButton: KNode = child { - hasTestTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON) + hasTestTag(TangemPayTestTags.SHOW_DETAILS_ROW) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt new file mode 100644 index 0000000000..e484f38fd4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt @@ -0,0 +1,90 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class AppCurrencyTest : BaseTestCase() { + + @AllureId("781") + @DisplayName("App Currency: change of equivalent") + @Test + fun changeAppCurrencyTest() { + val currenciesScenario = "currencies_api" + val appSettingsState = "AppSettings" + val targetCurrency = "EUR" + val targetSymbol = "€" + val token = "Bitcoin" + + setupHooks( + additionalAfterSection = { resetWireMockScenarioState(currenciesScenario) }, + ).run { + step("Set WireMock scenario '$currenciesScenario' to '$appSettingsState'") { + setWireMockScenarioState(scenarioName = currenciesScenario, state = appSettingsState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + synchronizeAddresses() + step("Open wallet details") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Click on 'App settings' button") { + onDetailsScreen { appSettingsButton.clickWithAssertion() } + } + step("Click on 'App currency' button") { + onAppSettingsScreen { currencyButton.clickWithAssertion() } + } + step("Click on search button") { + onAppCurrencySelectorScreen { searchActionButton.clickWithAssertion() } + } + step("Search currency '$targetCurrency'") { + onAppCurrencySelectorScreen { searchField.performTextInput(targetCurrency) } + } + step("Click on currency '$targetCurrency'") { + onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() } + } + step("Assert 'App settings' screen is open after currency selection") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onAppSettingsScreen { currencyButton.assertIsDisplayed() } + } + } + step("Return to 'Details' screen") { + waitForIdle() + device.uiDevice.pressBack() + } + step("Return to 'Main' screen via 'Back' button") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Main' screen is opened") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { screenContainer.assertIsDisplayed() } + } + } + step("Assert total balance contains '$targetSymbol' on 'Main' screen") { + // Balance re-loads in the new currency async after the switch — wait for the € equivalent. + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { totalBalanceText.assertTextContains(targetSymbol, substring = true) } + } + } + step("Click on token '$token'") { + onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() } + } + step("Assert token fiat balance contains '$targetSymbol'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { fiatBalance.assertTextContains(targetSymbol, substring = true) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index becda76ceb..8a918669d6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -44,8 +44,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } @@ -91,8 +91,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } @@ -165,8 +165,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } @@ -251,8 +251,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } @@ -336,8 +336,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } @@ -425,8 +425,8 @@ class BuyTokenTest : BaseTestCase() { onMainScreen { addFundsButton.clickWithAssertion() } } step("Click on token with name: '$tokenTitle'") { - onChooseTokenScreen { - topAppBarTitle.assertIsDisplayed() + onChooseTokenBottomSheet { + title.assertIsDisplayed() tokenWithTitle(tokenTitle).clickWithAssertion() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index 7b980f10d1..51339285da 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -5,14 +5,21 @@ import com.tangem.common.extensions.clickWithAssertion import com.tangem.domain.models.scan.ProductType import com.tangem.scenarios.openMainScreen import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.content.Firmware412MockContent +import com.tangem.tap.domain.sdk.mocks.content.S2CMockContent +import com.tangem.tap.domain.sdk.mocks.content.SingleCurrencyMockContent +import com.tangem.tap.domain.sdk.mocks.content.V3MockContent import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest class DetailsTest : BaseTestCase() { + @AllureId("836") + @DisplayName("Details: (Wallet) fields") @Test fun walletWithoutBackupDetailsTest() = setupHooks().run { @@ -46,70 +53,26 @@ class DetailsTest : BaseTestCase() { } onWalletSettingsScreen { step("Assert 'Link more cards' button is visible") { + scrollToLinkMoreCards() linkMoreCardsButton.assertIsDisplayed() } step("Assert 'Card Settings' button is visible") { + scrollToDeviceSettings() deviceSettingsButton.assertIsDisplayed() } step("Assert 'Referral program' button is visible") { + scrollToReferralProgram() referralProgramButton.assertIsDisplayed() } step("Assert 'Forget wallet' button is visible") { + scrollToForgetWallet() forgetWalletButton.assertIsDisplayed() } } } - // @Test - fun wallet2DetailsTest() = - setupHooks().run { - step("Open 'Main Screen'") { - openMainScreen(productType = ProductType.Wallet2) - } - onMainScreenTopBar { - step("Open wallet details") { - moreButton.clickWithAssertion() - } - } - onDetailsScreen { - step("Assert 'Wallet connect' button is visible") { - walletConnectButton.assertIsDisplayed() - } - step("Assert 'Scan card' button is visible") { - scanCardButton.assertIsDisplayed() - } - step("Assert 'Buy Tangem card' button is visible") { - buyTangemButton.assertIsDisplayed() - } - step("Assert 'App settings' button is visible") { - appSettingsButton.assertIsDisplayed() - } - step("Assert 'Contact support' button is visible") { - contactSupportButton.assertIsDisplayed() - } - step("Assert 'Terms or service' button is visible") { - toSButton.assertIsDisplayed() - } - step("Open 'Wallet settings' screen") { - walletNameButton.clickWithAssertion() - } - } - onWalletSettingsScreen { - step("Assert 'Link more cards' button does not exist") { - linkMoreCardsButton.assertIsNotDisplayed() - } - step("Assert 'Card Settings' button is visible") { - deviceSettingsButton.assertIsDisplayed() - } - step("Assert 'Referral program' button is visible") { - referralProgramButton.assertIsDisplayed() - } - step("Assert 'Forget wallet' button is visible") { - forgetWalletButton.assertIsDisplayed() - } - } - } - + @AllureId("837") + @DisplayName("Details: (Note) fields") @Test fun noteDetailsTest() = setupHooks().run { @@ -154,6 +117,214 @@ class DetailsTest : BaseTestCase() { } } + @AllureId("840") + @DisplayName("Details: (Twins) fields") + @Test + fun twinsDetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Twins, isTwinsCard = true) + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button does not exist") { + walletConnectButton.assertIsNotDisplayed() + } + step("Assert 'Buy Tangem card' button is visible") { + buyTangemButton.assertIsDisplayed() + } + step("Assert 'App settings' button is visible") { + appSettingsButton.assertIsDisplayed() + } + step("Assert 'Contact support' button is visible") { + contactSupportButton.assertIsDisplayed() + } + step("Assert 'Terms of service' button is visible") { + toSButton.assertIsDisplayed() + } + step("Assert app version is visible") { + versionName.assertIsDisplayed() + } + } + } + + @AllureId("839") + @DisplayName("Details: (v4.12) fields") + @Test + fun firmware412DetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = Firmware412MockContent) + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button is visible") { + walletConnectButton.assertIsDisplayed() + } + step("Assert 'Buy Tangem card' button is visible") { + buyTangemButton.assertIsDisplayed() + } + step("Assert 'App settings' button is visible") { + appSettingsButton.assertIsDisplayed() + } + step("Assert 'Contact support' button is visible") { + contactSupportButton.assertIsDisplayed() + } + step("Assert 'Terms of service' button is visible") { + toSButton.assertIsDisplayed() + } + step("Assert app version is visible") { + versionName.assertIsDisplayed() + } + } + } + + @AllureId("838") + @DisplayName("Details: (v3 multicurrency) fields") + @Test + fun v3MultiCurrencyDetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = V3MockContent) + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button is displayed") { + walletConnectButton.assertIsDisplayed() + } + step("Assert 'Buy Tangem card' button is displayed") { + buyTangemButton.assertIsDisplayed() + } + step("Assert 'App settings' button is displayed") { + appSettingsButton.assertIsDisplayed() + } + step("Assert 'Contact support' button is displayed") { + contactSupportButton.assertIsDisplayed() + } + step("Assert 'Terms of service' button is displayed") { + toSButton.assertIsDisplayed() + } + step("Assert app version is displayed") { + versionName.assertIsDisplayed() + } + } + } + + @AllureId("9832") + @DisplayName("Details: (single currency) fields") + @Test + fun singleCurrencyDetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = SingleCurrencyMockContent) + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button is not displayed") { + walletConnectButton.assertIsNotDisplayed() + } + step("Assert 'Buy Tangem card' button is displayed") { + buyTangemButton.assertIsDisplayed() + } + step("Assert 'App settings' button is displayed") { + appSettingsButton.assertIsDisplayed() + } + step("Assert 'Contact support' button is displayed") { + contactSupportButton.assertIsDisplayed() + } + step("Assert 'Terms of service' button is displayed") { + toSButton.assertIsDisplayed() + } + step("Assert app version is displayed") { + versionName.assertIsDisplayed() + } + } + } + + @AllureId("841") + @DisplayName("Details: (S2C) fields") + @Test + fun s2cDetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = S2CMockContent) + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button is not displayed") { + walletConnectButton.assertIsNotDisplayed() + } + step("Assert 'Buy Tangem card' button is displayed") { + buyTangemButton.assertIsDisplayed() + } + step("Assert 'App settings' button is displayed") { + appSettingsButton.assertIsDisplayed() + } + step("Assert 'Contact support' button is displayed") { + contactSupportButton.assertIsDisplayed() + } + step("Assert 'Terms of service' button is displayed") { + toSButton.assertIsDisplayed() + } + step("Assert app version is displayed") { + versionName.assertIsDisplayed() + } + } + } + + // Parked: createWalletActions adds Sell for single-wallet cards with no isStart2Coin() check. + @Ignore("[REDACTED_JIRA]") + @AllureId("2869") + @DisplayName("Details: (S2C) no trade buttons and standard details") + @Test + fun s2cNoTradeButtonsDetailsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(mockContent = S2CMockContent) + } + onMainScreen { + step("Assert 'Buy' button is not displayed") { + buyButton.assertIsNotDisplayed() + } + step("Assert 'Transfer' button is not displayed") { + transferButton.assertIsNotDisplayed() + } + step("Assert 'Swap' button is not displayed") { + swapButton.assertIsNotDisplayed() + } + } + onMainScreenTopBar { + step("Open wallet details") { + moreButton.clickWithAssertion() + } + } + onDetailsScreen { + step("Assert 'Wallet connect' button is not displayed") { + walletConnectButton.assertIsNotDisplayed() + } + } + } + @AllureId("3647") @DisplayName("Referral program: validate screen") @Test @@ -199,4 +370,32 @@ class DetailsTest : BaseTestCase() { onReferralProgramScreen { participateButton.assertIsDisplayed() } } } + + @AllureId("222") + @DisplayName("Details: ToS screen opening") + @Test + fun tosScreenOpeningTest() { + val tosUrl = "https://tangem.com/tangem_tos.html" + + setupHooks().run { + step("Open 'Main screen'") { + openMainScreen() + } + step("Open wallet details") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'ToS' screen") { + onDetailsScreen { toSButton.clickWithAssertion() } + } + step("Verify 'ToS' screen opened") { + onDisclaimerScreen { + title.assertIsDisplayed() + webView.assertIsDisplayed() + webView.assertContentDescriptionContains(tosUrl, true) + + } + } + } + } + } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index b7fa3d9c5b..821cf2ca7b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen import com.tangem.screens.onStoriesScreen import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onMainScreenTopBar +import com.tangem.screens.onTransferBottomSheet import com.tangem.tap.domain.sdk.mocks.MockProvider import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId @@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$sendAmount' in input text field") { onSendScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index 81fe762f61..23ae843dbc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() { fun groupTokensTest() { setupHooks().run { val tokenTitle = "Ethereum" - val tokenNetwork = "Ethereum network" + val networkTitleOrganize = "Ethereum" + val networkTitleMain = "Ethereum network" step("Open 'Main Screen'") { openMainScreen() @@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitle(tokenTitle).assertIsDisplayed() } } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } step("Click 'Group' button") { onOrganizeTokensScreen { groupButton.clickWithAssertion() } } step("Assert tokens were grouped on 'Organize tokens' screen") { - onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } + onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() } } step("Click 'Apply' button") { onOrganizeTokensScreen { applyButton.clickWithAssertion() } } step("Assert tokens were grouped on 'Main screen'") { - onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } + onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() } } step("Open 'Organize tokens' screen") { openOrganizeTokensScreen() @@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitle(tokenTitle).assertIsDisplayed() } } - step("Click 'Ungroup' button") { - onOrganizeTokensScreen { ungroupButton.clickWithAssertion() } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } + step("Click 'Group' checkbox again to ungroup") { + onOrganizeTokensScreen { groupButton.clickWithAssertion() } } step("Assert tokens were ungrouped on 'Organize tokens' screen") { - onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } + onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() } } step("Click 'Apply' button") { onOrganizeTokensScreen { applyButton.clickWithAssertion() } } step("Assert tokens were ungrouped on 'Main screen'") { - onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } + onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() } } } } @@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() } } + step("Open organize menu") { + onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() } + } step("Click 'By Balance' button") { onOrganizeTokensScreen { sortByBalanceButton.clickWithAssertion() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt index 1b83533fcb..d3511f52da 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/ScanCardTest.kt @@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(cardType) } step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") { - checkSingleCurrencyMainScreen( - cardBlockchain = cardBlockchain, - cardTitle = cardType.name, - withTransactions = true - ) + checkSingleCurrencyMainScreen(cardTitle = cardType.name) } } } @@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(mockContent = cardType, isTwinsCard = true) } step("Check 'Main' screen for '$cardName' $cardBlockchain card") { - checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName) + checkSingleCurrencyMainScreen(cardTitle = cardName) } } } @@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: Card with Secp256k1 curve") @Test fun secpk1CurveCardScanTest() { - val devicesCount = "1 device" val cardType: MockContent = Secpk1CurveMockContent val cardName = "Wallet" val card = "card with Secp256k1 curve" @@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on $card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen( - devicesCount = devicesCount, - cardTitle = cardName, - withWalletImage = false - ) + step("Check 'Main' screen for $card curve") { + checkMultiCurrencyMainScreen(cardTitle = cardName) } } } @@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() { openMainScreen(mockContent = cardType) } step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") { - checkSingleCurrencyMainScreen( - cardBlockchain = cardBlockchain, - cardTitle = cardName, - withWalletImage = false - ) + checkSingleCurrencyMainScreen(cardTitle = cardName) } } } @@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Shiba' card") @Test fun shibaCardScanTest() { - val devicesCount = "2 devices" val cardType: MockContent = ShibaMockContent val cardName = "Wallet" val card = "Shiba" @@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card' card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Ring'") @Test fun ringScanTest() { - val devicesCount = "3 devices" val cardType: ProductType = ProductType.Ring val cardName = "Wallet" val ring = "Ring" @@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$ring'") { openMainScreen(productType = cardType) } - step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$ring'") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Wallet' card") @Test fun walletCardScanTest() { - val devicesCount = "1 device" val cardType: ProductType = ProductType.Wallet val cardName = "Wallet" @@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$cardName' card") { openMainScreen(productType = cardType) } - step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$cardName' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: 'Wallet 2' card") @Test fun wallet2ScanTest() { - val devicesCount = "2 devices" val cardType: MockContent = Wallet2MockContent val cardName = "Wallet" val card = "Wallet 2" @@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card' card") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card' card") { + checkMultiCurrencyMainScreen(cardName) } } } @@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() { @DisplayName("Scan: Card with 4.12 firmware") @Test fun firmware412CardScanTest() { - val devicesCount = "1 device" val cardType: MockContent = Firmware412MockContent val cardName = "Tangem card" val card = "card with 4.12 firmware" @@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() { step("Open 'Main Screen' on '$card'") { openMainScreen(mockContent = cardType) } - step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") { - checkMultiCurrencyMainScreen(devicesCount, cardName) + step("Check 'Main' screen for '$card'") { + checkMultiCurrencyMainScreen(cardName) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SecurityModeTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SecurityModeTest.kt new file mode 100644 index 0000000000..8542e67bca --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/SecurityModeTest.kt @@ -0,0 +1,68 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.openDeviceSettingsScreen +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.scanCardInDeviceSettings +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SecurityModeTest : BaseTestCase() { + + @AllureId("2267") + @DisplayName("Security Mode: available for Twin cards") + @Test + fun securityModeOpensForTwinsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen(productType = ProductType.Twins, isTwinsCard = true) + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Scan card in 'Device settings'") { + scanCardInDeviceSettings() + } + step("Assert 'Security mode' row is enabled") { + onDeviceSettingsScreen { securityModeRow.assertIsEnabled() } + } + step("Click on 'Security mode' button") { + onDeviceSettingsScreen { securityModeRow.performClick() } + } + onSecurityModeScreen { + step("Assert 'Long tap' option is displayed") { + longTapOptionDescription.assertIsDisplayed() + } + step("Assert 'Save changes' button is displayed") { + saveChangesButton.assertIsDisplayed() + } + } + } + + @AllureId("9831") + @DisplayName("Security Mode: unavailable for single-capability cards") + @Test + fun securityModeRowDisabledForOtherCardsTest() = + setupHooks().run { + step("Open 'Main Screen'") { + openMainScreen() + } + step("Open 'Device settings' screen") { + openDeviceSettingsScreen() + } + step("Scan card in 'Device settings'") { + scanCardInDeviceSettings() + } + step("Assert 'Security mode' row title is displayed") { + onDeviceSettingsScreen { securityModeRowTitle.assertIsDisplayed() } + } + step("Assert 'Security mode' row is disabled") { + onDeviceSettingsScreen { securityModeRow.assertIsNotEnabled() } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index d02ce135ee..013753f460 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() { onTokenDetailsScreen { stakingBlock.assertIsDisplayed() } } step("Assert 'Staking title' is displayed") { - onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } + onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() } } step("Assert 'Staking fiat amount' is displayed") { onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() } } - step("Assert 'Staking dot' is displayed") { - onTokenDetailsScreen { stakingDot.assertIsDisplayed() } - } step("Assert 'Staking token amount' is displayed") { onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() } } - step("Assert 'Staking block chevron icon' is displayed") { - onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() } - } } } @@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() { val scenarioName = "staking_eth_pol_balances_android" val scenarioState = "Started" val stakingAmount = "1" + val stakingApy = "2.84%" setupHooks( additionalAfterSection = { @@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() { onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() } } step("Assert 'Available staking block' title is displayed") { - onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() } + onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } } step("Assert 'Available staking block' text is displayed") { - onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() } - } - step("Assert 'Available staking block' currency icon is displayed") { - onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() } + onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() } } step("Click on 'Stake' button") { onTokenDetailsScreen { stakeButton.clickWithAssertion() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt new file mode 100644 index 0000000000..2c3e1d848b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/WalletRenameTest.kt @@ -0,0 +1,59 @@ +package com.tangem.tests + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.scenarios.openMainScreen +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class WalletRenameTest : BaseTestCase() { + + @AllureId("2264") + @DisplayName("Wallet details: rename wallet") + @Test + fun renameWalletTest() = + setupHooks().run { + val newWalletName = "Tangem QA" + + step("Open 'Main Screen'") { + openMainScreen() + } + step("Open wallet details") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings' screen") { + onDetailsScreen { walletNameButton.clickWithAssertion() } + } + step("Click on 'Rename' button") { + onWalletSettingsScreen { + scrollToRenameButton() + renameWalletButton.clickWithAssertion() + } + } + step("Enter new wallet name '$newWalletName'") { + onDialog { inputField.performTextReplacement(newWalletName) } + } + step("Click on 'OK' button") { + onDialog { okButton.clickWithAssertion() } + } + step("Assert new wallet name '$newWalletName' is displayed on 'Wallet settings' screen") { + onWalletSettingsScreen { walletNameValue(newWalletName).assertIsDisplayed() } + } + step("Click on 'Back' button") { + onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert new wallet name '$newWalletName' is displayed on 'Details' screen") { + onDetailsScreen { walletNameValue(newWalletName).assertIsDisplayed() } + } + step("Click on 'Back' button") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert new wallet name '$newWalletName' is displayed on 'Main' screen") { + onMainScreen { walletNameText.assertTextContains(newWalletName) } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt index 51bc278976..e3c3937047 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/WarningTest.kt @@ -1,14 +1,10 @@ package com.tangem.tests import com.tangem.common.BaseTestCase -import com.tangem.common.utils.resetWireMockScenarioState -import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.screens.onMainScreen import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent -import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt index 70e4d5d02a..a6b7e599c7 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt @@ -3,6 +3,7 @@ package com.tangem.tests.accounts import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -10,7 +11,9 @@ import com.tangem.core.ui.R import com.tangem.scenarios.* import com.tangem.screens.accounts.onAccountDetailsScreen import com.tangem.screens.accounts.onArchivedAccountsScreen +import com.tangem.screens.onDetailsScreen import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreen import com.tangem.screens.onWalletSettingsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString @@ -164,8 +167,8 @@ class AccountArchivationsTest : BaseTestCase() { @Test @AllureId("5976") - @DisplayName("Accounts: restore an archived account") - fun restoreArchivedAccountTest() { + @DisplayName("Accounts: restore a simple archived account") + fun restoreSimpleArchivedAccountTest() { val archivedAccountName = "Account 3" val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" val userAccountsAfterArchivationState = "ReadyToRestore" @@ -202,6 +205,108 @@ class AccountArchivationsTest : BaseTestCase() { } } + @Test + @AllureId("5980") + @DisplayName("Accounts: restore archived account with custom token transfer") + fun restoreArchivedAccountWithCustomTokensTest() { + val mainAccountName = "Main account" + val archivedAccountName = "Account 2" + val customTokenName = "Ethereum" + val expectedArchivedTokensInfo = "1 token" + val userAccountsInitialState = "OneAccountWithArchivedCustomToken" + val userAccountsReadyToRestoreState = "ReadyToRestoreCustomToken" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsInitialState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() } + + step("Verify archived account '$archivedAccountName' shows '$expectedArchivedTokensInfo'") { + onArchivedAccountsScreen { + val row = findArchivedAccountItemByName(archivedAccountName) + row.container.assertIsDisplayed() + row.subtitle.assertTextContains(expectedArchivedTokensInfo, substring = true) + } + } + step("Switch WireMock to '$userAccountsReadyToRestoreState'") { + setWireMockScenarioState(userTokensScenario, userAccountsReadyToRestoreState) + } + step("Click restore button for '$archivedAccountName'") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(archivedAccountName) + .restoreButton.clickWithAssertion() + } + } + + step("Assert custom token migration dialog is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert dialog text mentions main account '$mainAccountName'") { + onDialog { text.assertTextContains(mainAccountName, substring = true) } + } + step("Assert dialog text mentions restoring account '$archivedAccountName'") { + onDialog { text.assertTextContains(archivedAccountName, substring = true) } + } + step("Confirm migration in dialog") { + onDialog { gotItButton.clickWithAssertion() } + } + + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert restored account '$archivedAccountName' is in active accounts list") { + onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() } + } + step("Navigate back to wallet details") { + onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Navigate back to main screen") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + + step("Assert main account '$mainAccountName' is visible on main screen") { + onMainScreen { findAccountSectionByName(mainAccountName).assertIsDisplayed() } + } + step("Assert restored account '$archivedAccountName' is visible on main screen") { + onMainScreen { findAccountSectionByName(archivedAccountName).assertIsDisplayed() } + } + + step("Expand main account '$mainAccountName'") { + onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() } + } + step("Assert '$customTokenName' is NOT displayed under main account") { + onMainScreen { assertTokenDoesNotExist(customTokenName) } + } + step("Expand main account '$mainAccountName'") { + onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() } + } + step("Assert '$customTokenName' is NOT displayed under main account") { + onMainScreen { + assertTokenDoesNotExist(customTokenName) + } + } + + step("Expand restored account '$archivedAccountName' and assert '$customTokenName' is displayed") { + onMainScreen { + findAccountSectionByName(archivedAccountName).clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onMainScreen { findTokenInAnyAccountByName(customTokenName).assertIsDisplayed() } + }, + ) + } + } + } + } + @Test @AllureId("7962") @DisplayName("Accounts: restore archived account error") @@ -250,4 +355,5 @@ class AccountArchivationsTest : BaseTestCase() { } } } + } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountCreationTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountCreationTest.kt new file mode 100644 index 0000000000..3793e13e4b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountCreationTest.kt @@ -0,0 +1,492 @@ +package com.tangem.tests.accounts + +import androidx.compose.ui.test.longClick +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.common.extensions.clickAndWaitFor +import com.tangem.common.extensions.clickOnSystemButton +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.DerivationPathHelper +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setClipboardText +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountInfoEditorScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Assert.assertTrue +import org.junit.Test + +@HiltAndroidTest +class AccountCreationTest : BaseTestCase() { + + private val userTokensScenario = "user_tokens_api" + + @Test + @AllureId("5504") + @DisplayName("Accounts: account creation network error handling") + fun accountCreationErrorTest() { + val accountName = "Account 2" + val userAccountsGetErrorState = "AccountsGetError" + val userAccountsPutErrorState = "AccountsPutError" + val userAccountsBeforeCreationState = "AccountReadyToCreate" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsGetErrorState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Start account creation") { startAccountCreation() } + + step("Enter account name: '$accountName'") { + onAccountInfoEditorScreen { + accountNameField.performClick() + accountNameField.performTextInput(accountName) + } + } + step("Click 'Add account' button (GET accounts is blocked)") { + onAccountInfoEditorScreen { + saveAccountButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onDialog { dialogContainer.assertIsDisplayed() } + }, + ) + } + } + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(com.tangem.core.ui.R.string.account_generic_error_dialog_message), + ) + } + step("Dismiss error dialog") { dismissErrorDialog() } + step("Assert still on account creation screen") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + + step("Unblock GET accounts, block PUT accounts") { + setWireMockScenarioState(userTokensScenario, userAccountsPutErrorState) + } + step("Click 'Add account' button again (PUT accounts is blocked)") { + onAccountInfoEditorScreen { + saveAccountButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onDialog { dialogContainer.assertIsDisplayed() } + }, + ) + } + } + step("Assert still on account creation screen") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(R.string.account_generic_error_dialog_message), + ) + } + + step("Unblock both 'accounts' requests") { + setWireMockScenarioState(userTokensScenario, userAccountsBeforeCreationState) + } + + step("Dismiss error dialog") { dismissErrorDialog() } + step("Assert still on account creation screen") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + + step("Click 'Add account' button again (both requests unblocked)") { + onAccountInfoEditorScreen { + saveAccountButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + }, + ) + } + } + step("Assert 'Manage Tokens' title is displayed") { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + } + step("Close 'Manage Tokens' screen") { + onManageTokensScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert new account '$accountName' appears in accounts list") { + onWalletSettingsScreen { accountItem(accountName).assertIsDisplayed() } + } + } + } + + @Test + @AllureId("5507") + @DisplayName("Accounts: name field verifications") + fun accountsCreationNameFieldValidationTest() { + val accountName = "TestAccount12" + val longName = "A".repeat(21) + val emptyPlaceholderValue = "New account" + val editedName = "Edited" + val context = device.context + val pasteButtonName = "Paste" + + setupHooks().run { + step("Set clipboard text '$longName'") { + setClipboardText(context,longName) + } + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open 'Wallet settings' screen") { openWalletSettingsScreen() } + step("Click on 'Add account' button") { + onWalletSettingsScreen { addAccountButton.clickWithAssertion() } + } + step("Assert 'Edit account details' dialog screen appears") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + + step("Enter account name manually: '$accountName'") { + onAccountInfoEditorScreen { + accountNameField.performClick() + accountNameField.performTextInput(accountName) + } + } + step("Assert name input is stable (keyboard doesn't flicker)") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(accountName) + } + } + step("Assert 'Add account' button is enabled") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsEnabled() + } + } + + step("Clear the 'Edit name' field") { + onAccountInfoEditorScreen { + accountNameField.performTextClearance() + } + } + step("Assert 'Add account' button becomes inactive when field is empty") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsNotEnabled() + } + } + step("Paste name from clipboard: '$accountName'") { + onAccountInfoEditorScreen { + accountNameField.performTextReplacement(accountName) + } + } + step("Assert pasted text is displayed in 'Account name' field") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(accountName) + } + } + step("Assert 'Add account' button is enabled") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsEnabled() + } + } + step("Edit the entered name (clear and retype)") { + onAccountInfoEditorScreen { + accountNameField.performTextReplacement(editedName) + } + } + step("Assert edited name in 'Account name' field is displayed") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(editedName) + } + } + step("Delete all text and leave 'Account name' field empty") { + onAccountInfoEditorScreen { + accountNameField.performTextClearance() + } + } + step("Assert 'Add account' button is inactive") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsNotEnabled() + } + } + step("Type name with more than 20 symbols") { + onAccountInfoEditorScreen { + accountNameField.performTextReplacement(longName) + } + } + step("Assert text over 20 symbols was not pasted and placeholder remains empty") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(emptyPlaceholderValue, substring = true) + } + } + step("Assert 'Add account' button is inactive") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsNotEnabled() + } + } + step("Clear text field") { + onAccountInfoEditorScreen { accountNameField.performTextClearance() } + } + step("Paste text longer than 20 characters to 'Account name' field") { + onAccountInfoEditorScreen { + accountNameField.performTouchInput { longClick(durationMillis = 2_000L) } + } + } + step("Click on system 'Paste' button to paste clipboard text") { + clickOnSystemButton(pasteButtonName) + } + step("Assert text over 20 symbols was not pasted and placeholder remains empty") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(emptyPlaceholderValue, substring = true) + } + } + step("Assert 'Add account' button is inactive") { + onAccountInfoEditorScreen { + saveAccountButton.assertIsNotEnabled() + } + } + } + } + + @Test + @AllureId("5505") + @DisplayName( + "Accounts: check unsaved changes notification " + + "after attempt to close edited account creation form" + ) + fun accountsCreationUnsavedChangesForNameFieldNotificationTest() { + val accountName = "Hikarik Test" + + setupHooks().run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open 'Wallet settings' screen") { openWalletSettingsScreen() } + step("Click on 'Add account' button") { + onWalletSettingsScreen { addAccountButton.clickWithAssertion() } + } + step("Assert edit account details dialog screen appears") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + + step("Enter account name manually: '$accountName'") { + onAccountInfoEditorScreen { + accountNameField.performClick() + accountNameField.performTextInput(accountName) + } + } + step("Tap 'Cross' button to attempt closing the screen") { + onAccountInfoEditorScreen { + crossButton.clickWithAssertion() + } + } + step("Verify 'Unsaved changes' screen parts") { + checkUnsavedChangesCreationModal() + } + + step("Tap 'Keep Editing' button to stay on screen") { + onDialog { keepEditButton.clickWithAssertion() } + } + step("Assert app still on 'Create account' screen") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + step("Assert previously entered data is preserved") { + onAccountInfoEditorScreen { + accountNameField.assertTextContains(accountName) + } + } + + step("Tap 'Cross' button to attempt closing the screen") { + onAccountInfoEditorScreen { crossButton.clickWithAssertion() } + } + step("Assert 'Unsaved changes' alert is displayed again") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Tap 'Discard' button to discard and close") { + onDialog { discardButton.clickWithAssertion() } + } + step("Assert 'Create account' screen is closed and 'Wallet settings' displayed again") { + onWalletSettingsScreen { + screenContainer.assertIsDisplayed() + } + } + step("Verify no new account has appeared in the list") { + onWalletSettingsScreen { + accountItem(accountName).assertDoesNotExist() + } + } + } + } + + @Test + @AllureId("5502") + @DisplayName("Accounts: account creation, accounts mode and per-account token derivation") + fun accountCreationAndDerivationTest() { + val createdAccountName = "Account 2" + val accountReadyState = "AccountReadyToCreateDerivation" + val accountIndex = "1" + val btcTokenName = "Bitcoin" + val ethTokenName = "Ethereum" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, accountReadyState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Start account creation") { startAccountCreation() } + + step("Enter account name: '$createdAccountName'") { + onAccountInfoEditorScreen { + accountNameField.performClick() + accountNameField.performTextInput(createdAccountName) + } + } + step("Assert account creation screen with derivation hint is displayed") { + onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() } + } + + step("Click 'Add account' and wait for 'Manage Tokens'") { + onAccountInfoEditorScreen { + saveAccountButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + }, + ) + } + } + + step("Close 'Manage Tokens' screen") { + onManageTokensScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert new account '$createdAccountName' appears (last) in accounts list") { + onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() } + } + step("Navigate back to wallet details") { + onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Navigate back to main screen") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + + step("Assert accounts mode is on main: account '$createdAccountName' section is visible") { + onMainScreen { findAccountSectionByName(createdAccountName).assertIsDisplayed() } + } + + step("Assert per-account token derivation paths from the domain account model") { + val account = awaitCryptoPortfolioAccount(derivationIndex = accountIndex.toInt()) + + val btcPaths = account.derivationPathsForToken(btcTokenName) + assertTrue( + "Expected a $btcTokenName derivation with 3rd node = $accountIndex' (account index). Paths: $btcPaths", + btcPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 3) == "$accountIndex'" }, + ) + + val ethPaths = account.derivationPathsForToken(ethTokenName) + assertTrue( + "Expected an $ethTokenName derivation with 5th node = $accountIndex (account index). Paths: $ethPaths", + ethPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 5) == accountIndex }, + ) + } + } + } + + @Test + @AllureId("8746") + @DisplayName("Accounts: empty account placeholder and 'Add tokens' entry to manage tokens") + fun emptyAccountPlaceholderTest() { + val createdAccountName = "Account 2" + val accountReadyState = "AccountReadyToCreateEmpty" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, accountReadyState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Start account creation") { startAccountCreation() } + + step("Enter account name: '$createdAccountName'") { + onAccountInfoEditorScreen { + accountNameField.performClick() + accountNameField.performTextInput(createdAccountName) + } + } + step("Click on 'Add account' and wait for 'Manage Tokens'") { + onAccountInfoEditorScreen { + saveAccountButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + }, + ) + } + } + step("Close 'Manage Tokens' without adding any token") { + onManageTokensScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert new empty account '$createdAccountName' appears in accounts list") { + onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() } + } + step("Navigate back to wallet details") { + onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Navigate back to main screen") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Expand empty account '$createdAccountName' section") { + onMainScreen { + scrollToAccountSection(createdAccountName) + findAccountSectionByName(createdAccountName).clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() } + }, + ) + } + } + step("Assert empty tokens placeholder is displayed") { + onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() } + } + step("Assert 'Add tokens' button is displayed under the placeholder") { + onMainScreen { emptyAccountAddTokensButton.assertIsDisplayed() } + } + step("Click on 'Add tokens' button") { + onMainScreen { + emptyAccountAddTokensButton.clickAndWaitFor( + rule = composeTestRule, + expectedCondition = { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + }, + ) + } + } + step("Assert 'Manage Tokens' screen is opened for the account") { + onManageTokensScreen { topAppBarTitle.assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 5e8dd103e2..96a6fb73dc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -383,11 +383,17 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Open 'Main Screen' on '$cardName' card") { openMainScreen(mockContent = cardType, isTwinsCard = true) } - step("Assert 'Buy' button is displayed") { - onMainScreen { buyButton.assertIsDisplayed() } + step("Assert 'Add funds' button is displayed") { + onMainScreen { addFundsButton.assertIsDisplayed() } } - step("Click on 'Buy' button") { - onMainScreen { buyButton.performClick() } + step("Click on 'Add funds' button") { + onMainScreen { addFundsButton.performClick() } + } + step("Click on '$tokenTitle'") { + onAddFundsBottomSheet { userTokenWithTitle(tokenTitle).clickWithAssertion() } + } + step("Click on 'Buy' button in bottom sheet") { + onGetTokenBottomSheet { buyButton.performClick() } } step("Click on 'Confirm' button in 'Dialog'") { waitForIdle() @@ -426,10 +432,10 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { addFundsButton.performClick() } } step("Assert 'Choose token' screen title is displayed") { - onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } + onChooseTokenBottomSheet { title.assertIsDisplayed() } } step("Assert token with title: '$tokenTitle' is displayed") { - onChooseTokenScreen { tokenWithTitle(tokenTitle).assertIsDisplayed() } + onChooseTokenBottomSheet { tokenWithTitle(tokenTitle).assertIsDisplayed() } } step("Press 'Back' button") { device.uiDevice.pressBack() @@ -449,14 +455,14 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Press 'Back' button") { device.uiDevice.pressBack() } - step("Assert 'Sell' button is displayed") { - onMainScreen { sellButton.assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onMainScreen { transferButton.assertIsDisplayed() } } - step("Click on 'Sell' button") { - onMainScreen { sellButton.performClick() } + step("Click on 'Transfer' button") { + onMainScreen { transferButton.performClick() } } - step("Assert 'Sell' token screen title is displayed") { - onSellScreen { title.assertIsDisplayed() } + step("Assert 'Choose token' title is displayed") { + onChooseTokenBottomSheet { title.assertIsDisplayed() } } } } @@ -485,7 +491,7 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { addFundsButton.performClick() } } step("Assert 'Choose token' screen opens (Add funds is always available)") { - onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } + onChooseTokenBottomSheet { title.assertIsDisplayed() } } step("Press 'Back' to return to main screen") { device.uiDevice.pressBack() @@ -498,22 +504,21 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { swapButton.performClick() } } step("Check 'Action is unavailable' dialog") { - checkActionIsUnavailableDialog() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkActionIsUnavailableDialog() + } } step("Click on 'Ok' button") { onDialog { okButton.performClick() } } - step("Assert 'Sell' button is displayed") { - onMainScreen { sellButton.assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onMainScreen { transferButton.assertIsDisplayed() } } - step("Click on 'Sell' button") { - onMainScreen { sellButton.performClick() } + step("Click on 'Transfer' button") { + onMainScreen { transferButton.performClick() } } - step("Check 'Action is unavailable' dialog") { - checkActionIsUnavailableDialog() - } - step("Click on 'Ok' button") { - onDialog { okButton.performClick() } + step("Assert 'Choose token' title is displayed") { + onChooseTokenBottomSheet { title.assertIsDisplayed() } } } } @@ -543,7 +548,7 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { addFundsButton.performClick() } } step("Assert 'Choose token' screen opens (Add funds is always available)") { - onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } + onChooseTokenBottomSheet { title.assertIsDisplayed() } } step("Press 'Back' to return to main screen") { device.uiDevice.pressBack() @@ -561,17 +566,14 @@ class MainScreenActionButtonsTest : BaseTestCase() { step("Click on 'Ok' button") { onDialog { okButton.performClick() } } - step("Assert 'Sell' button is displayed") { - onMainScreen { sellButton.assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onMainScreen { transferButton.assertIsDisplayed() } } - step("Click on 'Sell' button") { - onMainScreen { sellButton.performClick() } + step("Click on 'Transfer' button") { + onMainScreen { transferButton.performClick() } } - step("Check 'Action is unavailable' dialog") { - checkActionIsUnavailableDialog() - } - step("Click on 'Ok' button") { - onDialog { okButton.performClick() } + step("Assert 'Choose token' title is displayed") { + onChooseTokenBottomSheet { title.assertIsDisplayed() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index f634fe21c0..2cf1653be7 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -2,16 +2,21 @@ package com.tangem.tests.actionButtons import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.checkQrCodeBottomSheetScenario import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSendFromTokenDetails import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onAddFundsBottomSheet import com.tangem.screens.onMainScreen +import com.tangem.screens.onSendScreen import com.tangem.screens.onSwapStoriesScreen import com.tangem.screens.onSwapTokenScreen import com.tangem.screens.onTokenDetailsScreen +import com.tangem.screens.onTransferBottomSheet import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -37,20 +42,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is displayed") { - onTokenDetailsScreen { receiveButton().assertIsDisplayed() } - } - step("Assert 'Buy' button is displayed") { - onTokenDetailsScreen { buyButton().assertIsDisplayed() } - } - step("Assert 'Send' button is displayed") { - onTokenDetailsScreen { sendButton().assertIsDisplayed() } + step("Assert 'Add funds' button is displayed") { + onTokenDetailsScreen { addFundsButton.assertIsDisplayed() } } step("Assert 'Swap' button is displayed") { - onTokenDetailsScreen { swapButton().assertIsDisplayed() } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } - step("Assert 'Sell' button is displayed") { - onTokenDetailsScreen { sellButton().assertIsDisplayed() } + step("Assert 'Transfer' button is displayed") { + onTokenDetailsScreen { transferButton.assertIsDisplayed() } + } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } + } + step("Assert 'Buy' button in bottom sheet is displayed") { + onAddFundsBottomSheet { buyButton.assertIsDisplayed() } + } + step("Assert 'Swap' button in bottom sheet is displayed") { + onAddFundsBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Receive' button in bottom sheet is displayed") { + onAddFundsBottomSheet { receiveButton.assertIsDisplayed() } + } + step("Click on 'Close' button in bottom sheet") { + onAddFundsBottomSheet { closeButton.clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Assert 'Send' button in bottom sheet is displayed") { + onTransferBottomSheet { sendButton.assertIsDisplayed() } + } + step("Assert 'Swap' button in bottom sheet is displayed") { + onTransferBottomSheet { swapButton.assertIsDisplayed() } + } + step("Assert 'Sell' button in bottom sheet is displayed") { + onTransferBottomSheet { sellButton.assertIsDisplayed() } } } } @@ -72,20 +98,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Assert 'Receive' button is not dimmed") { - onTokenDetailsScreen { receiveButton().assertIsDimmed(false) } + step("Assert 'Add funds' button is enabled") { + onTokenDetailsScreen { addFundsButton.assertIsEnabled() } } - step("Assert 'Buy' button is not dimmed") { - onTokenDetailsScreen { buyButton().assertIsDimmed(false) } + step("Assert 'Swap' button is disabled") { + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } - step("Assert 'Send' button is not dimmed") { - onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + step("Assert 'Transfer' button is enabled") { + onTokenDetailsScreen { transferButton.assertIsEnabled() } } - step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed() } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } } - step("Assert 'Sell' button is dimmed") { - onTokenDetailsScreen { sellButton().assertIsDimmed() } + step("Assert 'Buy' button in bottom sheet is enabled") { + onAddFundsBottomSheet { buyButton.assertIsEnabled() } + } + step("Assert 'Swap' button in bottom sheet is disabled") { + onAddFundsBottomSheet { swapButton.assertIsNotEnabled() } + } + step("Assert 'Receive' button in bottom sheet is enabled") { + onAddFundsBottomSheet { receiveButton.assertIsEnabled() } + } + step("Click on 'Close' button in bottom sheet") { + onAddFundsBottomSheet { closeButton.clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Assert 'Send' button in bottom sheet is enabled") { + onTransferBottomSheet { sendButton.assertIsEnabled() } + } + step("Assert 'Swap' button in bottom sheet is disabled") { + onTransferBottomSheet { swapButton.assertIsNotEnabled() } + } + step("Assert 'Sell' button in bottom sheet is disabled") { + onTransferBottomSheet { sellButton.assertIsNotEnabled() } } } } @@ -109,7 +156,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -140,8 +187,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { waitForIdle() onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } } - step("Click on 'Receive' button") { - onTokenDetailsScreen { receiveButton().performClick() } + step("Click on 'Add funds' button") { + onTokenDetailsScreen { addFundsButton.clickWithAssertion() } + } + step("Click on 'Receive' button in bottom sheet") { + onAddFundsBottomSheet { receiveButton.clickWithAssertion() } } step("Go to QR code bottom sheet") { flakySafely(WAIT_UNTIL_TIMEOUT) { @@ -153,4 +203,58 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { } } } + + @AllureId("591") + @DisplayName("Action buttons (token details screen): send available for funded token, unavailable for empty token") + @Test + fun checkSendAvailabilityForFundedAndEmptyTokenTest() { + val emptyTokenTitle = "Polygon" + val fundedTokenTitle = "Ethereum" + val polygonBalanceScenarioName = "polygon_coin_balance" + val polygonBalanceScenarioState = "ZeroBalance" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(polygonBalanceScenarioName) + } + ).run { + step("Set WireMock scenario: '$polygonBalanceScenarioName' to state: '$polygonBalanceScenarioState'") { + setWireMockScenarioState(polygonBalanceScenarioName, polygonBalanceScenarioState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$emptyTokenTitle'") { + waitForIdle() + onMainScreen { tokenWithTitleAndAddress(emptyTokenTitle).clickWithAssertion() } + } + step("Assert 'Token details' screen is displayed") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Assert 'Transfer' button is not displayed for the empty token") { + onTokenDetailsScreen { transferButton.assertIsNotDisplayed() } + } + step("Go back to 'Main Screen'") { + device.uiDevice.pressBack() + } + step("Assert 'Main Screen' is displayed") { + onMainScreen { screenContainer.assertIsDisplayed() } + } + step("Click on token with name: '$fundedTokenTitle'") { + onMainScreen { tokenWithTitleAndAddress(fundedTokenTitle).clickWithAssertion() } + } + step("Assert 'Transfer' button is displayed for the funded token") { + onTokenDetailsScreen { transferButton.assertIsDisplayed() } + } + step("Open the send flow from token details") { + openSendFromTokenDetails() + } + step("Assert 'Send' screen is displayed") { + onSendScreen { amountInputTextField.assertIsDisplayed() } + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt new file mode 100644 index 0000000000..91f7c4781d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt @@ -0,0 +1,86 @@ +package com.tangem.tests.addFunds + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.assertTextContainsSafe +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onAddTokenBottomSheet +import com.tangem.screens.onAddFundsBottomSheet +import com.tangem.screens.onBuyTokenDetailsScreen +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class BuyTest : BaseTestCase() { + + @AllureId("587") + @DisplayName("Buy. Display tokens available for purchase") + @Test + fun buyDisplayTokensAvailableForPurchaseTest() { + val token = "Bitcoin" + + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on 'Add Funds' button") { + onMainScreen { addFundsButton.clickWithAssertion() } + } + step("Click on $token in Wallet list") { + onAddFundsBottomSheet { userTokenWithTitle(token).clickWithAssertion() } + } + step("Click on 'Buy' button") { + onAddFundsBottomSheet { buyTokenButton.clickWithAssertion() } + } + step("Verify 'Buy $token' title is displayed") { + onBuyTokenDetailsScreen { + topBarTitle.assertTextContainsSafe("Buy $token", substring = true) + } + } + } + } + + @AllureId("590") + @DisplayName("Buy. Adding trending token to the main screen") + @Test + fun buyAddingTrendingTokenToMainScreenTest() { + val token = "Solana" + + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on 'Add Funds' button") { + onMainScreen { addFundsButton.clickWithAssertion() } + } + step("Click on $token in Trending list") { + onAddFundsBottomSheet { trendingTokenWithTitle(token).clickWithAssertion() } + } + step("Click on 'Add' button") { + onAddTokenBottomSheet { addButton.clickWithAssertion() } + } + step("Close 'Get token' screen") { + onAddFundsBottomSheet { closeButton.clickWithAssertion() } + } + step("Verify token $token exists on main screen") { + onMainScreen { assertTokenExists(token) } + } + step("Click on 'Add Funds' button") { + onMainScreen { addFundsButton.clickWithAssertion() } + } + step("Verify token $token in Wallet list") { + onAddFundsBottomSheet { userTokenWithTitle(token).assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt deleted file mode 100644 index bae6abd799..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceLongTapTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tests.balance - -import androidx.compose.ui.test.longClick -import com.tangem.common.BaseTestCase -import com.tangem.scenarios.openMainScreen -import com.tangem.scenarios.synchronizeAddresses -import com.tangem.screens.onMainScreen -import dagger.hilt.android.testing.HiltAndroidTest -import io.qameta.allure.kotlin.AllureId -import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Test - -@HiltAndroidTest -class TotalBalanceLongTapTest : BaseTestCase() { - - @Test - @AllureId("3965") - @DisplayName("Total balance: check long tap on block without biometry") - fun whenBiometryIsOffTest() { - setupHooks().run { - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Long tap on total balance block") { - onMainScreen { - totalBalanceContainer.performTouchInput { - longClick() - } - } - } - step("Assert 'Rename' button is displayed") { - onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() } - } - step("Assert 'Delete' button is not displayed") { - onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() } - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index b8a7826147..531f8a6e71 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -3,6 +3,7 @@ package com.tangem.tests.balance import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TOTAL_BALANCE +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.extensions.* import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -73,7 +74,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } } step("Open 'Markets screen'") { - onMainScreen { searchThroughMarketPlaceholder.performClick() } + onMainScreen { marketsSheetDragHandle.clickWithAssertion() } waitForIdle() } step("Click on $tokenTitle token") { @@ -82,28 +83,25 @@ class TotalBalanceUpdateTest : BaseTestCase() { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) } - step("Click on 'Add to portfolio' button") { - onMarketsScreen { addToPortfolioButton.clickWithAssertion() } + step("Click on 'Add' button in 'Markets' bottom sheet") { + onMarketsScreen { addButton.clickWithAssertion() } } - step("Click on main network") { - onMarketsScreen { mainNetworkSuffix.performClick() } + step("Click on 'Add' button in 'Add token' bottom sheet") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onAddTokenBottomSheet { addButton.performClick() } + } } - step("Click on 'Add' button") { - onDialog { addButton.clickWithAssertion() } - } - step("Assert 'Continue' is not displayed") { - onDialog { addButton.assertIsNotDisplayed() } - } - step("Click on 'Later' button") { - onDialog { laterButton.clickWithAssertion() } - } - step("Go back to 'Markets: tokens list'") { + step("Press 'Back' button") { waitForIdle() - onMarketsScreen { topBarBackButton.clickWithAssertion() } + device.uiDevice.pressBack() } - step("Close 'Markets screen'") { - onSearchBar { searchField.assertIsDisplayed() } - swipeMarketsBlock(SwipeDirection.DOWN) + step("Press 'Back' button") { + waitForIdle() + device.uiDevice.pressBack() + } + step("Press 'Back' button") { + waitForIdle() + device.uiDevice.pressBack() } step("Assert $updatedBalance is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } @@ -201,8 +199,11 @@ class TotalBalanceUpdateTest : BaseTestCase() { step("Assert 'Token details screen' open") { onTokenDetailsScreen { screenContainer.assertIsDisplayed() } } - step("Click 'More' button") { - onTokenDetailsTopBar { backButton.clickWithAssertion() } + step("Click on 'Back' button") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onTokenDetailsTopBar { backButton.clickWithAssertion() } + onMainScreen { screenContainer.assertIsDisplayed() } + } } step("Assert $TOTAL_BALANCE is displayed in total balance") { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt new file mode 100644 index 0000000000..27b130e296 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AssetsDiscoveryTest.kt @@ -0,0 +1,251 @@ +package com.tangem.tests.hotWallet + +import androidx.test.InstrumentationRegistry.getTargetContext +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO +import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO +import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO +import com.tangem.common.constants.TestConstants.SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.restartApp +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.openMainScreenWithExistingHotWallet +import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountDetailsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test +import com.tangem.core.ui.R as CoreUiR + +@HiltAndroidTest +class AssetsDiscoveryTest : BaseTestCase() { + + private companion object { + const val DISCOVERY_TIMEOUT_MILLIS = 120_000L + + const val SCENARIO_STATE_STARTED = "Started" + const val SCENARIO_STATE_EMPTY = "Empty" + const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists" + const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect" + const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances" + const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow" + + val EXPECTED_DISCOVERED_TOKENS = listOf( + "Ethereum", + "Polygon", + "Tether", + ) + + val TOKENS_THAT_MUST_NOT_APPEAR = listOf( + "Solana", + "USDC", + ) + + val BACKEND_PRE_POPULATED_TOKENS = listOf( + "Bitcoin", + "Ethereum", + "Polygon", + ) + } + + @AllureId("9280") + @DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path") + @Test + fun newHotWalletImportHappyPathTest() { + val packageName = getTargetContext().packageName + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert expected discovered tokens are visible in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Tap 'Check here' (Manage tokens) on the banner") { + onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() } + } + step("Assert 'Manage Tokens' screen is opened") { + onManageTokensScreen { searchField.assertIsDisplayed() } + } + step("Return to main screen") { + device.uiDevice.pressBack() + waitForIdle() + } + step("Assert banner is hidden after navigating into Manage Tokens") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Force-close and re-launch the app") { + restartApp(packageName) + } + step("Assert banner is NOT shown again after relaunch") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + step("Assert previously discovered tokens still appear in the assets list") { + onMainScreen { + EXPECTED_DISCOVERED_TOKENS.forEach { token -> + tokenRowWithTitle(token).assertIsDisplayed() + } + } + } + step("Assert zero-balance and spam tokens are NOT shown in the assets list") { + onMainScreen { + TOKENS_THAT_MUST_NOT_APPEAR.forEach { token -> + assertTokenDoesNotExist(token) + } + } + } + } + } + + @AllureId("9284") + @DisplayName("Hot wallet: token added manually during Discovery — no duplicate created") + @Test + fun manualTokenAddDuringDiscoveryNoDuplicateTest() { + val tetherTitle = "Tether" + val ethereumNetworkTitle = "ETHEREUM" + val accountName = getResourceString(CoreUiR.string.account_main_account_title) + val expectedTokensCount = 2 + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT) + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH) + setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState( + MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, + state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW, + ) + }, + additionalAfterSection = { + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import a new hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH) + } + step("Assert 'Restoring' progress loader is shown (discovery is in flight)") { + onMainScreen { restoringProgressText.assertIsDisplayed() } + } + step("Open wallet details from top bar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings'") { + onDetailsScreen { walletNameButton.performClick() } + } + step("Open account: '$accountName'") { + onWalletSettingsScreen { accountItem(accountName).performClick() } + } + step("Open 'Manage Tokens' from account details") { + onAccountDetailsScreen { manageTokensButton.performClick() } + } + step("Search for '$tetherTitle' in Manage Tokens") { + onManageTokensScreen { + searchField.performClick() + searchField.performTextInput(tetherTitle) + } + device.uiDevice.pressBack() + waitForIdle() + } + step("Expand '$tetherTitle'") { + onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() } + waitForIdle() + } + step("Enable the $ethereumNetworkTitle network") { + onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() } + } + step("Save Manage Tokens changes") { + onManageTokensScreen { saveButton.clickWithAssertion() } + waitForIdle() + } + step("Navigate back to main screen") { + repeat(times = 3) { + device.uiDevice.pressBack() + waitForIdle() + } + } + step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") { + flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) { + onMainScreen { walletImportedBanner.assertIsDisplayed() } + } + } + step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") { + onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() } + } + step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") { + onMainScreen { assertTokensCount(expectedTokensCount) } + } + } + } + + @AllureId("9282") + @DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend") + @Test + fun reimportExistingHotWalletTest() { + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS) + setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED) + setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY) + }, + additionalAfterSection = { + resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO) + }, + ).run { + step("Import an existing hot wallet from seed phrase") { + openMainScreenWithExistingHotWallet(SEED_PHRASE_12) + } + step("Assert tokens from backend are displayed immediately") { + BACKEND_PRE_POPULATED_TOKENS.forEach { token -> + onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() } + } + } + step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") { + onMainScreen { restoringProgressText.assertIsNotDisplayed() } + } + step("Assert 'Wallet successfully imported' banner is NOT displayed") { + onMainScreen { walletImportedBanner.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt index 32dfdd1b1b..2f69a7e418 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt @@ -2,7 +2,9 @@ package com.tangem.tests.main import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen @@ -37,7 +39,7 @@ class MainScreenTest : BaseTestCase() { } @AllureId("8748") - @DisplayName("Main: check 'Organize tokens' button with single token no accounts") + @DisplayName("Main: check 'Add & Manage' button with single token no accounts") @Test fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() { val scenarioState = "Cardano" @@ -58,14 +60,14 @@ class MainScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Add & Manage' button is not displayed") { - onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButtonNode.assertIsDisplayed() } } } } @AllureId("8749") - @DisplayName("Main: check 'Organize tokens' button with single token two accounts") + @DisplayName("Main: check 'Add & Manage' button with single token two accounts") @Test fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() { val scenarioState = "TwoAccountsSingleTokenEach" @@ -84,10 +86,10 @@ class MainScreenTest : BaseTestCase() { openMainScreen() } step("Assert 'Add & Manage' button is displayed") { - onMainScreen { addAndManageButtonNode.assertIsDisplayed() } + onMainScreen { addAndManageButton().assertIsDisplayed() } } step("Click 'Add & Manage' button") { - onMainScreen { addAndManageButtonNode.clickWithAssertion() } + onMainScreen { addAndManageButton().clickWithAssertion() } } step("Assert 'Organize tokens' option is not displayed (nothing to organize)") { onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() } @@ -99,7 +101,7 @@ class MainScreenTest : BaseTestCase() { } @AllureId("8750") - @DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts") + @DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts") @Test fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() { val scenarioState = "TwoAccountsMixed" @@ -117,8 +119,11 @@ class MainScreenTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f) + } step("Assert 'Add & Manage' button is displayed") { - onMainScreen { addAndManageButtonNode.assertIsDisplayed()} + onMainScreen { addAndManageButtonNode.assertIsDisplayed() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt index 689da371e0..8510d1e1c6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/WarningsTest.kt @@ -16,7 +16,7 @@ import org.junit.Test class WarningsTest : BaseTestCase() { @AllureId("184") - @DisplayName("Token list: hide token by long tap") + @DisplayName("Warnings: missing address warning") @Test fun checkUnavailableNetworksWarningTest() { val scenarioState = "MissingDerivation" @@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses(isBalanceAvailable = false) } - step("Assert 'Missing addresses' notification icon is displayed") { - onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() } - } step("Assert 'Missing addresses' notification title is displayed") { onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index fc17e54653..4cc58d517c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -1,5 +1,6 @@ package com.tangem.tests.markets +import androidx.compose.ui.test.ExperimentalTestApi import com.tangem.common.BaseTestCase import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig @@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() { } } + @OptIn(ExperimentalTestApi::class) @Test @AllureId("56") @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) @@ -53,16 +55,15 @@ class MarketsExchangesTest : BaseTestCase() { synchronizeAddresses() } step("Open 'Markets' screen") { - onMainScreen { searchThroughMarketPlaceholder.performClick() } + onMainScreen { marketsSheetDragHandle.clickWithAssertion() } waitForIdle() } step("Click on '$tokenName' token") { onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } waitForIdle() } - step("Scroll down") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) + step("Scroll to 'Listed on exchanges' block") { + onMarketsScreen { scrollToListedOnBlock() } } step("Assert 'Listed on exchanges' block has title") { onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt index 37950eb1a3..ff84ea465b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/RecentBlockTest.kt @@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() { val sendAmount = "1" val txHistoryScenarioState = "11OutgoingTransactions" val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq" - val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b" + val longRecipientAddress = recipientAddressBase + "123456789b" setupHooks( additionalAfterSection = { @@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() { checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) } step("Check recent address item №2") { - checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2) + checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2) } step("Check recent address item №3") { checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt index 6f0000086b..49d9682e4e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/addressScreen/SendAddressScreenTest.kt @@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt index a9746ca3c1..b7fe82c3f4 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/confirmScreen/SendConfirmScreenTest.kt @@ -4,6 +4,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG @@ -46,8 +47,11 @@ class SendConfirmScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { @@ -123,8 +127,11 @@ class SendConfirmScreenTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } - step("Click on 'Send' button") { - onTokenDetailsScreen { sendButton().performClick() } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } } step("Type '$inputAmount' in input text field") { onSendScreen { @@ -320,4 +327,42 @@ class SendConfirmScreenTest : BaseTestCase() { } } } + + @AllureId("557") + @DisplayName("Send (Confirm screen): send a second transaction while the first is still pending") + @Test + fun sendSecondTransactionWhileFirstActiveTest() { + val tokenName = "Ethereum" + val inputAmount = "0.001" + + setupHooks().run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName) + } + step("Enter amount '$inputAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + // Hold-to-confirm is swallowed while the fee is still settling — wait for it to load first. + waitUntilNetworkFeeIsStable { readNetworkFeeAmount() } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.clickWithAssertion() } + } + step("Assert 'Token details' screen is displayed") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Open the send flow again from token details") { + openSendFromTokenDetails() + } + step("Enter amount '$inputAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + waitUntilNetworkFeeIsStable { readNetworkFeeAmount() } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt index b9f6635d09..4fb3a7e707 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendFeeScreenTest.kt @@ -5,6 +5,7 @@ import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG @@ -281,13 +282,13 @@ class SendFeeScreenTest : BaseTestCase() { fun checkNetworkFeeBottomSheetForBitcoinTest() { val tokenName = "Bitcoin" val tokenAmount = "0.00000001" - val feeAmount = "$2.86" + val feeAmount = "$0.48" val fiatFeeAmount = "$0.24" val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) val feeUpTo = getResourceString(R.string.send_max_fee) - val feeUpToValue = "0.0000264 BTC" + val feeUpToValue = "0.0000044 BTC" val newFeeUpToValue = "0.0000022 BTC" val satoshi = getResourceString(R.string.send_satoshi_per_byte_title) val satoshiValue = "2" @@ -443,4 +444,30 @@ class SendFeeScreenTest : BaseTestCase() { } } } + + @AllureId("547") + @DisplayName("Send (Fee screen): network fee recalculates on speed switch and sends") + @Test + fun recalculateFeeOnSpeedSwitchAndSendTest() { + val tokenName = "Ethereum" + val inputAmount = "0.8" + + setupHooks().run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName) + } + step("Enter amount '$inputAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + waitUntilNetworkFeeIsStable { readNetworkFeeAmount() } + val marketFee = getNetworkFeeAmount() + step("Switch the network fee to 'Fast'") { + switchFeeToFastAndApply() + } + assertNetworkFeeChanged(marketFee) + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendTokenFeeTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendTokenFeeTest.kt new file mode 100644 index 0000000000..8c50048abb --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/feeScreen/SendTokenFeeTest.kt @@ -0,0 +1,92 @@ +package com.tangem.tests.send.feeScreen + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Completing a send paid with a fee in the token itself (no native fee coin), on a hot wallet: + * VeChain's VeThor and Terra Classic's TerraClassicUSD. + */ +@HiltAndroidTest +class SendTokenFeeTest : BaseTestCase() { + + private val tokenAmount = "1" + + @AllureId("4907") + @DisplayName("Send (Fee in token): send VeThor and complete the transaction") + @Test + fun sendVeThorWithFeeInTokenTest() { + val tokenName = "VeThor" + val scenarioState = "Vechain" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) + } + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + waitUntilNetworkFeeIsStable { readNetworkFeeAmount() } + assertNetworkFeeContains("\$") + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } + + @AllureId("4908") + @DisplayName("Send (Fee in token): send TerraClassicUSD and complete the transaction") + @Test + fun sendTerraClassicUsdWithFeeInTokenTest() { + val tokenName = "TerraClassicUSD" + val scenarioState = "Terra" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) + } + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = TERRA_RECIPIENT_ADDRESS) + } + waitUntilNetworkFeeIsStable { readNetworkFeeAmount() } + assertNetworkFeeContains("\$") + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt new file mode 100644 index 0000000000..4c21d6586c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt @@ -0,0 +1,317 @@ +package com.tangem.tests.send.gasless + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.enterAmountAndOpenSendConfirm +import com.tangem.scenarios.enterRecipientAndOpenSendConfirm +import com.tangem.scenarios.openSendScreen +import com.tangem.scenarios.selectStablecoinAsFeeToken +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendFeeSelectorBottomSheet +import com.tangem.screens.onSendScreen +import com.tangem.screens.onSendSelectNetworkFeeBottomSheet +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless network-fee behaviour on the send summary (fee selector): availability, calculation, + * speed options, switching the fee token, and balance-driven notifications. All run on the default + * (cold) wallet without signing a transaction. + */ +@HiltAndroidTest +class GaslessFeeTest : BaseTestCase() { + + private val scenarioState = "PolygonUSDC" + private val tokenName = "USDC" + private val nativeTokenName = "Polygon" + private val tokenAmount = "1" + + @AllureId("5061") + @DisplayName("Gasless: Network fee on summary is selectable and the stablecoin is available for the fee") + @Test + fun checkNetworkFeeTokenSelectionAvailableTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee' block with token selection is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeSelectorTitle.assertIsDisplayed() + selectFeeIcon.assertIsDisplayed() + } + } + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { feeSelectorBlock.performClick() } + } + step("Assert 'Network fee' bottom sheet is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Assert '$tokenName' is available for the fee payment") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() } + } + } + } + + @AllureId("5062") + @DisplayName("Gasless: network fee for a stablecoin is calculated and shown in the stablecoin") + @Test + fun checkFeeCalculatedInStablecoinTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert the fee is shown under the '$marketSpeed' speed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() } + } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is calculated in '$tokenName' (not in the coin) on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeBlockCurrency(tokenName).assertIsDisplayed() + feeAmount.assertIsDisplayed() + } + } + } + } + } + + @AllureId("5064") + @DisplayName("Gasless: only Market speed is available when paying the fee with a stablecoin") + @Test + fun checkOnlyMarketSpeedAvailableForStablecoinFeeTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast) + val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { + feeSelectorBlock.assertIsDisplayed() + feeSelectorBlock.performClick() + } + } + step("Assert 'Network fee' bottom sheet is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Assert 'Network fee' bottom sheet is displayed after token selection") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Assert '$marketSpeed' speed is displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() } + } + step("Assert '$fastSpeed' speed is not displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() } + } + step("Assert '$slowSpeed' speed is not displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() } + } + step("Click on '$marketSpeed' fee row") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() } + } + step("Assert 'Choose speed' bottom sheet did not open for stablecoin fee") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() } + } + } + } + + @AllureId("5068") + @DisplayName("Gasless: switching the fee token back to the coin restores the standard fee flow") + @Test + fun checkSwitchFeeTokenBackToCoinTest() { + val nativeSymbol = "POL" + val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Open the fee token selector again via the '$tokenName' fee token") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + } + step("Switch the fee token back to '$nativeTokenName'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Click on 'Apply' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + } + step("Assert the network fee is now paid in '$nativeSymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() } + } + } + step("Assert 'Network fee coverage' notification is not displayed (standard fee flow)") { + onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsNotDisplayed() } + } + step("Assert 'Send' button is enabled") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + } + } + + @AllureId("5063") + @DisplayName("Gasless: insufficient stablecoin balance to cover the fee shows error and blocks send") + @Test + fun checkInsufficientBalanceForFeeTest() { + val usdcBalanceScenario = "polygon_usdc_balance" + val lowBalanceState = "LowBalance" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(usdcBalanceScenario) + } + ).run { + step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") { + setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState) + } + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Click on 'Max' button") { + onSendScreen { maxButton.performClick() } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter the recipient and open the 'Send confirm' screen") { + enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert 'Not enough funds' error is displayed in the fee selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() } + } + } + step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") { + onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() } + } + } + } + + @AllureId("5097") + @DisplayName("Gasless: no insufficient-coin-for-fee notification is shown when gasless covers the fee") + @Test + fun checkNoInsufficientCoinNotificationWhenGaslessTest() { + val coinBalanceScenario = "polygon_coin_balance" + val zeroBalanceState = "ZeroBalance" + val feeBlockedTitlePart = getResourceString(R.string.warning_send_blocked_funds_for_fee_title, "X") + .substringAfter("X ") + .trim() + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(coinBalanceScenario) + } + ).run { + step("Set WireMock scenario '$coinBalanceScenario' to '$zeroBalanceState'") { + setWireMockScenarioState(scenarioName = coinBalanceScenario, state = zeroBalanceState) + } + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert the fee defaults to '$tokenName' (gasless covers the missing coin)") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(tokenName).assertIsDisplayed() } + } + } + step("Assert the insufficient-coin-for-fee notification is not shown") { + onSendConfirmScreen { warningTitleContaining(feeBlockedTitlePart).assertIsNotDisplayed() } + } + step("Assert 'Send' button is enabled") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt new file mode 100644 index 0000000000..2ce8fe21c2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt @@ -0,0 +1,189 @@ +package com.tangem.tests.send.gasless + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless send lifecycle: signing and broadcasting a stablecoin-fee transaction (hot wallet), + * the max-amount fee reservation, and the completed gasless transaction in the token history. + */ +@HiltAndroidTest +class GaslessSendTest : BaseTestCase() { + + private val scenarioState = "PolygonUSDC" + private val tokenName = "USDC" + private val currencySymbol = "USDC" + private val nativeTokenName = "Polygon" + private val hotWalletTokensState = "PolygonUSDCHotWallet" + private val tokenAmount = "1" + + @AllureId("5069") + @DisplayName("Gasless: max amount reserves the stablecoin fee and stays sendable") + @Test + fun checkMaxAmountSendTest() { + val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title) + val feeCoverageMessagePart = getResourceString(R.string.common_network_fee_warning_content, "", "") + .substringBefore("(") + .trim() + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openGaslessSendScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + userTokensState = hotWalletTokensState, + quotesState = scenarioState, + ) + } + step("Click on 'Max' button") { + onSendScreen { maxButton.performClick() } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter the recipient and open the 'Send confirm' screen") { + enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert 'Network fee coverage' notification title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsDisplayed() } + } + } + step("Assert 'Network fee coverage' notification text is displayed (amount reduced by fee)") { + onSendConfirmScreen { warningMessageContaining(feeCoverageMessagePart).assertIsDisplayed() } + } + step("Assert 'Send' button is enabled (enough left for the fee)") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } + + @AllureId("5065") + @DisplayName("Gasless: sign and send a stablecoin transaction with the stablecoin fee") + @Test + fun checkSignAndSendGaslessTransactionTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openGaslessSendScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + userTokensState = hotWalletTokensState, + quotesState = scenarioState, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert gasless fee is paid in '$currencySymbol' and 'Send' is enabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeBlockCurrency(currencySymbol).assertIsDisplayed() + sendButton.assertIsEnabled() + } + } + } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } + + @AllureId("5066") + @DisplayName("Gasless: completed gasless transaction is shown in token transaction history") + @Test + fun checkGaslessTransactionInHistoryTest() { + val sentAmount = "1.00" + val gaslessFeeAmount = "0.10" + val sentTitle = getResourceString(R.string.common_sent) + val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) + } + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Assert 'Token details' screen is displayed") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Wait for gasless '$gaslessFeeTitle' transaction in history") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() } + } + } + step("Assert '$sentTitle' transaction is displayed") { + onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() } + } + step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") { + onTxHistoryScreen { + transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true) + transactionCurrency(sentTitle).assertTextEquals(currencySymbol) + } + } + step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") { + onTxHistoryScreen { + transactionAmount(gaslessFeeTitle).assertTextContains(gaslessFeeAmount, substring = true) + transactionCurrency(gaslessFeeTitle).assertTextEquals(currencySymbol) + } + } + step("Assert gasless '$gaslessFeeTitle' status is confirmed") { + onTxHistoryScreen { transactionConfirmedStatus(gaslessFeeTitle).assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt new file mode 100644 index 0000000000..c922a7915d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt @@ -0,0 +1,256 @@ +package com.tangem.tests.send.sendViaSwap + +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless send-via-swap: paying the network fee with the stablecoin while converting it through an + * express swap. Covers the fee-token selection on the swap summary, the stablecoin balance validation + * against the gasless fee, and the full signed swap-and-send on a hot wallet. + */ +@HiltAndroidTest +class GaslessSendViaSwapTest : BaseTestCase() { + + private val tokenName = "USDC" + private val currencySymbol = "USDC" + private val nativeTokenName = "Polygon" + private val swapTokenName = "Bitcoin" + private val mainNetwork = "MAIN" + private val providerName = "Changelly" + private val tokenAmount = "1" + private val hotWalletTokensState = "PolygonUSDCHotWallet" + private val quotesState = "PolygonUSDC" + private val assetsScenarioName = "express_api_assets" + private val assetsExchangeEnabledState = "BitcoinExchangeEnabled" + private val providersState = "HotWalletSvS" + + @AllureId("5120") + @DisplayName("Gasless Send via Swap: the network fee is selectable and payable with the stablecoin") + @Test + fun checkFeeTokenSelectionForSwapTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee' block with token selection is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeSelectorTitle.assertIsDisplayed() + selectFeeIcon.assertIsDisplayed() + } + } + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { feeSelectorBlock.performClick() } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Assert '$tokenName' is available for the fee payment") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is calculated in '$currencySymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + } + } + + @AllureId("5121") + @DisplayName("Gasless Send via Swap: insufficient stablecoin balance to cover the fee blocks the swap") + @Test + fun checkBalanceValidationForFeeTest() { + val usdcBalanceScenario = "polygon_usdc_balance" + val lowBalanceState = "LowBalance" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(usdcBalanceScenario) + } + ).run { + step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") { + setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState) + } + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert 'Not enough funds' error is displayed in the fee selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() } + } + } + step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") { + onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() } + } + } + } + + @AllureId("5122") + @DisplayName("Gasless Send via Swap: sign and send a swap paying the fee with the stablecoin") + @Test + fun checkSendViaSwapFinalScreenAndSendTest() { + val exchangeStatusScenario = "exchange_status_provider" + val changellyStatusState = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(PROVIDERS_API_SCENARIO) + resetWireMockScenarioState(exchangeStatusScenario) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") { + setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState) + } + step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") { + setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState) + } + + step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") { + openSendViaSwapScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + swapTokenName = swapTokenName, + networkName = swapTokenName, + networkType = mainNetwork, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the sent '$tokenName' amount is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { primaryAmount.assertIsDisplayed() } + } + } + step("Assert the recipient address is displayed") { + onSendConfirmScreen { recipientAddress(BITCOIN_RECIPIENT_ADDRESS).assertIsDisplayed() } + } + step("Assert the amount to receive after the swap is displayed") { + onSendConfirmScreen { secondaryAmount.assertIsDisplayed() } + } + step("Assert the network fee is paid in '$currencySymbol'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + step("Sign, send and open the 'Transaction sent' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' success screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt index 924bb8603b..eb64d3839f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt @@ -8,6 +8,7 @@ import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.XRP_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.extractText import com.tangem.common.extensions.clickWithAssertion @@ -665,4 +666,163 @@ class SendViaSwapTest : BaseTestCase() { } } } + + @AllureId("9924") + @DisplayName("Send via Swap: editing the memo persists the latest value") + @Test + fun sendViaSwapMemoEditPersistsLatestValueTest() { + val tokenName = "Bitcoin" + val swapTokenName = "XRP" + val networkName = "XRP Ledger" + val inputAmount = "0.001" + val recipientAddress = XRP_ACTIVATED_RECIPIENT_ADDRESS + val memoFirst = "11111111" + val memoSecond = "22222222" + val hotWalletScenarioState = "HotWalletSvS" + val quotesScenarioState = "Ripple" + val bitcoinBalanceScenarioName = "bitcoin_utxo" + val bitcoinBalanceScenarioState = "BalanceHotWalletSvS" + val assetsScenarioName = "express_api_assets" + val assetsScenarioState = "BitcoinExchangeEnabledWithXRP" + val coinsScenarioName = "coins_api" + val coinsScenarioState = "WithXRP" + val providersScenarioName = "networks_providers" + val providersScenarioState = "HotWalletSvS" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(bitcoinBalanceScenarioName) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(coinsScenarioName) + resetWireMockScenarioState(providersScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesScenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesScenarioState) + } + step("Set WireMock scenario: '$bitcoinBalanceScenarioName' to state: '$bitcoinBalanceScenarioState'") { + setWireMockScenarioState(scenarioName = bitcoinBalanceScenarioName, state = bitcoinBalanceScenarioState) + } + step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) + } + step("Set WireMock scenario: '$coinsScenarioName' to state: '$coinsScenarioState'") { + setWireMockScenarioState(scenarioName = coinsScenarioName, state = coinsScenarioState) + } + step("Set WireMock scenario: '$providersScenarioName' to state: '$providersScenarioState'") { + setWireMockScenarioState(scenarioName = providersScenarioName, state = providersScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } + } + step("Click on 'Swap to another token' button") { + onSendScreen { swapToAnotherTokenButton.performClick() } + } + step("Type '$swapTokenName' in search text field") { + onSendViaSwapScreen { + searchField.performClick() + searchField.performTextInput(swapTokenName) + } + } + step("Click on token: '$swapTokenName'") { + onSendViaSwapScreen { tokenItem(swapTokenName).performClick() } + } + step("Click on '$networkName' network") { + onChooseNetworkBottomSheet { networkItem(networkName).performClick() } + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Type '$memoFirst' in 'Destination Tag' field") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendAddressScreen { destinationTagTextField.performTextReplacement(memoFirst) } + } + } + step("Assert 'Destination Tag' field value is '$memoFirst'") { + onSendAddressScreen { destinationTagTextField.assertTextContains(memoFirst) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Assert recipient memo on 'Send confirm' screen contains '$memoFirst'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { recipientMemo.assertTextContains(memoFirst, substring = true) } + } + } + + // Edit-mode re-entry from Confirm uses the 'Continue' button, not 'Next'. + step("Click on recipient block to edit memo") { + onSendConfirmScreen { recipientBlock.performClick() } + } + step("Replace 'Destination Tag' field value with '$memoSecond'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendAddressScreen { destinationTagTextField.performTextReplacement(memoSecond) } + } + } + step("Assert 'Destination Tag' field value is '$memoSecond'") { + onSendAddressScreen { destinationTagTextField.assertTextContains(memoSecond) } + } + step("Return to 'Send confirm' screen via 'Continue' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaContinueButton() + } + } + step("Assert recipient memo on 'Send confirm' screen contains '$memoSecond'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { recipientMemo.assertTextContains(memoSecond, substring = true) } + } + } + + step("Click on recipient block to edit memo") { + onSendConfirmScreen { recipientBlock.performClick() } + } + step("Replace 'Destination Tag' field value back with '$memoFirst'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendAddressScreen { destinationTagTextField.performTextReplacement(memoFirst) } + } + } + step("Assert 'Destination Tag' field value is '$memoFirst'") { + onSendAddressScreen { destinationTagTextField.assertTextContains(memoFirst) } + } + step("Return to 'Send confirm' screen via 'Continue' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaContinueButton() + } + } + step("Assert recipient memo on 'Send confirm' screen contains '$memoFirst'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { recipientMemo.assertTextContains(memoFirst, substring = true) } + } + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt index 5156609bb7..7ab3fb9ab5 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/DogecoinWarningsTest.kt @@ -4,9 +4,11 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.DOGECOIN_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendScreen @@ -20,8 +22,8 @@ import org.junit.Test @HiltAndroidTest class DogecoinWarningsTest : BaseTestCase() { private val tokenName = "Dogecoin" - private val amountToLeaveLessThanDust = "5.78654978" - private val amountToLeaveMoreThanDust = "5.7" + private val amountToLeaveLessThanDust = "5.7045" + private val amountToLeaveMoreThanDust = "5.6" private val amountGreaterThanDust = "0.02" private val amountLessThanDust = "0.005" private val dustAmount = "DOGE 0.01" @@ -56,8 +58,10 @@ class DogecoinWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid amount warning' is displayed") { checkSendWarning( @@ -93,8 +97,10 @@ class DogecoinWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid amount warning' is not displayed") { checkSendWarning( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaDustWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaDustWarningsTest.kt new file mode 100644 index 0000000000..1b5ae1d17b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaDustWarningsTest.kt @@ -0,0 +1,290 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class KaspaDustWarningsTest : BaseTestCase() { + private val tokenName = "Kaspa" + private val amountLessThanMinimum = "0.1" + private val amountExactlyMinimum = "0.2" + private val amountMoreThanMinimum = "0.3" + private val amountToLeaveMoreThanMinimumChange = "0.5" + private val amountToLeaveExactlyMinimumChange = "0.79" + private val amountToLeaveLessThanMinimumChange = "0.85" + + private val kaspaUTXOScenarioName = "kaspa_utxo" + private val dustState = "dust" + + private val dustAmount = "KAS 0.20" + private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + private val invalidAmountMessage = getResourceString( + R.string.send_notification_invalid_minimum_amount_text, + dustAmount, dustAmount + ) + + @AllureId("4685") + @DisplayName("Warnings: invalid amount warning is displayed, when sending less than minimum amount (Kaspa)") + @Test + fun warningIsDisplayedWhenSendingLessThanMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountLessThanMinimum' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountLessThanMinimum) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage + ) + } + } + } + + @AllureId("9860") + @DisplayName("Warnings: invalid amount warning is NOT displayed, when sending exactly minimum amount (Kaspa)") + @Test + fun warningIsNotDisplayedWhenSendingExactlyMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountExactlyMinimum' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountExactlyMinimum) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4683") + @DisplayName("Warnings: invalid amount warning is NOT displayed, when sending more than minimum amount (Kaspa)") + @Test + fun warningIsNotDisplayedWhenSendingMoreThanMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountMoreThanMinimum' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountMoreThanMinimum) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4684") + @DisplayName("Warnings: invalid amount warning is NOT displayed, when change is more than minimum amount (Kaspa)") + @Test + fun warningIsNotDisplayedWhenChangeIsMoreThanMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveMoreThanMinimumChange' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveMoreThanMinimumChange) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4682") + @DisplayName("Warnings: invalid amount warning is displayed, when change is less than minimum amount (Kaspa)") + @Test + fun warningIsDisplayedWhenChangeIsLessThanMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveLessThanMinimumChange' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveLessThanMinimumChange) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage + ) + } + } + } + + @AllureId("9861") + @DisplayName("Warnings: invalid amount warning is NOT displayed, when change is exactly minimum amount (Kaspa)") + @Test + fun warningIsNotDisplayedWhenChangeIsExactlyMinimum() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(kaspaUTXOScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") { + setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState) + } + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$amountToLeaveExactlyMinimumChange' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amountToLeaveExactlyMinimumChange) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount warning' is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt index 98b623c535..18d4af9a0a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/KaspaWarningsTest.kt @@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendScreen @@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Click 'Next' button until 'Send Confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } } step("Assert 'UTXO limit warning' is displayed") { checkSendWarning( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt index fb4483da57..c2278600d3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt @@ -3,11 +3,13 @@ package com.tangem.tests.send.warnings import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendConfirmScreen @@ -110,8 +112,10 @@ class StellarWarningsTest : BaseTestCase() { step("Type non activated address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid reserve amount warning' is not displayed") { checkSendWarning( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/GaslessSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/GaslessSwapTest.kt new file mode 100644 index 0000000000..7c03377786 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/GaslessSwapTest.kt @@ -0,0 +1,542 @@ +package com.tangem.tests.swap + +import com.tangem.common.BaseTestCase +import com.tangem.common.R as CommonR +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.extractText +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless swap on a CEX route, paying the swap network fee with a stablecoin. Covers the best-rate + * block / unchanged rate (5117), the fee-token selector bottom sheet (5111), the network-fee + * selection for a USDC -> POL swap (5110), the signed swap reaching the provider (5116, hot wallet), + * the insufficient-stablecoin-balance-for-fee error (5118), the stablecoin fee shown with its + * fiat equivalent (5112), switching the fee token between the coin and the stablecoin (5114), and the + * fee-selection options when paying with a token (5115), and the max amount reserving the fee (5113). + * Validation cases run on the default (cold) wallet without signing. + */ +@HiltAndroidTest +class GaslessSwapTest : BaseTestCase() { + + private val tokenName = "USDC" + private val currencySymbol = "USDC" + private val swapTokenName = "Ethereum" + private val nativeTokenName = "Polygon" + private val inputAmount = "50" + private val userTokensState = "PolygonUSDCEthereum" + private val quotesState = "PolygonUSDC" + private val assetsScenarioName = "express_api_assets" + private val assetsExchangeEnabledState = "BitcoinExchangeEnabled" + + @AllureId("5117") + @DisplayName("Gasless Swap: CEX best rate stays the same when the fee is paid with a stablecoin") + @Test + fun checkBestRateUnchangedWithStablecoinFeeTest() { + var capturedReceiveAmount: String? = null + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount) + } + step("Assert 'Best rate' label is displayed") { + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { bestRateText.assertIsDisplayed() } + } + } + step("Capture the received amount (exchange rate) before changing the fee token") { + onSwapTokenScreen { capturedReceiveAmount = receiveAmount.extractText() } + } + step("Pay the network fee with the stablecoin '$tokenName'") { + switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName) + } + step("Assert the received amount (rate) is unchanged after selecting the stablecoin fee") { + val expected = requireNotNull(capturedReceiveAmount) { "Receive amount was not captured" } + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { receiveAmount.assertTextEquals(expected) } + } + } + } + } + + @AllureId("5111") + @DisplayName("Gasless Swap: fee-token selector — coin has a speed choice, stablecoin only Market, token is selectable") + @Test + fun checkFeeSelectorBottomSheetTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast) + val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount) + } + step("Open the 'Network fee' bottom sheet") { + openSwapNetworkFeeSelector() + } + step("Assert the '$nativeTokenName' fee coin is displayed") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).assertIsDisplayed() } + } + step("Open 'Choose speed' for the '$nativeTokenName' fee") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() } + } + step("Assert 'Choose speed' offers multiple speeds ('$marketSpeed', '$fastSpeed') for the coin") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendSelectNetworkFeeBottomSheet { + chooseSpeedTitle.assertIsDisplayed() + regularFeeSelectorItem(marketSpeed).assertIsDisplayed() + regularFeeSelectorItem(fastSpeed).assertIsDisplayed() + } + } + } + step("Select '$marketSpeed' speed") { + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSpeed).performClick() } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + } + step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") { + onSendFeeSelectorBottomSheet { + chooseTokenTitle.assertIsDisplayed() + feeTokenItem(tokenName).assertIsDisplayed() + } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Assert only '$marketSpeed' speed is available for the stablecoin fee") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { + feeSpeedItemTitle(marketSpeed).assertIsDisplayed() + feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() + feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() + } + } + } + step("Click on '$marketSpeed' fee row") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() } + } + step("Assert 'Choose speed' bottom sheet did not open for the stablecoin fee") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is shown in '$currencySymbol' on the swap screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + } + } + + @AllureId("5110") + @DisplayName("Gasless Swap: network fee with fee-token selection is shown for a USDC -> POL swap") + @Test + fun checkNetworkFeeSelectionForSwapTest() { + val receiveTokenName = "Polygon" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + + step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = receiveTokenName, amount = inputAmount) + } + step("Assert 'Network fee' block with token selection is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + networkFeeBlock.assertIsDisplayed() + selectFeeIcon.assertIsDisplayed() + } + } + } + step("Open the 'Network fee' bottom sheet") { + openSwapNetworkFeeSelector() + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { + chooseTokenTitle.assertIsDisplayed() + feeTokenItem(tokenName).assertIsDisplayed() + } + } + } + } + } + + @AllureId("5116") + @DisplayName("Gasless Swap: sign a swap paying the fee with the stablecoin and reach the provider") + @Test + fun checkSignSwapWithStablecoinFeeTest() { + val hotWalletTokensState = "PolygonUSDCHotWallet" + val receiveTokenName = "Polygon" + val providerName = "Changelly" + val exchangeStatusScenario = "exchange_status_provider" + val changellyStatusState = "Changelly" + val expressStatusItemTitle = getResourceString(CommonR.string.express_exchange_by, providerName) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(exchangeStatusScenario) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") { + setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState) + } + + step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName' on an existing hot wallet") { + openSwapAmountScreen( + fromTokenName = tokenName, + receiveTokenName = receiveTokenName, + amount = inputAmount, + seedPhrase = SVS_SEED_PHRASE_12, + ) + } + step("Pay the network fee with the stablecoin '$tokenName'") { + switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName) + } + step("Assert the network fee is paid in '$currencySymbol'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + feeBlockCurrency(currencySymbol).assertIsDisplayed() + } + } + } + step("Confirm the swap by holding the 'Swap' button and sign") { + confirmSwapByHolding() + } + step("Assert the 'Swap in progress' screen is displayed (transaction sent to provider)") { + onSwapSuccessScreen { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { title.assertIsDisplayed() } + } + } + step("Click on 'Close' button") { + onSwapSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } + + @AllureId("5118") + @DisplayName("Gasless Swap: insufficient stablecoin balance to cover the fee shows an error and blocks the swap") + @Test + fun checkInsufficientBalanceForFeeTest() { + val usdcBalanceScenario = "polygon_usdc_balance" + val lowBalanceState = "LowBalance" + val lowAmount = "0.0005" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(usdcBalanceScenario) + } + ).run { + step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") { + setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState) + } + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = lowAmount) + } + step("Open the 'Network fee' bottom sheet") { + openSwapNetworkFeeSelector() + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Select '$tokenName' as the fee-paying token") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + } + step("Assert 'Not enough funds' error is displayed in the fee selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() } + } + } + step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") { + onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() } + } + } + } + + @AllureId("5112") + @DisplayName("Gasless Swap: the stablecoin network fee is shown with its fiat (dollar) equivalent") + @Test + fun checkStablecoinFeeShownWithFiatTest() { + val fiatSign = "$" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount) + } + step("Pay the network fee with the stablecoin '$tokenName'") { + switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName) + } + step("Assert the network fee is shown in '$currencySymbol' with its fiat ('$fiatSign') equivalent") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + feeBlockCurrency(currencySymbol).assertIsDisplayed() + feeAmount.assertTextContains(fiatSign, substring = true) + } + } + } + } + } + + @AllureId("5114") + @DisplayName("Gasless Swap: switching the fee token from the coin to the stablecoin and back updates the summary") + @Test + fun checkSwitchFeeTokenBetweenCoinAndStablecoinTest() { + val nativeSymbol = "POL" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount) + } + step("Switch the fee token from the coin '$nativeTokenName' to the stablecoin '$tokenName'") { + switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName) + } + step("Assert the network fee is now paid in '$currencySymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + step("Switch the fee token from the stablecoin '$tokenName' back to the coin '$nativeTokenName'") { + switchFeeTokenAndApply(currentFeeToken = tokenName, newFeeToken = nativeTokenName) + } + step("Assert the network fee is back to the coin ('$nativeSymbol') on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() } + } + } + } + } + + @AllureId("5115") + @DisplayName("Gasless Swap: fee selector offers token selection and no speed choice when paying with a token") + @Test + fun checkFeeSelectionOptionsTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast) + val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount) + } + step("Open the 'Network fee' bottom sheet") { + openSwapNetworkFeeSelector() + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") { + onSendFeeSelectorBottomSheet { + chooseTokenTitle.assertIsDisplayed() + feeTokenItem(tokenName).assertIsDisplayed() + } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Assert only '$marketSpeed' speed is available for the token fee") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { + feeSpeedItemTitle(marketSpeed).assertIsDisplayed() + feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() + feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() + } + } + } + step("Click on '$marketSpeed' fee row") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() } + } + step("Assert 'Choose speed' bottom sheet did not open when paying with a token") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is shown in '$currencySymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() } + } + } + } + } + + @AllureId("5113") + @DisplayName("Gasless Swap: the max amount reserves the stablecoin fee and stays valid without an insufficient error") + @Test + fun checkMaxAmountReservesFeeTest() { + val maxInputAmount = "100" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(assetsScenarioName) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState) + } + + step("Open the swap amount screen for '$tokenName' -> '$swapTokenName' with the max amount '$maxInputAmount'") { + openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = maxInputAmount) + } + step("Pay the network fee with the stablecoin '$tokenName'") { + switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName) + } + step("Assert no insufficient-balance error and 'Swap' is enabled (the fee is reserved from the max amount)") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + insufficientFundsErrorTitle.assertIsNotDisplayed() + swapButton.assertIsEnabled() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt index 13bc7aba06..e5f34ef64d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt @@ -3,8 +3,6 @@ package com.tangem.tests.swap import androidx.compose.ui.test.longClick import androidx.test.InstrumentationRegistry.getTargetContext import com.tangem.common.BaseTestCase -import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT -import com.tangem.common.extensions.assertHasBadge import com.tangem.common.extensions.restartApp import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState @@ -18,101 +16,6 @@ import org.junit.Test @HiltAndroidTest class SwapStoriesTest : BaseTestCase() { - @AllureId("5453") - @DisplayName("Check 'Swap' button badge on 'Main' screen") - @Test - fun checkMainScreenSwapButtonBadgeTest() { - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Assert 'Swap' button has badge") { - onMainScreen { swapButton.assertHasBadge() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MainScreen) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onMainScreen { swapButton.assertHasBadge(false) } - } - } - } - - @AllureId("5454") - @DisplayName("Check 'Swap' button badge on token details screen") - @Test - fun checkTokenDetailsScreenSwapButtonTest() { - val tokenName = "Ethereum" - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Click on token with name: '$tokenName'") { - onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } - } - step("Assert 'Swap' button has badge") { - onTokenDetailsScreen { swapButton().assertHasBadge() } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.TokenDetails) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onTokenDetailsScreen { swapButton().assertHasBadge(false) } - } - } - } - - @AllureId("5455") - @DisplayName("Check 'Swap' button badge on token details in 'Market' screen") - @Test - fun checkMarketTokenDetailsScreenSwapButtonTest() { - val tokenName = "Ethereum" - val badgeShown = "Badge shown" - val badgeHidden = "Badge hidden" - - setupHooks().run { - - step("Open 'Main Screen'") { - openMainScreen() - } - step("Synchronize addresses") { - synchronizeAddresses() - } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) - } - step("Assert 'Swap' button has badge") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) } - } - step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails) - } - step("Click on 'Close' button") { - onSwapTokenScreen { closeButton.performClick() } - } - step("Assert 'Swap' button has not badge") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) } - } - } - } - @AllureId("5469") @DisplayName("Check unavailable swap stories on 'Main' screen") @Test @@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Swap' button has not badge") { - onMainScreen { swapButton.assertHasBadge(false) } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) } @@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() { waitForIdle() onMainScreen { swapButton.assertIsDisplayed() } } - step("Assert 'Swap' button has badge") { - onMainScreen { swapButton.assertHasBadge() } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true) } @@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Assert 'Swap' button has not badge") { - onTokenDetailsScreen { swapButton().assertHasBadge(false) } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } @@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() { step("Restart app") { restartApp(packageName) } - step("Assert 'Swap' button has badge") { - waitForIdle() - flakySafely(WAIT_UNTIL_TIMEOUT) { - composeTestRule.mainClock.advanceTimeBy(500) - onMainScreen { swapButton.assertHasBadge() } - } - } step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) } @@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() { val scenarioErrorState = "Error" val packageName = getTargetContext().packageName val tokenName = "Ethereum" - val badgeShown = "Badge shown" - val badgeHidden = "Badge hidden" setupHooks( additionalBeforeAppLaunchSection = { @@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) + step("Open 'Token details' from 'Markets' screen for token '$tokenName'") { + openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName) } - step("Assert 'Swap' button has not badge") { + step("Assert 'Swap' button is displayed") { waitForIdle() - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } step("Click on 'Close' button") { onSwapTokenScreen { closeButton.performClick() } @@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() { step("Restart app") { restartApp(packageName) } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) - } - step("Assert 'Swap' button has badge") { + step("Assert 'Swap' button is displayed") { waitForIdle() - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) } + onTokenDetailsScreen { swapButton.assertIsDisplayed() } } step("Open 'Swap' screen") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) } } } @@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() { step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } } - step("Assert 'Swap' button has badge") { - onTokenDetailsScreen { swapButton().assertHasBadge() } - } step("Click on 'Swap' button on 'Token details' screen") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Check stories changes") { checkStoriesChanges() @@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Open 'Markets' token details screen for token '$tokenName'") { - openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) + step("Open 'Token details' from 'Markets' screen for token '$tokenName'") { + openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName) } step("Click on 'Swap' button on 'Markets' token details screen") { - onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Check stories changes") { checkStoriesChanges() @@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() { onSwapTokenScreen { closeButton.performClick() } } step("Open 'Swap' screen without stories") { - openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } } } @@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() { step("Click on 'Close' button") { onSwapTokenScreen { closeButton.performClick() } } + step("Long click on token with name: '$tokenName' again to reopen actions menu") { + waitForIdle() + onMainScreen { + tokenWithTitleAndAddress(tokenName).performTouchInput { + longClick( + position = center, + durationMillis = 1000L, + ) + } + } + } step("Open 'Swap' screen without stories") { openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 94092b1e2f..3e6ae3f082 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -18,6 +18,7 @@ import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Assert.assertTrue import org.junit.Test @HiltAndroidTest @@ -50,7 +51,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -147,7 +148,7 @@ class SwapTokenScreenTest : BaseTestCase() { disableMobileData() } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -201,7 +202,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -304,7 +305,7 @@ class SwapTokenScreenTest : BaseTestCase() { onTokenDetailsScreen { title.assertIsDisplayed() } } step("Click on 'Swap' button") { - onTokenDetailsScreen { swapButton().performClick() } + onTokenDetailsScreen { swapButton.performClick() } } step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } @@ -510,7 +511,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() } } step("Assert 'Swap' button is not dimmed. Swap available") { - onTokenDetailsScreen { swapButton().assertIsDimmed(false) } + onTokenDetailsScreen { swapButton.assertIsEnabled() } } step("Press 'Back' button") { device.uiDevice.pressBack() @@ -519,7 +520,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() } } step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed(true) } + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } step("Press 'Back' button") { device.uiDevice.pressBack() @@ -528,7 +529,7 @@ class SwapTokenScreenTest : BaseTestCase() { onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() } } step("Assert 'Swap' button is dimmed") { - onTokenDetailsScreen { swapButton().assertIsDimmed(true) } + onTokenDetailsScreen { swapButton.assertIsNotEnabled() } } } } @@ -545,11 +546,11 @@ class SwapTokenScreenTest : BaseTestCase() { val inputAmount = "0.99" val market = "Market" val fast = "Fast" - val marketFeeAmount = "$1.12" - val fastFeeAmount = "$1.43" setupHooks().run { + var marketFee = 0.0 + step("Open 'Main Screen'") { openMainScreen() } @@ -575,19 +576,28 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Select '$market' fee type") { + step("Select '$market' fee type and capture its amount") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { - selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount) + marketFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Market)) } } - step("Select '$fast' fee type") { + step("Select '$fast' fee type and assert it exceeds the '$market' fee") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { - selectFeeType(FeeType.Fast, selectedFeeAmount = fastFeeAmount) + val fastFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Fast)) + assertTrue( + "Expected '$fast' fee ($fastFee) to be greater than '$market' fee ($marketFee)", + fastFee > marketFee, + ) } } } } + private fun parseFeeAmount(raw: String): Double = raw + .replace(Regex("[^0-9.]"), "") + .toDoubleOrNull() + ?: error("Could not parse a numeric fee amount from '$raw'") + @AllureId("8536") @DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)") @Test @@ -600,18 +610,24 @@ class SwapTokenScreenTest : BaseTestCase() { val feeAmount = "$" val scenarioName = "eth_network_balance" val scenarioState = "LessThanDollar" + val pairsScenarioName = "ethereum_from_pairs" + val pairsScenarioState = "DexProvider" val networkName = "Ethereum" val currencySymbol = "ETH" setupHooks( additionalAfterSection = { resetWireMockScenarioState(scenarioName) + resetWireMockScenarioState(pairsScenarioName) } ).run { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) } + step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") { + setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState) + } step("Open 'Main Screen'") { openMainScreen() @@ -679,6 +695,8 @@ class SwapTokenScreenTest : BaseTestCase() { val marketFeeAmount = "$1." val scenarioName = "eth_fee_history" val scenarioState = "UnableToCoverFastFee" + val pairsScenarioName = "ethereum_from_pairs" + val pairsScenarioState = "DexProvider" val networkName = "Ethereum" val currencySymbol = "ETH" @@ -686,12 +704,16 @@ class SwapTokenScreenTest : BaseTestCase() { setupHooks( additionalAfterSection = { resetWireMockScenarioState(scenarioName) + resetWireMockScenarioState(pairsScenarioName) } ).run { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) } + step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") { + setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState) + } step("Open 'Main Screen'") { openMainScreen() @@ -728,7 +750,7 @@ class SwapTokenScreenTest : BaseTestCase() { } step("Select '$fastFeeType' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { - selectFeeTypeWithGasless(feeType = FeeType.Fast, fastFeeAmount) + selectFeeType(feeType = FeeType.Fast, selectedFeeAmount = fastFeeAmount) } } step("Assert fee amount is equal to '$fastFeeType' fee:'$fastFeeAmount'") { @@ -744,7 +766,7 @@ class SwapTokenScreenTest : BaseTestCase() { } step("Select '$marketFeeType' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { - selectFeeTypeWithGasless(feeType = FeeType.Market, marketFeeAmount) + selectFeeType(feeType = FeeType.Market, selectedFeeAmount = marketFeeAmount) } } step("Assert 'Swap' button is enabled") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt index 51e2368332..314b7f2f14 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt @@ -86,6 +86,8 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { val tokensScenarioState = "SolanaUSDC" val balanceScenarioName = "solana_balance" val balanceScenarioState = "Empty" + val pairsScenarioName = "solana_from_pairs" + val pairsScenarioState = "DexProvider" val networkName = "Solana" val currencySymbol = "SOL" @@ -94,6 +96,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) resetWireMockScenarioState(QUOTES_API_SCENARIO) resetWireMockScenarioState(balanceScenarioName) + resetWireMockScenarioState(pairsScenarioName) } ).run { @@ -106,6 +109,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Set WireMock scenario: '$balanceScenarioName' to state: $balanceScenarioState") { setWireMockScenarioState(scenarioName = balanceScenarioName, state = balanceScenarioState) } + step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") { + setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState) + } step("Open 'Main Screen'") { openMainScreen() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt index f9925bcdcc..fa4e3f9910 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt @@ -3,12 +3,13 @@ package com.tangem.tests.tangempay import androidx.test.platform.app.InstrumentationRegistry import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.assertTextContainsSafe import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText -import com.tangem.common.extensions.pullToRefresh import com.tangem.common.utils.assertClipboardTextEquals import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.resetWireMockScenarios import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.* import com.tangem.screens.tangempay.* @@ -23,7 +24,7 @@ class TangemPayTest : BaseTestCase() { @AllureId("4549") @DisplayName("Tangem Pay: change PIN code from card details") @Test - fun changePin_SetsNewPinCode_FromCardDetails() { + fun changePinSetsNewPinCodeFromCardDetailsTest() { val newPin = "5217" val pinSetupScenario = "tangem_pay_pin_setup" val pinNotSetState = "PinNotSet" @@ -31,6 +32,7 @@ class TangemPayTest : BaseTestCase() { setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(pinSetupScenario, pinNotSetState) }, @@ -52,11 +54,10 @@ class TangemPayTest : BaseTestCase() { step("Enter PIN '$newPin'") { onTangemPayChangePinScreen { inputField.performTextInput(newPin) } } - step("Click on 'Submit' button") { - onTangemPayChangePinScreen { submitButton.performClick() } - } - step("Assert success screen is displayed") { - onTangemPayChangePinScreen { successTitle.assertIsDisplayed() } + step("Assert success screen title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTangemPayChangePinScreen { successTitle.assertIsDisplayed() } + } } step("Click on 'Done' button") { onTangemPayChangePinScreen { doneButton.clickWithAssertion() } @@ -67,7 +68,7 @@ class TangemPayTest : BaseTestCase() { @AllureId("4969") @DisplayName("Tangem Pay: balance updates after transaction on payment account screen") @Test - fun balanceUpdatesAfterTransaction_OnPaymentAccountScreen() { + fun balanceUpdatesAfterTransactionOnPaymentAccountScreenTest() { val balanceScenario = "tangem_pay_balance_update" val initialState = "InitialBalance" val afterTransactionState = "AfterTransaction" @@ -75,6 +76,7 @@ class TangemPayTest : BaseTestCase() { setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(balanceScenario, initialState) }, @@ -90,7 +92,7 @@ class TangemPayTest : BaseTestCase() { step("Switch WireMock scenario '$balanceScenario' to '$afterTransactionState'") { setWireMockScenarioState(balanceScenario, afterTransactionState) } - step("Pull to refresh") { pullToRefresh() } + step("Pull to refresh") { pullToRefreshTangemPay() } step("Assert updated balance contains '9'") { onTangemPayMainScreen { balance.assertTextContainsSafe("9", substring = true) } } @@ -100,7 +102,7 @@ class TangemPayTest : BaseTestCase() { @AllureId("4970") @DisplayName("Tangem Pay: new transaction appears after mocked charge") @Test - fun transactionList_NewTransactionAppears_AfterMockedCharge() { + fun transactionListNewTransactionAppearsAfterMockedChargeTest() { val historyScenario = "tangem_pay_transaction_history" val initialState = "InitialEmpty" val afterTransactionState = "AfterTransaction" @@ -109,6 +111,7 @@ class TangemPayTest : BaseTestCase() { setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(historyScenario, initialState) }, @@ -126,7 +129,7 @@ class TangemPayTest : BaseTestCase() { step("Switch WireMock scenario '$historyScenario' to '$afterTransactionState'") { setWireMockScenarioState(historyScenario, afterTransactionState) } - step("Pull to refresh") { pullToRefresh() } + step("Pull to refresh") { pullToRefreshTangemPay() } step("Assert transaction from '$merchantName' is displayed") { onTangemPayMainScreen { transactionRowWithText(merchantName).assertIsDisplayed() @@ -138,12 +141,13 @@ class TangemPayTest : BaseTestCase() { @AllureId("4974") @DisplayName("Tangem Pay: reveal and copy card number, expiration and CVC") @Test - fun revealAndCopyCardDetails_NumberExpirationCVC() { + fun revealAndCopyCardDetailsNumberExpirationCVCTest() { val context = InstrumentationRegistry.getInstrumentation().targetContext val eligibilityState = "PaeraCustomer" setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) }, additionalAfterSection = { @@ -152,9 +156,11 @@ class TangemPayTest : BaseTestCase() { ).run { openTangemPay() step("Click on card button") { + waitForIdle() onTangemPayMainScreen { cardButton.clickWithAssertion() } } step("Click on 'Show details' button") { + waitForIdle() onTangemPayCardPageScreen { showDetailsButton.clickWithAssertion() } } step("Assert number, expiration and CVC values are visible") { @@ -200,13 +206,14 @@ class TangemPayTest : BaseTestCase() { @AllureId("4971") @DisplayName("Tangem Pay: freeze card via confirmation sheet") @Test - fun freezeUnfreezeCard_TogglesCardState() { + fun freezeUnfreezeCardTogglesCardStateTest() { val freezeScenario = "tangem_pay_card_freeze" val startedState = "Started" val eligibilityState = "PaeraCustomer" setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(freezeScenario, startedState) }, diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt index 4143126b3a..700738e23c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt @@ -9,6 +9,7 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.assertTextContainsSafe import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.resetWireMockScenarios import com.tangem.common.utils.setWireMockScenarioState import com.tangem.core.res.R as CoreResR import com.tangem.scenarios.* @@ -26,7 +27,7 @@ class TangemPayTopUpTest : BaseTestCase() { @AllureId("4973") @DisplayName("Tangem Pay: top up swaps Bitcoin to USDC and appends deposit to history") @Test - fun topUpFromTangemPay_SwapsBitcoinToUSDC_AppendsDepositToHistory() { + fun topUpFromTangemPaySwapsBitcoinToUSDCAppendsDepositToHistoryTest() { val bitcoinScenario = "bitcoin_utxo" val expressAssetsScenario = "express_api_assets" val balanceScenario = "tangem_pay_balance_update" @@ -43,6 +44,7 @@ class TangemPayTopUpTest : BaseTestCase() { setupHooks( additionalBeforeSection = { + resetWireMockScenarios() setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(bitcoinScenario, bitcoinBalanceState) setWireMockScenarioState(expressAssetsScenario, expressAssetsState) @@ -62,6 +64,7 @@ class TangemPayTopUpTest : BaseTestCase() { onTangemPayMainScreen { balance.assertTextContainsSafe("10", substring = true) } } step("Click on 'Top Up' action chip") { + waitForIdle() onTangemPayMainScreen { topUpButton.clickWithAssertion() } } step("Assert 'Add Funds' sheet is displayed") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt new file mode 100644 index 0000000000..1904bd4c09 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -0,0 +1,1241 @@ +package com.tangem.tests.transfer + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.extractText +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R as CoreUiR +import com.tangem.scenarios.* +import com.tangem.screens.* +import com.tangem.screens.tangempay.* +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent +import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore +import org.junit.Test + +@HiltAndroidTest +class AppTransfersTest : BaseTestCase() { + + private val ethCallScenario = "eth_call_api" + private val ethBalanceScenario = "eth_network_balance" + private val started = "Started" + // Disable the first-time-swap stories (500 → not shown); their auto-advancing animation keeps Compose non-idle and flakes the close. + private val storiesScenario = "stories_first_time_swap_v2" + private val storiesErrorState = "Error" + + @AllureId("9838") + @DisplayName("App transfers: identical pair switches to Transfer mode") + @Test + fun identicalPairSwitchesToTransferModeTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("9843") + @DisplayName("App transfers: zero amount keeps Transfer button disabled") + @Test + fun zeroAmountKeepsTransferButtonDisabledTest() { + val token = "Ethereum" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Assert provider block is not displayed") { + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + + @AllureId("9992") + @DisplayName("App transfers: reversing tokens keeps Transfer mode") + @Test + fun reversingTokensKeepsTransferModeTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Click on 'Swap tokens' (reverse) button") { + onSwapTokenScreen { replaceTokensButton.performClick() } + } + step("Assert Transfer mode is ready") { assertTransferReady() } + } + } + + @AllureId("9847") + @DisplayName("App transfers: Max amount keeps Transfer enabled and subtracts fee") + @Test + fun maxAmountFractionSubtractsFeeTest() { + val token = "Ethereum" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Focus amount field to reveal predefined amount buttons") { + waitForIdle() + onSwapTokenScreen { textInput.clickWithAssertion() } + } + step("Click on 'Max' amount button") { + onSwapTokenScreen { maxAmountButton.performClick() } + } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert 'Transfer' button is enabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsEnabled() } + } + } + } + } + + @AllureId("9844") + @DisplayName("App transfers: amount above balance disables Transfer") + @Test + fun amountAboveBalanceDisablesTransferTest() { + val token = "Ethereum" + val aboveBalanceAmount = "100" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$aboveBalanceAmount'") { inputAmount(aboveBalanceAmount) } + // Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state. + step("Assert 'Insufficient funds' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("10003") + @DisplayName("App transfers: EVM network fee speed options") + @Test + fun evmNetworkFeeSpeedOptionsTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Assert Transfer mode is ready") { assertTransferReady() } + + var marketFee = "" + step("Read displayed 'Market' fee amount") { + onSwapTokenScreen { marketFee = feeAmount.extractText() } + } + step("Open 'Network fee' selector via 'Select fee' icon") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { selectFeeIcon.performClick() } + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } + } + } + step("Click on 'Fast' fee option") { + onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() } + } + step("Assert fee amount changed from Market fee") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeAmount.assertIsDisplayed() } + check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$marketFee'" } + } + } + } + } + + @AllureId("10002") + @DisplayName("App transfers: UTXO network fee") + @Test + fun utxoNetworkFeeTest() { + val token = "Bitcoin" + val amount = "0.001" + val userTokensState = "TwoAccountsSameBitcoin" + val bitcoinUtxoScenario = "bitcoin_utxo" + val bitcoinUtxoState = "BalanceAnyAddress" + val assetsScenario = "express_api_assets" + val assetsBitcoinState = "BitcoinExchangeEnabled" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(bitcoinUtxoScenario) + resetWireMockScenarioState(assetsScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$bitcoinUtxoScenario' to state: '$bitcoinUtxoState'") { + setWireMockScenarioState(scenarioName = bitcoinUtxoScenario, state = bitcoinUtxoState) + } + // Bitcoin swap must be exchange-enabled or the token-details Swap button stays disabled. + step("Set WireMock scenario: '$assetsScenario' to state: '$assetsBitcoinState'") { + setWireMockScenarioState(scenarioName = assetsScenario, state = assetsBitcoinState) + } + + // V3 card: Bitcoin's default path is m/84' (matches the stub) so the coin isn't custom — else the Swap button stays disabled. + step("Open Swap in Transfer mode for '$token'") { + openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10004") + @DisplayName("App transfers: Solana network fee") + @Test + fun solanaNetworkFeeTest() { + val token = "Solana" + val amount = "0.001" + val userTokensState = "TwoAccountsSameSolana" + val solanaBalanceScenario = "solana_balance" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + // Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("9845") + @DisplayName("App transfers: insufficient native coin for fee disables Transfer") + @Test + fun insufficientNativeCoinForFeeDisablesTransferTest() { + val token = "Tether" + val feeCoinName = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameUsdt" + // Zero native ETH (coin present in the mock so the fee still estimates) → fee exceeds balance. + val ethBalanceState = "EmptyAnyId" + val quotesUsdtState = "USDTHotWalletSvS" + val feeHistoryScenario = "eth_fee_history" + val estimateGasScenario = "eth_estimate_gas" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(feeHistoryScenario) + resetWireMockScenarioState(estimateGasScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$ethBalanceState'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = ethBalanceState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesUsdtState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesUsdtState) + } + step("Set WireMock scenario: '$feeHistoryScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = feeHistoryScenario, state = started) + } + step("Set WireMock scenario: '$estimateGasScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = estimateGasScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed() + } + } + } + } + } + + @AllureId("9990") + @DisplayName("App transfers: search filters receive token list") + @Test + fun searchFiltersReceiveTokenListTest() { + val sourceToken = "Polygon" + val ethereumToken = "Ethereum" + val polygonReceiveName = "POL (ex-MATIC)" + val noMatchQuery = "f" + val polygonQuery = "pol" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { resetWireMockScenarioState(storiesScenario) }, + ).run { + step("Open 'Main' screen") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Click on token with name: '$sourceToken'") { + onMainScreen { tokenWithTitleAndAddress(sourceToken).clickWithAssertion() } + } + step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) } + step("Open receive token selector") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Type '$noMatchQuery' in search field") { + onSwapSelectTokenScreen { + searchBarBlock.performClick() + searchBarBlock.performTextInput(noMatchQuery) + } + } + step("Assert '$ethereumToken' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() } + } + step("Assert '$polygonReceiveName' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsNotDisplayed() } + } + step("Replace search text with '$polygonQuery'") { + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) } + } + step("Assert '$polygonReceiveName' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } + } + } + step("Assert '$ethereumToken' is not displayed") { + onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() } + } + } + } + + @AllureId("9841") + @DisplayName("App transfers: full transfer reaches 'Transfer in progress' screen") + @Test + fun fullTransferReachesTransferInProgressScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9999") + @DisplayName("App transfers: broadcast error shows alert without finish screen") + @Test + fun broadcastErrorShowsAlertWithoutFinishScreenTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val sendRawTransactionScenario = "eth_sendRawTransaction" + val broadcastErrorState = "BroadcastError" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(sendRawTransactionScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$sendRawTransactionScenario' to state: '$broadcastErrorState'") { + setWireMockScenarioState(scenarioName = sendRawTransactionScenario, state = broadcastErrorState) + } + + step("Open Swap in Transfer mode for '$token' with existing hot wallet") { + openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transaction failed' dialog is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onFailedTransactionDialog { dialogContainer.assertIsDisplayed() } + } + } + step("Assert 'Transfer in progress' screen is not displayed") { + onSwapSuccessScreen { transferInProgressTitle.assertDoesNotExist() } + } + } + } + + @AllureId("9989") + @DisplayName("App transfers: receive list allows identical token on another account") + @Test + fun receiveListAllowsIdenticalTokenOnAnotherAccountTest() { + val token = "Ethereum" + val receiveAccountName = "Account 2" + val userTokensState = "TwoAccountsSameToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open Swap for '$token' in 'Account 1'") { openSwapForTokenInAccount(token) } + step("Open receive token selector") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Expand account '$receiveAccountName' in receive selector") { + onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() } + } + step("Assert token '$token' is displayed in receive selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(token).assertIsDisplayed() } + } + } + } + } + + @AllureId("9998") + @DisplayName("App transfers: fee calculation error disables Transfer") + @Test + fun feeCalculationErrorDisablesTransferTest() { + val token = "Ethereum" + val amount = "0.001" + val userTokensState = "TwoAccountsSameToken" + val feeHistoryScenario = "eth_fee_history" + val estimateGasScenario = "eth_estimate_gas" + val unreachable = "Unreachable" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + resetWireMockScenarioState(feeHistoryScenario) + resetWireMockScenarioState(estimateGasScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + step("Set WireMock scenario: '$feeHistoryScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = feeHistoryScenario, state = unreachable) + } + step("Set WireMock scenario: '$estimateGasScenario' to state: '$unreachable'") { + setWireMockScenarioState(scenarioName = estimateGasScenario, state = unreachable) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + // Unreachable fee endpoints leave the fee unresolved (shown as '—'); the transfer stays blocked. + step("Assert 'Transfer' button is disabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + } + + @AllureId("9994") + @DisplayName("App transfers: mode switches reactively without screen reload") + @Test + fun modeSwitchesReactivelyWithoutScreenReloadTest() { + val token = "Solana" + val swapReceiveToken = "USDC" + val userTokensState = "TwoAccountsSameSolanaWithUsdc" + val solanaBalanceScenario = "solana_balance" + val assetsScenario = "express_api_assets" + val fromPairsScenario = "solana_from_pairs" + val dexProviderState = "DexProvider" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(assetsScenario) + resetWireMockScenarioState(fromPairsScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$assetsScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = assetsScenario, state = started) + } + step("Set WireMock scenario: '$fromPairsScenario' to state: '$dexProviderState'") { + setWireMockScenarioState(scenarioName = fromPairsScenario, state = dexProviderState) + } + // Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Change receive token to '$swapReceiveToken' to switch to Swap mode") { + changeReceiveToken(swapReceiveToken) + } + step("Assert 'Swap' button is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { swapButton.assertIsDisplayed() } + } + } + step("Change receive token back to identical '$token' to switch to Transfer mode") { + changeReceiveToken(token) + } + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { assertTransferReady() } + } + } + } + + @AllureId("10001") + @DisplayName("App transfers: memo field is not entered manually in Transfer mode") + @Test + fun memoFieldIsNotEnteredManuallyInTransferModeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert manual 'Destination tag' field is not displayed") { + onSwapTokenScreen { destinationTagField.assertDoesNotExist() } + } + } + } + + @AllureId("10009") + @DisplayName("App transfers: XRP network fee") + @Test + fun xrpNetworkFeeTest() { + val token = "XRP Ledger" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXRP" + val rippleAccountInfoScenario = "ripple_account_info" + val quotesRippleState = "Ripple" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(rippleAccountInfoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10011") + @DisplayName("App transfers: Stellar network fee") + @Test + fun stellarNetworkFeeTest() { + val token = "Stellar" + val amount = "0.001" + val userTokensState = "TwoAccountsSameXLM" + val quotesXlmState = "XLM" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesXlmState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesXlmState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10005") + @DisplayName("App transfers: Tron network fee") + @Test + fun tronNetworkFeeTest() { + val token = "Tron" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTron" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + // blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. + @Ignore("[REDACTED_JIRA]") + @AllureId("10012") + @DisplayName("App transfers: TON network fee") + @Test + fun tonNetworkFeeTest() { + // The SDK names TON's coin "Gram" (Blockchain.TON.getCoinName), so the portfolio row shows "Gram", not "Toncoin". + val token = "Gram" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTON" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10013") + @DisplayName("App transfers: Cosmos network fee") + @Test + fun cosmosNetworkFeeTest() { + val token = "Cosmos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameCosmos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10015") + @DisplayName("App transfers: Aptos network fee") + @Test + fun aptosNetworkFeeTest() { + val token = "Aptos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameAptos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("9856") + @DisplayName("App transfers: Transfer mode is available from a Tangem Pay account") + @Test + fun transferModeAvailableFromTangemPayAccountTest() { + val token = "USDC" + val receiveAccountName = "Main account" + val eligibilityState = "PaeraCustomer" + val balanceScenario = "tangem_pay_balance_update" + val balanceInitialState = "InitialBalance" + val historyScenario = "tangem_pay_transaction_history" + val historyInitialState = "InitialEmpty" + val userTokensState = "TangemPayTransferUsdc" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) + resetWireMockScenarioState(balanceScenario) + resetWireMockScenarioState(historyScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to state: '$eligibilityState'") { + setWireMockScenarioState(scenarioName = TANGEM_PAY_ELIGIBILITY_SCENARIO, state = eligibilityState) + } + step("Set WireMock scenario: '$balanceScenario' to state: '$balanceInitialState'") { + setWireMockScenarioState(scenarioName = balanceScenario, state = balanceInitialState) + } + step("Set WireMock scenario: '$historyScenario' to state: '$historyInitialState'") { + setWireMockScenarioState(scenarioName = historyScenario, state = historyInitialState) + } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + + step("Open Tangem Pay") { openTangemPay() } + step("Click on 'Withdraw' button") { + onTangemPayMainScreen { withdrawButton.clickWithAssertion() } + } + step("Acknowledge withdrawal note sheet") { + onTangemPayWithdrawNoteSheet { + title.assertIsDisplayed() + gotItButton.clickWithAssertion() + } + } + step("Choose identical receive token '$token' from '$receiveAccountName'") { + chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName) + } + // Withdraw-entry swap keeps recalculating — use flakySafely rather than assertTransferReady's waitUntil. + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + onSwapTokenScreen { transferTitle.assertIsDisplayed() } + } + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + } + } + + @AllureId("9995") + @DisplayName("App transfers: transfer between different wallets reaches 'Transfer in progress' screen") + @Test + fun transferBetweenDifferentWalletsReachesFinishTest() { + val token = "Ethereum" + val amount = "0.001" + val secondWalletName = "Wallet 2" + val userTokensState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Add a second card wallet '$secondWalletName'") { + addNewCardWallet(WalletMockContent) + } + step("Switch back to the hot wallet") { switchToPreviousWallet() } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Select identical receive token '$token' on '$secondWalletName'") { + selectReceiveTokenOnWallet(token = token, walletName = secondWalletName) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9996") + @DisplayName("App transfers: adding a missing token to the recipient wallet enables Transfer") + @Test + fun addMissingTokenToRecipientWalletEnablesTransferTest() { + val token = "Ethereum" + val bitcoinToken = "Bitcoin" + val recipientWalletName = "Wallet" + val recipientWithoutEthereumState = "RecipientWithoutEthereum" + val ethereumWithSecondTokenState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$recipientWithoutEthereumState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = recipientWithoutEthereumState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Assert token '$bitcoinToken' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { tokenWithTitleAndAddress(bitcoinToken).assertIsDisplayed() } + } + } + // Switch the user-tokens mock so the second wallet loads with Ethereum while the recipient stays Ethereum-less. + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$ethereumWithSecondTokenState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = ethereumWithSecondTokenState) + } + step("Add a second card wallet") { + addNewCardWallet(WalletMockContent) + } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Add missing token '$token' to recipient wallet '$recipientWalletName'") { + addMissingReceiveTokenToWallet(token = token, recipientWalletName = recipientWalletName) + } + step("Assert Transfer mode is ready") { assertTransferReady() } + } + } + + // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. + @Ignore("[REDACTED_JIRA]") + @AllureId("9852") + @DisplayName("App transfers: amount below destination reserve disables Transfer") + @Test + fun amountBelowDestinationReserveDisablesTransferTest() { + val token = "Solana" + val belowReserveAmount = "0.0001" + val userTokensState = "TwoAccountsSameSolana" + val solanaBalanceScenario = "solana_balance" + val recipientAccountScenario = "solana_recipient_account" + val notExistState = "NotExist" + val quotesSolanaState = "Solana" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(solanaBalanceScenario) + resetWireMockScenarioState(recipientAccountScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started) + } + step("Set WireMock scenario: '$recipientAccountScenario' to state: '$notExistState'") { + setWireMockScenarioState(scenarioName = recipientAccountScenario, state = notExistState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowReserveAmount'") { inputAmount(belowReserveAmount) } + step("Assert error notification is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { errorNotificationTitle.assertIsDisplayed() } + } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } + + @AllureId("9997") + @DisplayName("App transfers: amount below minimum disables Transfer") + @Test + fun amountBelowMinimumDisablesTransferTest() { + val token = "Kaspa" + val belowMinimumAmount = "0.00000001" + val userTokensState = "TwoAccountsSameKaspa" + val kaspaUtxoScenario = "kaspa_utxo" + // Android-specific UTXO body — addresses differ from the iOS fixture (see kaspa-utxo.json). + val kaspaUtxoState = "more_than_84_android" + val quotesKaspaState = "Kaspa" + val invalidAmountTitle = getResourceString(CoreUiR.string.send_notification_invalid_amount_title) + val minimumAmountMessagePrefix = + getResourceString(CoreUiR.string.send_notification_invalid_minimum_amount_text).substringBefore("%1") + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(kaspaUtxoScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") { + setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$belowMinimumAmount'") { inputAmount(belowMinimumAmount) } + step("Assert '$invalidAmountTitle' notification title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { warningTitle(invalidAmountTitle).assertIsDisplayed() } + } + } + step("Assert notification message contains the minimum-amount text") { + onSwapTokenScreen { errorNotificationText.assertTextContains(minimumAmountMessagePrefix, substring = true) } + } + step("Assert 'Transfer' button is disabled") { + onSwapTokenScreen { transferButton.assertIsNotEnabled() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6139be58a..36cca67394 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -210,6 +210,17 @@ android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 97ff5929f9..a7b32c7668 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093 +Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 4c7aa189cb..61f52257be 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -5,9 +5,12 @@ import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager +import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.config.environment.EnvironmentConfig +import com.tangem.lib.auth.AuthFeatureToggles +import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.wallets.repository.WalletsRepository @@ -49,4 +52,10 @@ interface ApplicationEntryPoint { fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor + + fun getDeviceKeyManager(): DeviceKeyManager + + fun getDeviceRegistrar(): DeviceRegistrar + + fun getAuthFeatureToggles(): AuthFeatureToggles } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index b2aa247f2c..6e37802279 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -177,12 +177,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { val splashScreen = installSplashScreen() TangemLogger.i("Splash screen installed") - enableEdgeToEdge( - navigationBarStyle = SystemBarStyle.auto( - Color.Transparent.toArgb(), - Color.Transparent.toArgb(), - ), - ) + applyEdgeToEdge() super.onCreate(savedInstanceState) @@ -200,6 +195,10 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown } + splashScreen.setOnExitAnimationListener { provider -> + provider.remove() + applyEdgeToEdge() + } installActivityDependencies() observeAppThemeModeUpdates() @@ -223,6 +222,15 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } } + private fun applyEdgeToEdge() { + enableEdgeToEdge( + navigationBarStyle = SystemBarStyle.auto( + Color.Transparent.toArgb(), + Color.Transparent.toArgb(), + ), + ) + } + private fun setRootContent() { // for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy // doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 3684c3af20..1a1a4046bf 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -21,6 +21,9 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.common.LogConfig import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.lib.auth.AuthFeatureToggles +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler @@ -92,6 +95,15 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val sendTransactionSignerInfoInterceptor get() = entryPoint.getSendTransactionSignerInfoInterceptor() + private val deviceKeyManager: DeviceKeyManager + get() = entryPoint.getDeviceKeyManager() + + private val deviceRegistrar: DeviceRegistrar + get() = entryPoint.getDeviceRegistrar() + + private val authFeatureToggles: AuthFeatureToggles + get() = entryPoint.getAuthFeatureToggles() + // endregion private val appScope = MainScope() @@ -132,6 +144,16 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. } fun init() { + if (authFeatureToggles.isBackendAuthenticationEnabled) { + appScope.launch { + // Order matters: registration reads the device public key, so it must wait for + // generation to complete. Running them concurrently on first launch would race — + // register() would see `DeviceKeyUnavailable` and defer to the next app launch. + deviceKeyManager.generateIfMissing() + deviceRegistrar.register() + .onLeft { error -> TangemLogger.w("Device registration deferred: $error") } + } + } walletsRepository = entryPoint.getWalletsRepository() apiConfigsManager.initialize() diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt index e24d5594e5..0cd2024584 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/HotWalletContextInterceptor.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.SignIn import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent class HotWalletContextInterceptor( val parent: ParamsInterceptor? = null, @@ -18,6 +19,7 @@ class HotWalletContextInterceptor( is SignIn.ButtonAddWallet, is SignIn.ButtonUnlockAllWithBiometric, is IntroductionProcess.ButtonScanCard, + is TokenScreenAnalyticsEvent.ButtonQuickTopUp, -> false is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll else -> true diff --git a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt index 0b6f9c503e..61ac194a7f 100644 --- a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt +++ b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt @@ -6,6 +6,8 @@ import android.net.Uri import androidx.core.net.toUri import com.tangem.common.routing.DeepLinkScheme import com.tangem.common.uri.ExternalUrlValidator +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.url.UrlOpener import com.tangem.utils.logging.TangemLogger @@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger internal class DefaultDeeplinkLauncher( private val context: Context, private val urlOpener: UrlOpener, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : DeeplinkLauncher { override fun launch(link: String) { @@ -58,11 +61,33 @@ internal class DefaultDeeplinkLauncher( } private fun launchDeepLink(uri: Uri) { - context.startActivity(createDeepLinkIntent(uri)) + val intent = createDeepLinkIntent(uri) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + TangemLogger.i( + """ + No match found for deep link + |- Received URI: $uri + """.trimIndent(), + ) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = UnresolvedDeeplinkException(uri), + params = mapOf( + "uri_scheme" to uri.scheme.orEmpty(), + "uri_host" to uri.host.orEmpty(), + ), + ), + ) + } } private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply { setPackage(context.packageName) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } -} \ No newline at end of file +} + +internal class UnresolvedDeeplinkException(uri: Uri) : + RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}") \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt index 6e9e000506..a103b11bb0 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt @@ -4,15 +4,20 @@ import android.app.Application import com.chuckerteam.chucker.api.ChuckerInterceptor import com.tangem.Log import com.tangem.TangemSdkLogger +import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.datasource.local.logs.SensitiveUrlMasker import com.tangem.datasource.utils.NetworkLogsSaveInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.common.LogConfig import com.tangem.operations.attestation.api.TangemApiServiceSettings +import com.tangem.utils.JsonStringValuesExtractor import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig +import kotlinx.serialization.json.Json /** * Owns all app-startup wiring of the logging subsystem in a single place: @@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig * @property appLogsStore app logs store used by file-based writer and the network logs save * interceptor * @property tangemSdkLogger Card SDK logger registered with [Log.addLogger] + * @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain + * URL masker * [REDACTED_AUTHOR] */ class TangemLoggingInitializer( private val appLogsStore: AppLogsStore, private val tangemSdkLogger: TangemSdkLogger, + private val environmentConfig: EnvironmentConfig, ) { fun initAppLogging() { @@ -64,6 +72,13 @@ class TangemLoggingInitializer( } add(createNetworkLoggingInterceptor()) add(ChuckerInterceptor(application)) + add( + NetworkLogsSaveInterceptor( + appLogsStore = appLogsStore, + sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(), + shouldCheckResponseBodySize = true, + ), + ) } TangemApiServiceSettings.addInterceptors( @@ -77,4 +92,16 @@ class TangemLoggingInitializer( }.toTypedArray(), ) } + + private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker { + val json = Json.encodeToJsonElement( + BlockchainSdkConfig.serializer(), + environmentConfig.blockchainSdkConfig, + ) + // Drop URL-shaped drawable (e.g. public endpoint URLs from BlockchainSdkConfig like + // kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs. + val values = JsonStringValuesExtractor.extract(json) + .filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) } + return SensitiveUrlMasker(values) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt index af5060ac0c..f0b5edc2f0 100644 --- a/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/core/ui/DefaultDesignFeatureToggles.kt @@ -11,4 +11,8 @@ class DefaultDesignFeatureToggles @Inject constructor( override val isRedesignEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.APP_REDESIGN_ENABLED) + + override val isWarningsRefactoringEnabled: Boolean + get() = isRedesignEnabled && + featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_14829_WARNINGS_REFACTORING_ENABLED) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt index 89c89aef25..7050229bd8 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt @@ -1,29 +1,89 @@ package com.tangem.tap.data +import androidx.datastore.core.DataStore import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp import com.tangem.domain.offramp.repository.OfframpRepository import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder +import com.tangem.tap.data.converter.PendingOfframpEntryConverter +import com.tangem.tap.data.model.PendingOfframpEntry import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import java.util.UUID +import java.util.concurrent.TimeUnit /** - * Default implementation of [OfframpRepository] + * Default implementation of [OfframpRepository]. * * @property sellService sell service for getting offramp URL + * @property pendingOfframpStore dedicated kotlinx-serialized store of app-initiated sells + * @property dispatchers coroutine dispatchers provider for IO operations */ internal class DefaultOfframpRepository( private val sellService: SellService, + private val pendingOfframpStore: DataStore>, + private val dispatchers: CoroutineDispatcherProvider, ) : OfframpRepository { + private val pendingOfframpConverter = PendingOfframpEntryConverter() + override fun getOfframpUrl( cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String, + requestId: String, ): String? { return sellService.getUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive, + requestId = requestId, ) } + + override suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String = + withContext(dispatchers.io) { + val requestId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + pendingOfframpStore.updateData { stored -> + stored.filterNotExpired(now) + PendingOfframpEntry( + requestId = requestId, + userWalletId = userWalletId.stringValue, + currencyId = currencyId, + createdAt = now, + ) + } + requestId + } + + override suspend fun consumePendingOfframp( + requestId: String, + userWalletId: UserWalletId, + currencyId: String, + ): PendingOfframp? = withContext(dispatchers.io) { + val now = System.currentTimeMillis() + var matched: PendingOfframpEntry? = null + pendingOfframpStore.updateData { stored -> + matched = stored.firstOrNull { entry -> + entry.requestId == requestId && + entry.userWalletId == userWalletId.stringValue && + entry.currencyId == currencyId && + now - entry.createdAt < EXPIRY_MS + } + // Remove only the fully-matched record (single-use); always prune expired ones. A request_id that + // matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it. + stored.filter { it != matched }.filterNotExpired(now) + } + matched?.let(pendingOfframpConverter::convert) + } + + private fun List.filterNotExpired(now: Long): List = + filter { now - it.createdAt < EXPIRY_MS } + + private companion object { + val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt index 46b35c3bce..85108219f0 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultTangemPayStorage.kt @@ -267,6 +267,19 @@ internal class DefaultTangemPayStorage @Inject constructor( appPreferencesStore.store(PreferencesKeys.getTangemPayOrderIdKey(customerWalletAddress), "") appPreferencesStore.store(PreferencesKeys.getTangemPayAddToWalletKey(customerWalletAddress), false) appPreferencesStore.store(PreferencesKeys.getTangemPayHideOnboardingKey(userWalletId), false) + // Clear the withdraw order hints together with the rest of the cache. + deleteActiveWithdrawOrder(userWalletId) + clearWithdrawOrders(userWalletId) + } + + private suspend fun clearWithdrawOrders(userWalletId: UserWalletId) { + appPreferencesStore.editData { prefs -> + val walletKey = createWithdrawOrderIdKey(userWalletId) + val currentMap = prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY]?.let(adapter::fromJson) + .orEmpty() + val updatedMap = currentMap - walletKey + prefs[PreferencesKeys.TANGEM_PAY_WITHDRAW_ORDERS_KEY] = adapter.toJson(updatedMap) + } } private fun createAuthTokensKey(address: String): String = "${AUTH_TOKENS_DEFAULT_KEY}_$address" diff --git a/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt b/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt new file mode 100644 index 0000000000..9be3bdb848 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.tap.data.converter + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp +import com.tangem.tap.data.model.PendingOfframpEntry +import com.tangem.utils.converter.Converter + +/** + * Converts a persisted [PendingOfframpEntry] into the domain [PendingOfframp]. + */ +internal class PendingOfframpEntryConverter : Converter { + + override fun convert(value: PendingOfframpEntry): PendingOfframp = PendingOfframp( + requestId = value.requestId, + userWalletId = UserWalletId(stringValue = value.userWalletId), + currencyId = value.currencyId, + createdAt = value.createdAt, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt b/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt new file mode 100644 index 0000000000..29304bba31 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt @@ -0,0 +1,23 @@ +package com.tangem.tap.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Persisted entry of an app-initiated sell (off-ramp) flow, stored in a dedicated kotlinx-serialized DataStore. + * + * [userWalletId] holds the [com.tangem.domain.models.wallet.UserWalletId.stringValue]. + * + * @see com.tangem.domain.offramp.model.PendingOfframp + */ +@Serializable +internal data class PendingOfframpEntry( + @SerialName("requestId") + val requestId: String, + @SerialName("userWalletId") + val userWalletId: String, + @SerialName("currencyId") + val currencyId: String, + @SerialName("createdAt") + val createdAt: Long, +) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 61da4b0ea5..37929d7d54 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -5,12 +5,10 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.domain.card.BuildConfig import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager -import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.visa.VisaCardScanHandler @@ -34,8 +32,6 @@ internal class TangemSdkManagerModule { visaCardActivationTaskFactory: VisaCardActivationTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, - dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, - blockchainToDeriveFinder: BlockchainToDeriveFinder, analyticsErrorHandler: AnalyticsErrorHandler, cardRepository: CardRepository, ): TangemSdkManager { @@ -49,8 +45,6 @@ internal class TangemSdkManagerModule { visaCardActivationTaskFactory = visaCardActivationTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, - dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, - blockchainToDeriveFinder = blockchainToDeriveFinder, analyticsErrorHandler = analyticsErrorHandler, cardRepository = cardRepository, ) diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 492904d18a..d8cb34aece 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di import android.content.Context +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.finisher.AppFinisher @@ -55,7 +56,10 @@ internal interface UtilsModule { @Provides @Singleton - fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher = - DefaultDeeplinkLauncher(context, urlOpener) + fun provideDeeplinkLauncher( + @ApplicationContext context: Context, + urlOpener: UrlOpener, + analyticsExceptionHandler: AnalyticsExceptionHandler, + ): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index f6dc626675..0086789a1c 100644 --- a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.data import com.tangem.blockchain.common.logging.BlockchainSDKLogger +import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.tap.common.log.TangemBlockchainSDKLogger import com.tangem.tap.common.log.TangemCardSDKLogger @@ -17,10 +18,14 @@ internal object TangemLoggingModule { @Provides @Singleton - fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer { + fun provideLoggingInitializer( + appLogsStore: AppLogsStore, + environmentConfig: EnvironmentConfig, + ): TangemLoggingInitializer { return TangemLoggingInitializer( appLogsStore = appLogsStore, tangemSdkLogger = TangemCardSDKLogger(appLogsStore), + environmentConfig = environmentConfig, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt new file mode 100644 index 0000000000..3fa8efefb9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -0,0 +1,48 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider +import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase +import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase +import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AddressBookDomainModule { + + @Provides + @Singleton + fun provideValidateContactAddressUseCase( + validateWalletAddressUseCase: ValidateWalletAddressUseCase, + getNetworkAddressesUseCase: GetNetworkAddressesUseCase, + ): ValidateContactAddressUseCase { + return ValidateContactAddressUseCase( + validateWalletAddressUseCase = validateWalletAddressUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + ) + } + + @Provides + @Singleton + fun provideVerifyAddressEntriesUseCase( + verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, + ): VerifyAddressEntriesUseCase { + return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) + } + + @Provides + @Singleton + fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher() + + @Provides + @Singleton + fun provideIsoTimestampProvider(): IsoTimestampProvider = DefaultIsoTimestampProvider() +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 45b884766c..cef5171d26 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -1,6 +1,8 @@ package com.tangem.tap.di.domain +import com.tangem.domain.card.BackupValidator import com.tangem.domain.card.DeleteSavedAccessCodesUseCase +import com.tangem.domain.card.IsWalletBackupProblematicUseCase import com.tangem.domain.card.ResetCardUseCase import com.tangem.domain.card.SetCardWasScannedUseCase import com.tangem.domain.card.repository.CardRepository @@ -32,6 +34,16 @@ internal object CardDomainModule { @Singleton fun provideIsDemoCardUseCase(): IsDemoCardUseCase = IsDemoCardUseCase(config = DemoConfig) + @Provides + @Singleton + fun provideBackupValidator(): BackupValidator = BackupValidator() + + @Provides + @Singleton + fun provideIsWalletBackupProblematicUseCase(backupValidator: BackupValidator): IsWalletBackupProblematicUseCase { + return IsWalletBackupProblematicUseCase(backupValidator = backupValidator) + } + @Provides @Singleton fun provideDerivePublicKeysUseCase(derivationsRepository: DerivationsRepository): DerivePublicKeysUseCase { diff --git a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt index 48bfb0b921..040f65f923 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/FeedbackDomainModule.kt @@ -3,6 +3,7 @@ package com.tangem.tap.di.domain import android.content.Context import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.repository.FeedbackRepository import dagger.Module @@ -36,4 +37,16 @@ internal object FeedbackDomainModule { fun provideSaveBlockchainErrorUseCase(feedbackRepository: FeedbackRepository): SaveBlockchainErrorUseCase { return SaveBlockchainErrorUseCase(feedbackRepository = feedbackRepository) } + + @Provides + @Singleton + fun provideSendBackupProblemEmailUseCase( + getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, + sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + ): SendBackupProblemEmailUseCase { + return SendBackupProblemEmailUseCase( + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt new file mode 100644 index 0000000000..baa62b68a1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt @@ -0,0 +1,56 @@ +package com.tangem.tap.di.domain + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.tangem.datasource.utils.KotlinxDataStoreSerializer +import com.tangem.domain.offramp.GetOfframpUrlUseCase +import com.tangem.domain.offramp.repository.OfframpRepository +import com.tangem.tap.data.DefaultOfframpRepository +import com.tangem.tap.data.model.PendingOfframpEntry +import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.serialization.builtins.ListSerializer + +@Module +@InstallIn(SingletonComponent::class) +internal object OfframpDomainModule { + + @Provides + @Singleton + fun providePendingOfframpStore( + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): DataStore> = DataStoreFactory.create( + serializer = KotlinxDataStoreSerializer( + defaultValue = emptyList(), + serializer = ListSerializer(PendingOfframpEntry.serializer()), + ), + produceFile = { context.dataStoreFile(fileName = "pending_offramps") }, + scope = appScope, + ) + + @Provides + @Singleton + fun provideOfframpRepository( + sellService: SellService, + pendingOfframpStore: DataStore>, + dispatchers: CoroutineDispatcherProvider, + ): OfframpRepository { + return DefaultOfframpRepository(sellService, pendingOfframpStore, dispatchers) + } + + @Provides + @Singleton + fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase { + return GetOfframpUrlUseCase(offrampRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index c372bdbd2b..4bc8f028bb 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -1,12 +1,8 @@ package com.tangem.tap.di.domain -import com.tangem.domain.offramp.GetOfframpUrlUseCase -import com.tangem.domain.offramp.repository.OfframpRepository import com.tangem.domain.onramp.* import com.tangem.domain.onramp.repositories.* import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.tap.data.DefaultOfframpRepository -import com.tangem.tap.network.exchangeServices.SellService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -270,16 +266,4 @@ internal object OnrampDomainModule { settingsRepository = settingsRepository, ) } - - @Provides - @Singleton - fun provideOfframpRepository(sellService: SellService): OfframpRepository { - return DefaultOfframpRepository(sellService) - } - - @Provides - @Singleton - fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase { - return GetOfframpUrlUseCase(offrampRepository) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt index 548ea7d9df..3ab23fe831 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/PushNotificationPreferencesDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.pushnotificationpreferences.ObserveWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase +import com.tangem.domain.pushnotificationpreferences.SetAllWalletPushNotificationPreferencesUseCase import com.tangem.domain.pushnotificationpreferences.UpdateWalletPushNotificationPreferenceUseCase import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository import dagger.Module @@ -37,4 +38,12 @@ internal object PushNotificationPreferencesDomainModule { ): UpdateWalletPushNotificationPreferenceUseCase { return UpdateWalletPushNotificationPreferenceUseCase(repository = repository) } + + @Provides + @Singleton + fun providesSetAllWalletPushNotificationPreferencesUseCase( + repository: WalletPushNotificationPreferencesRepository, + ): SetAllWalletPushNotificationPreferencesUseCase { + return SetAllWalletPushNotificationPreferencesUseCase(repository = repository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index f852d91fc1..ddceb2ede1 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -10,19 +11,20 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher -import com.tangem.domain.stories.StoriesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase +import com.tangem.domain.stories.StoriesRepository import com.tangem.domain.tokens.* import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository import com.tangem.domain.tokens.wallet.WalletBalanceFetcher +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -162,6 +164,8 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + virtualAccountStatusFetcher: VirtualAccountStatusFetcher, + virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, stakingIdFactory: StakingIdFactory, dispatchers: CoroutineDispatcherProvider, ): WalletBalanceFetcher { @@ -175,6 +179,8 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher, + virtualAccountStatusFetcher = virtualAccountStatusFetcher, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, stakingIdFactory = stakingIdFactory, dispatchers = dispatchers, ) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 1172d04238..57ab3eb8e5 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -69,6 +69,20 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideSignAndBroadcastPsbtUseCase( + cardSdkConfigRepository: CardSdkConfigRepository, + walletManagersFacade: WalletManagersFacade, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): SignAndBroadcastPsbtUseCase { + return SignAndBroadcastPsbtUseCase( + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + getHotTransactionSigner = tangemHotWalletSignerFactory::create, + ) + } + @Provides @Singleton fun provideAssociateAssetUseCase( @@ -236,6 +250,12 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideVerifySecp256k1MessagesUseCase(): VerifySecp256k1MessagesUseCase { + return VerifySecp256k1MessagesUseCase() + } + @Provides @Singleton fun provideCreateNFTTransferTransactionUseCase( diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index e16015f499..1340cbbf2e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule { ) } + @Provides + @Singleton + fun provideWrapYieldSwapCallDataWithUpgradeUseCase( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): WrapYieldSwapCallDataWithUpgradeUseCase { + return WrapYieldSwapCallDataWithUpgradeUseCase( + yieldSupplyTransactionRepository = yieldSupplyTransactionRepository, + ) + } + @Provides @Singleton fun provideYieldSupplyGetProtocolBalanceUseCase( diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 797c3ce6da..4ed0eef964 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -27,7 +27,6 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId @@ -58,6 +57,7 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask import com.tangem.tap.domain.twins.FinalizeTwinTask import com.tangem.tap.domain.visa.VisaCardScanHandler +import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -73,8 +73,6 @@ internal class DefaultTangemSdkManager( private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, - private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, - private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val analyticsErrorHandler: AnalyticsErrorHandler, private val cardRepository: CardRepository, ) : TangemSdkManager { @@ -145,12 +143,10 @@ internal class DefaultTangemSdkManager( runTaskAsyncReturnOnMain( runnable = ScanProductTask( card = null, - blockchainToDeriveFinder = blockchainToDeriveFinder, allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository, visaCardScanHandler = visaCardScanHandler, visaCoroutineScope = this, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, - isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, onboardingV2FeatureToggles = onboardingV2FeatureToggles, cardRepository = cardRepository, ), @@ -242,6 +238,7 @@ internal class DefaultTangemSdkManager( Analytics.send(event = analyticsEvent.withParams(params.toMap())) } .doOnFailure { tangemError -> + TangemLogger.e("scanProduct failed: code=${tangemError.code}, message=${tangemError.customMessage}") (tangemError as? TangemSdkError)?.let { error -> Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) } @@ -470,7 +467,6 @@ internal class DefaultTangemSdkManager( runnable = FinalizeTwinTask( twinPublicKey = secondCardPublicKey, issuerKeys = issuerKeyPair, - isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, cardRepository = cardRepository, ), cardId = cardId, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index ed23617f90..ecdb384b6d 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -36,6 +36,9 @@ object MockProvider { MockOption("Backup Wallet") { BackupWalletMockContent }, MockOption("Dev Wallet") { DevWalletMockContent }, MockOption("Firmware 4.12") { Firmware412MockContent }, + MockOption("V3 Multicurrency") { V3MockContent }, + MockOption("Single Currency") { SingleCurrencyMockContent }, + MockOption("Start2Coin") { S2CMockContent }, MockOption("Cobrand") { showCobrandConfigDialog(it) }, ) @@ -99,6 +102,7 @@ object MockProvider { ProductType.Note -> NoteMockContent ProductType.Ring -> RingMockContent ProductType.Twins -> TwinsMockContent + ProductType.Start2Coin -> S2CMockContent else -> TODO() } } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/S2CMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/S2CMockContent.kt new file mode 100644 index 0000000000..27f873c0f5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/S2CMockContent.kt @@ -0,0 +1,112 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +// Start2Coin (S2C): issuer "Start2Coin" trips isStart2Coin → single currency, WalletConnect hidden. +object S2CMockContent : MockContent { + + override val cardDto = CardDTO( + cardId = "1198724260000000", + batchId = "CD04", + cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119), + firmwareVersion = CardDTO.FirmwareVersion( + major = 4, + minor = 52, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1671494400000), + signature = byteArrayOf(), + ), + issuer = CardDTO.Issuer( + name = "Start2Coin", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 1, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = false, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = false, + isHDWalletAllowed = false, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf(EllipticCurve.Secp256k1), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, 106, 7, -77, -109, 39, 3, 80, 99, 31, 50, -40, -113, -81, -76, -21, 123, -60, 0, -121, -56, 126, 2, 123, 111, 80, 47, -37, 40, 119, -22, 33, 32), + chainCode = byteArrayOf(), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = true), + totalSignedHashes = 1, + remainingSignatures = 999999, + index = 0, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Start2Coin, + walletData = WalletData(blockchain = "BTC", token = null), + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap()) + + override val extendedPublicKey + get() = error("Available only for wallet+?") + + override val successResponse = SuccessResponse(cardId = "1198724260000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/SingleCurrencyMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/SingleCurrencyMockContent.kt new file mode 100644 index 0000000000..e6d159d584 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/SingleCurrencyMockContent.kt @@ -0,0 +1,112 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +// Single-currency card (XLM/ed25519, pre-4.0 firmware) → isMultiwalletAllowed false → WalletConnect hidden. +object SingleCurrencyMockContent : MockContent { + + override val cardDto = CardDTO( + cardId = "0052000000000000", + batchId = "0052", + cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119), + firmwareVersion = CardDTO.FirmwareVersion( + major = 3, + minor = 5, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 1, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = false, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = false, + isHDWalletAllowed = false, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = false), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf(EllipticCurve.Ed25519), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 0, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = WalletData(blockchain = "XLM", token = null), + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap()) + + override val extendedPublicKey + get() = error("Available only for wallet+?") + + override val successResponse = SuccessResponse(cardId = "0052000000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/V3MockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/V3MockContent.kt new file mode 100644 index 0000000000..4fe7912d33 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/V3MockContent.kt @@ -0,0 +1,112 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +// v3 multicurrency card: single secp256k1 wallet on pre-4.0 firmware → isMultiwalletAllowed via the secp branch. +object V3MockContent : MockContent { + + override val cardDto = CardDTO( + cardId = "0045000000000000", + batchId = "0045", + cardPublicKey = byteArrayOf(2, 102, 3, -106, -14, -87, -118, 120, 10, 93, 17, 55, 26, -44, 5, 115, 88, 35, 49, -88, -69, 116, 0, -72, -27, 57, 50, -55, 80, -16, 39, -70, 119), + firmwareVersion = CardDTO.FirmwareVersion( + major = 3, + minor = 5, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1649635200000), + signature = byteArrayOf(), + ), + issuer = CardDTO.Issuer( + name = "TANGEM AG", + publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 1, + isSettingAccessCodeAllowed = false, + isSettingPasscodeAllowed = false, + isResettingUserCodesAllowed = true, + isLinkedTerminalEnabled = true, + isBackupAllowed = false, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = false, + isHDWalletAllowed = false, + isKeysImportAllowed = false, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = false), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = false, + isPasscodeSet = false, + supportedCurves = listOf(EllipticCurve.Secp256k1), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(2, -27, -117, 23, 68, -3, 21, -109, 18, -67, -107, -42, -44, -16, -127, -53, 46, -109, -46, -51, 89, 119, 79, 111, 78, 62, -125, 72, 109, 8, 45, 59, 117), + chainCode = byteArrayOf(), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 0, + hasBackup = false, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.NoBackup, + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet, + walletData = WalletData(blockchain = "BTC", token = null), + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse(entries = emptyMap()) + + override val extendedPublicKey + get() = error("Available only for wallet+?") + + override val successResponse = SuccessResponse(cardId = "0045000000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = error("Available only for Wallet 2") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index e6d872be54..a8c47499e7 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -132,6 +132,10 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), + DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey( publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -172,7 +176,32 @@ object WalletMockContent : MockContent { remainingSignatures = null, index = 1, hasBackup = false, - derivedKeys = emptyMap(), + derivedKeys = mapOf( + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1) + publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43), + chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40), + ), + DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2) + publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111), + chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126), + ), + DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + ), extendedPublicKey = ExtendedPublicKey( publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), @@ -218,6 +247,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/84'/0'/1'/0/0") to ExtendedPublicKey( // btc (account 2) + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -274,6 +310,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos (account 1) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/118'/1'/0/0") to ExtendedPublicKey( // Cosmos (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -281,6 +331,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/144'/1'/0/0") to ExtendedPublicKey( // XRP (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/1729'/0'/0'") to ExtendedPublicKey( // Tezos publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -380,6 +437,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -394,6 +458,34 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1) + publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43), + chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2) + publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111), + chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), @@ -548,6 +640,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), ), diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/BlockchainToDeriveFinder.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/BlockchainToDeriveFinder.kt deleted file mode 100644 index 9118279751..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/BlockchainToDeriveFinder.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.tangem.tap.domain.tasks.product - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.derivation.DerivationStyle -import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.data.common.account.WalletAccountsFetcher -import com.tangem.data.wallets.derivations.BlockchainToDerive -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.domain.wallets.derivations.derivationStyleProvider -import com.tangem.tap.features.demo.DemoHelper -import javax.inject.Inject - -/** - * Finder of blockchains to derive. - * Returns only saved, default or demo blockchains without any additional logic - * (no cardano/ethereum additions or unnecessary blockchain removals). - */ -class BlockchainToDeriveFinder @Inject constructor( - private val walletAccountsFetcher: WalletAccountsFetcher, -) { - - suspend fun find(card: CardDTO): Set { - if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet() - val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet() - - val derivationStyle = card.derivationStyleProvider.getDerivationStyle() - - val blockchains = getBlockchains(userWalletId).ifEmpty { - if (DemoHelper.isDemoCardId(card.cardId)) { - getDemoBlockchains(derivationStyle, card.cardId) - } else { - getDefaultBlockchains(derivationStyle) - } - } - - return blockchains - } - - private suspend fun getBlockchains(userWalletId: UserWalletId): Set { - return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty() - .flatMap { accountDTO -> - accountDTO.tokens.orEmpty() - .filter { it.contractAddress == null } - } - .mapNotNull { coin -> - val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null - val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null - - BlockchainToDerive(blockchain, derivationPath) - } - .toSet() - } - - private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set { - return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle) - } - - private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set { - val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum) - return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle) - } - - private fun Set.mapToBlockchainsWithDerivations( - derivationStyle: DerivationStyle?, - ): Set { - return mapNotNullTo(hashSetOf()) { blockchain -> - val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null - BlockchainToDerive(blockchain, derivationPath) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 78d3151844..7476e9dde4 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -12,8 +12,6 @@ import com.tangem.common.extensions.* import com.tangem.common.tlv.Tlv import com.tangem.common.tlv.TlvDecoder import com.tangem.crypto.CryptoUtils -import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.data.wallets.derivations.MissedDerivationsFinder import com.tangem.domain.card.common.TapWorkarounds.isExcluded import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin @@ -32,25 +30,21 @@ import com.tangem.operations.PreflightReadMode import com.tangem.operations.ScanTask import com.tangem.operations.backup.PrimaryCard import com.tangem.operations.backup.StartPrimaryCardLinkingTask -import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask import com.tangem.operations.files.ReadFilesTask import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand import com.tangem.tap.domain.TapSdkError import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.mainScope -import com.tangem.tap.scope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class ScanProductTask( private val card: Card?, - private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, private val visaCardScanHandler: VisaCardScanHandler?, private val visaCoroutineScope: CoroutineScope?, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val shouldCheckIsAlreadyActivated: Boolean, - private val isDynamicAddressesEnabled: Boolean, private val cardRepository: CardRepository, override val allowsRequestAccessCodeFromRepository: Boolean = false, ) : CardSessionRunnable { @@ -80,8 +74,6 @@ internal class ScanProductTask( session = session, cardDto = cardDto, scanWalletProcessor = ScanWalletProcessor( - blockchainToDeriveFinder = blockchainToDeriveFinder, - isDynamicAddressesEnabled = isDynamicAddressesEnabled, cardRepository = cardRepository, ), callback = callback, @@ -92,8 +84,6 @@ internal class ScanProductTask( val commandProcessor = when { cardDto.isTangemTwins -> ScanTwinProcessor() else -> ScanWalletProcessor( - blockchainToDeriveFinder = blockchainToDeriveFinder, - isDynamicAddressesEnabled = isDynamicAddressesEnabled, cardRepository = cardRepository, ) } @@ -102,8 +92,8 @@ internal class ScanProductTask( is CompletionResult.Success -> ScanTask().run(session) { scanTaskResult -> when (scanTaskResult) { is CompletionResult.Success -> { - // it needed because processorResult.data.card doesn't contains attestation result - // and CardWallet.derivedKeys + // It's needed because processorResult.data.card doesn't contain the attestation + // result or the existing CardWallet.derivedKeys read from the card. val processorScanResponseWithNewCard = processorResult.data.copy( card = CardDTO(scanTaskResult.data), ) @@ -176,8 +166,6 @@ internal class ScanProductTask( } private class ScanWalletProcessor( - private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, - private val isDynamicAddressesEnabled: Boolean, private val cardRepository: CardRepository, ) : ProductCommandProcessor { @@ -281,48 +269,34 @@ private class ScanWalletProcessor( when (linkingResult) { is CompletionResult.Success -> { primaryCard = linkingResult.data - deriveKeysIfNeeded(card, session, callback) + completeScan(card, session, callback) } is CompletionResult.Failure -> { - deriveKeysIfNeeded(card, session, callback) + completeScan(card, session, callback) } } } } else { - deriveKeysIfNeeded(card, session, callback) + completeScan(card, session, callback) } } } - private fun deriveKeysIfNeeded( + // Keys are no longer derived during scan: default derivations are created up front in + // CreateProductWalletTask, and derivations for additional tokens are handled by + // DefaultColdMapDerivationsRepository when the user explicitly adds a token. + private fun completeScan( card: CardDTO, session: CardSession, callback: (result: CompletionResult) -> Unit, ) { - val productType = getWalletProductType(card) - scope.launch { - val scanResponse = ScanResponse( - card = card, - productType = productType, - walletData = session.environment.walletData, - primaryCard = primaryCard, - ) - val derivations = collectDerivations(card, scanResponse) - if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) { - callback(CompletionResult.Success(scanResponse)) - return@launch - } - - DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result -> - when (result) { - is CompletionResult.Success -> { - val response = scanResponse.copy(derivedKeys = result.data.entries) - callback(CompletionResult.Success(response)) - } - is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error)) - } - } - } + val scanResponse = ScanResponse( + card = card, + productType = getWalletProductType(card), + walletData = session.environment.walletData, + primaryCard = primaryCard, + ) + callback(CompletionResult.Success(scanResponse)) } private fun getWalletProductType(card: CardDTO): ProductType { @@ -334,17 +308,6 @@ private class ScanWalletProcessor( else -> ProductType.Wallet } } - - private suspend fun collectDerivations( - card: CardDTO, - scanResponse: ScanResponse, - ): Map> { - val blockchains = blockchainToDeriveFinder - ?.find(card) - ?: return emptyMap() - - return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains) - } } @Suppress("MagicNumber") diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index c2c044dd95..cba0726923 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -13,7 +13,6 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask class FinalizeTwinTask( private val twinPublicKey: ByteArray, private val issuerKeys: KeyPair, - private val isDynamicAddressesEnabled: Boolean, private val cardRepository: CardRepository, ) : CardSessionRunnable { @@ -31,11 +30,9 @@ class FinalizeTwinTask( is CompletionResult.Success -> ScanProductTask( card = readResult.data, - blockchainToDeriveFinder = null, visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, - isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, cardRepository = cardRepository, ).run(session, callback) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 411869a6cd..5c6d10635a 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -242,6 +242,10 @@ internal class DefaultUserWalletsListRepository( setSelectedUserWallet(newSelected) } userWallets.value = updatedWallets + + if (updatedWallets?.isEmpty() == true) { + trackingContextProxy.eraseContext() + } } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -325,11 +329,7 @@ internal class DefaultUserWalletsListRepository( sensitiveInformationRepository.getAll(listOf(encryptionKey)) .doOnSuccess { sensitiveInfo -> updateWallets { wallets -> - // It is necessary to update derivations because when scanning we obtain the missing keys - wallets?.updateWith( - walletIdToSensitiveInformation = sensitiveInfo, - walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys), - ) + wallets?.updateWith(walletIdToSensitiveInformation = sensitiveInfo) } trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt index 51aefb10ab..4e14c06473 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt @@ -1,10 +1,8 @@ package com.tangem.tap.domain.userWalletList.utils -import com.tangem.domain.models.scan.KeyWalletPublicKey import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation @@ -74,10 +72,7 @@ internal fun List.toUserWallets(): List return this.map { it.toUserWallet() } } -internal fun UserWallet.updateWith( - sensitiveInformation: UserWalletSensitiveInformation, - derivedKeys: Map?, -): UserWallet { +internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet { return when (this) { is UserWallet.Cold -> { copy( @@ -85,7 +80,6 @@ internal fun UserWallet.updateWith( card = scanResponse.card.copy( wallets = requireNotNull(sensitiveInformation.wallets), ), - derivedKeys = derivedKeys ?: scanResponse.derivedKeys, // visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus, ), ) @@ -98,17 +92,14 @@ internal fun UserWallet.updateWith( internal fun List.updateWith( walletIdToSensitiveInformation: Map, - walletIdToDerivedKeys: Map>? = null, ): List { return if (walletIdToSensitiveInformation.isEmpty()) { this } else { this.map { wallet -> val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId] - val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId) - if (sensitiveInformation != null) { - wallet.updateWith(sensitiveInformation, derivedKeys) + wallet.updateWith(sensitiveInformation) } else { wallet } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appcurrency/AppCurrencySelectorScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appcurrency/AppCurrencySelectorScreen.kt index 06eda3e12c..34627ab203 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appcurrency/AppCurrencySelectorScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appcurrency/AppCurrencySelectorScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -29,6 +30,7 @@ import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.AppCurrencySelectorScreenTestTags import com.tangem.tap.features.details.ui.appcurrency.AppCurrencySelectorState.Currency import com.tangem.wallet.R import kotlinx.collections.immutable.ImmutableList @@ -123,7 +125,9 @@ private fun TopBar( when (state) { is AppCurrencySelectorState.Content -> { IconButton( - modifier = Modifier.size(TangemTheme.dimens.size32), + modifier = Modifier + .size(TangemTheme.dimens.size32) + .testTag(AppCurrencySelectorScreenTestTags.TOP_BAR_ACTION_BUTTON), onClick = state.onTopBarActionClick, ) { val iconResId = when (state) { @@ -157,7 +161,8 @@ private fun SearchBar(onInputChange: (String) -> Unit, modifier: Modifier = Modi TextField( modifier = modifier - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .testTag(AppCurrencySelectorScreenTestTags.SEARCH_FIELD), value = input, onValueChange = { input = it }, singleLine = true, @@ -218,7 +223,7 @@ private fun CurrenciesList( ) { val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } LazyColumn( - modifier = modifier, + modifier = modifier.testTag(AppCurrencySelectorScreenTestTags.LAZY_LIST), state = listState, contentPadding = PaddingValues(bottom = bottomBarHeight), ) { @@ -246,6 +251,7 @@ private fun CurrencyItem(name: String, isSelected: Boolean, onClick: () -> Unit, Row( modifier = modifier + .testTag(AppCurrencySelectorScreenTestTags.CURRENCY_ITEM) .clickable( interactionSource = interactionSource, indication = LocalIndication.current, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt index 8ffb314316..a7d5a8890a 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt @@ -10,11 +10,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.AppSettingsScreenTestTags import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item import com.tangem.tap.features.details.ui.appsettings.components.* @@ -55,7 +57,15 @@ private fun AppSettings(state: AppSettingsScreenState.Content) { item = item, ) is Item.Button -> SettingsButtonItem( - modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8), + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing8) + .then( + if (item.id == AppSettingsItemsFactory.ID_SELECT_APP_CURRENCY_BUTTON) { + Modifier.testTag(AppSettingsScreenTestTags.CURRENCY_BUTTON) + } else { + Modifier + }, + ), item = item, ) is Item.Switch -> SettingsSwitchItem( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt index 3178d30e40..026de703ee 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt @@ -105,7 +105,7 @@ internal class CardSettingsModel @Inject constructor( private fun scanCard() = modelScope.launch { scanCardProcessor.scan( - analyticsSource = com.tangem.core.analytics.models.AnalyticsParam.ScreensSources.Settings, + analyticsSource = com.tangem.core.analytics.models.AnalyticsParam.ScreensSources.CardSettings, shouldCheckIsAlreadyActivated = false, allowsRequestAccessCodeFromRepository = true, ) diff --git a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt index 237bc5ebd3..9427725b3f 100644 --- a/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt +++ b/app/src/main/java/com/tangem/tap/features/hot/TangemHotSDKProxy.kt @@ -1,8 +1,11 @@ package com.tangem.tap.features.hot +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.crypto.bip39.Mnemonic import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.* +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -16,7 +19,9 @@ import javax.inject.Singleton * Be aware that the SDK is initialized on activity creation, so it may not be available immediately. */ @Singleton -class TangemHotSDKProxy @Inject constructor() : TangemHotSdk { +class TangemHotSDKProxy @Inject constructor( + private val analyticsExceptionHandler: AnalyticsExceptionHandler, +) : TangemHotSdk { val sdkState = MutableStateFlow(null) @@ -56,8 +61,15 @@ class TangemHotSDKProxy @Inject constructor() : TangemHotSdk { callSdk { signHashes(unlockHotWallet, dataToSign) } private suspend fun callSdk(block: suspend TangemHotSdk.() -> T): T { - return withTimeout(timeMillis = 1000) { - sdkState.filterNotNull().first() - }.block() + return try { + withTimeout(timeMillis = 1000) { + sdkState.filterNotNull().first() + }.block() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(exception = e)) + throw e + } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 34881a8063..e8c71b07bf 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -27,13 +27,13 @@ import com.tangem.domain.notifications.SendPushTokenUseCase import com.tangem.domain.notifications.models.ApplicationId import com.tangem.domain.notifications.models.NotificationsError import com.tangem.domain.onramp.FetchHotCryptoUseCase -import com.tangem.domain.stories.GetStoryContentUseCase -import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.quotes.multi.MultiQuoteUpdater import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingOptionsUseCase +import com.tangem.domain.stories.GetStoryContentUseCase +import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase @@ -268,8 +268,12 @@ internal class MainViewModel @Inject constructor( onShownBalanceToastAction() } }, + startIconId = if (settings.isBalanceHidden) { + R.drawable.ic_eye_off_outline_24 + } else { + R.drawable.ic_eye_outline_24 + }, ) - messageSender.send(message) } } diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index ebbc510654..94abc606cb 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -60,6 +60,7 @@ internal class DefaultAuthProvider( override fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend { return ProviderSuspend { when (apiEnvironment.invoke()) { + ApiEnvironment.MOCK, ApiEnvironment.DEV, -> environmentConfig.gaslessTxApiKeyDev ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt index 4075e7dd57..97d7caa711 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt @@ -20,5 +20,6 @@ interface SellService { fiatCurrencyName: String, walletAddress: String, isDarkTheme: Boolean, + requestId: String, ): String? } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt index 8807a899e6..10f1730935 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt @@ -138,6 +138,7 @@ class MoonPayService( fiatCurrencyName: String, walletAddress: String, isDarkTheme: Boolean, + requestId: String, ): String? { val blockchain = cryptoCurrency.network.toBlockchain() if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl() @@ -165,7 +166,12 @@ class MoonPayService( .appendQueryParameter("apiKey", apiKey) .appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase()) .appendQueryParameter("refundWalletAddress", walletAddress) - .appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}") + // request_id authenticates the returning redirect_sell deeplink. It must be added to + // redirectURL BEFORE createSignature below so it is covered by the MoonPay URL signature. + .appendQueryParameter( + "redirectURL", + "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}&request_id=$requestId", + ) if (isDarkTheme) uri.appendQueryParameter("theme", "dark") diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index 14f5871bbc..762f6b21a6 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -4,12 +4,7 @@ import android.app.Activity import android.os.Build import android.os.Bundle import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -32,11 +27,7 @@ import com.tangem.core.ui.components.haze.ProvideHaze import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost import com.tangem.core.ui.message.EventMessageEffect -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.LocalRootBackgroundColor -import com.tangem.core.ui.res.LocalSnackbarHostState -import com.tangem.core.ui.res.LocalTopSnackbarHostState -import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.* import com.tangem.core.ui.security.ProvideSecureFlagController import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.transitions.RoutingTransitionAnimationFactory @@ -124,20 +115,34 @@ private fun childrenAnimation( backHandler: BackHandler, onBack: () -> Unit, ): StackAnimation { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val routeAnimation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { predictiveBackAnimation( backHandler = backHandler, onBack = onBack, selector = { backEvent, _, _ -> androidPredictiveBackAnimatable(backEvent) }, - fallbackAnimation = stackAnimation { - RoutingTransitionAnimationFactory.create(it.configuration) + fallbackAnimation = stackAnimation { child -> + RoutingTransitionAnimationFactory.create(child.configuration) }, ) } else { - stackAnimation { - RoutingTransitionAnimationFactory.create(it.configuration) + stackAnimation { child -> + RoutingTransitionAnimationFactory.create(child.configuration) } } + + return skipAnimationWhileInitial(routeAnimation) +} + +private fun skipAnimationWhileInitial( + delegate: StackAnimation, +): StackAnimation = StackAnimation { stack, animModifier, content -> + if (stack.active.configuration is AppRoute.Initial) { + Box(modifier = animModifier) { + content(stack.active) + } + } else { + delegate(stack, animModifier, content) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 6be9860e0c..6e5488d67b 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -214,33 +214,18 @@ internal class DefaultRoutingComponent @AssistedInject constructor( FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, ) TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled") - if (isHotWalletOnboardingEnabled) { + val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) { val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) { appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}") if (tangemPayHotWalletOnboardingDeepLink != null) { - val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding - val shouldShowTos = !cardRepository.isTangemTOSAccepted() - val route = if (shouldShowTos) "Disclaimer" else "HotWalletOnboarding" - TangemLogger.i("[TangemPay][HWO] TOS accepted=${!shouldShowTos}, navigating to $route") - return if (shouldShowTos) { - AppRoute.Disclaimer(isTosAccepted = false, nextRoute = hotWalletRoute) - } else { - hotWalletRoute - } + AppRoute.TangemPayHotWalletOnboarding + } else { + getDefaultRoute() } - } - - val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( - FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, - ) - // Referral users skip the Home stories screen and land directly on the - // mobile wallet creation flow. - val afterEmptyRoute: AppRoute = if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { - AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) } else { - AppRoute.Home(launchMode = launchMode) + getDefaultRoute() } val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() @@ -261,6 +246,19 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } } + private suspend fun getDefaultRoute(): AppRoute { + val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( + FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, + ) + // Referral users skip the Home stories screen and land directly on the + // mobile wallet creation flow. + return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { + AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) + } else { + AppRoute.Home(launchMode = launchMode) + } + } + @Composable override fun Content(modifier: Modifier) { RootContent( @@ -397,7 +395,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor( componentScope.launch(dispatchers.main) { backupServiceHolder.backupService.get()?.discardSavedBackup() val unfinishedBackup = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch - cardRepository.finishCardActivation(unfinishedBackup.card.cardId) + cardRepository.finishCardActivation(cardId = unfinishedBackup.card.cardId, hasBackupError = true) onboardingRepository.clearUnfinishedFinalizeOnboarding() analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished()) } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index b11bbff6bb..c43ead18f2 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -9,9 +9,9 @@ import com.tangem.feature.stories.api.StoriesComponent import com.tangem.feature.usedesk.api.UsedeskComponent import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.features.account.AccountCreateEditComponent -import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.ArchivedAccountListComponent +import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.createwalletselection.CreateWalletSelectionComponent import com.tangem.features.createwalletstart.CreateWalletStartComponent import com.tangem.features.details.component.DetailsComponent @@ -21,6 +21,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent +import com.tangem.features.survey.SurveyComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode @@ -31,9 +32,10 @@ import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacksStub import com.tangem.features.pushnotifications.api.PushNotificationsParams -import com.tangem.features.send.v2.api.NFTSendComponent -import com.tangem.features.send.v2.api.SendComponent -import com.tangem.features.send.v2.api.SendEntryPointComponent +import com.tangem.features.pushnotificationsettings.component.PushNotificationSettingsComponent +import com.tangem.features.send.api.NFTSendComponent +import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.SendEntryPointComponent import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent @@ -86,6 +88,7 @@ internal class ChildFactory @Inject constructor( private val resetCardComponentFactory: ResetCardComponent.Factory, private val referralComponentFactory: ReferralComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, + private val pushNotificationSettingsComponentFactory: PushNotificationSettingsComponent.Factory, private val walletComponentFactory: WalletEntryComponent.Factory, private val sendComponentFactoryV2: SendComponent.Factory, private val redesignedWalletConnectComponentFactory: WalletConnectEntryComponent.Factory, @@ -112,9 +115,10 @@ internal class ChildFactory @Inject constructor( private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, + private val surveyComponentFactory: SurveyComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, - private val addFundsComponentFactory: AddFundsComponent.Factory, + private val addressBookComponentFactory: AddressBookComponent.Factory, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -170,6 +174,13 @@ internal class ChildFactory @Inject constructor( componentFactory = walletSettingsComponentFactory, ) } + is AppRoute.PushNotificationSettings -> { + createComponentChild( + context = context, + params = PushNotificationSettingsComponent.Params(route.userWalletId), + componentFactory = pushNotificationSettingsComponentFactory, + ) + } is AppRoute.WalletBackup -> { createComponentChild( context = context, @@ -216,6 +227,7 @@ internal class ChildFactory @Inject constructor( userWalletId = route.userWalletId, cryptoCurrency = route.currency, source = route.source, + initialFiatAmount = route.initialFiatAmount, ), componentFactory = onrampComponentFactory, ) @@ -234,13 +246,6 @@ internal class ChildFactory @Inject constructor( componentFactory = buyCryptoComponentFactory, ) } - is AppRoute.AddFunds -> { - createComponentChild( - context = context, - params = AddFundsComponent.Params(userWalletId = route.userWalletId), - componentFactory = addFundsComponentFactory, - ) - } is AppRoute.SellCrypto -> { createComponentChild( context = context, @@ -321,10 +326,11 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SwapComponent.Params( - cryptoCurrency = route.cryptoCurrency, + fromCryptoCurrency = route.fromCryptoCurrency, + toCryptoCurrency = route.toCryptoCurrency, userWalletId = route.userWalletId, screenSource = route.screenSource, - currencyPosition = when (route.currencyPosition) { + fromCurrencyPosition = when (route.fromCurrencyPosition) { AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY @@ -533,7 +539,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.CreateHardwareWallet -> { createComponentChild( context = context, - params = Unit, + params = CreateHardwareWalletComponent.Params(source = route.source), componentFactory = createHardwareWalletComponentFactory, ) } @@ -622,6 +628,7 @@ internal class ChildFactory @Inject constructor( params = SendEntryPointComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.currency, + shouldStartWithSwap = route.shouldStartWithSwap, ), componentFactory = sendEntryPointComponentFactory, ) @@ -702,6 +709,13 @@ internal class ChildFactory @Inject constructor( componentFactory = kycComponentFactory, ) } + is AppRoute.Survey -> { + createComponentChild( + context = context, + params = SurveyComponent.Params(token = route.token, displayId = route.displayId), + componentFactory = surveyComponentFactory, + ) + } is AppRoute.YieldSupplyEntry -> { createComponentChild( context = context, @@ -742,6 +756,13 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.AddressBook -> { + createComponentChild( + context = context, + params = AddressBookComponent.Params(route.predefinedAddress), + componentFactory = addressBookComponentFactory, + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 72fdba54ab..378e230a37 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -17,8 +17,9 @@ import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler -import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler +import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler +import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler @@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor( private val newsDeepLink: NewsDeepLinkHandler.Factory, private val earnDeepLink: EarnDeepLinkHandler.Factory, private val yieldDeepLink: YieldDeepLinkHandler.Factory, + private val surveyDeepLink: SurveyDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 6ec92245ac..fb2a9642e9 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -3,6 +3,7 @@