diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 000000000..8d30dab4f --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,47 @@ +name: "Smoke test" + +# UAT smoke suite: boots the quickstart stack (full API + regtest lightning) +# and runs test/flash/smoke against it over HTTP — the automated slice of the +# integrated UAT matrix. See test/flash/smoke/README.md for coverage. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + smoke: + name: UAT smoke suite + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v3 + - uses: carvel-dev/setup-action@v1 + with: + only: ytt, vendir + - uses: actions/setup-node@v3 + with: + node-version: 24 + cache: "yarn" + - run: yarn install --frozen-lockfile + - name: Boot quickstart stack + run: | + cd quickstart + ./bin/bump-galoy-image-digest.sh local + ./bin/bump-mongodb-migrate-image-digest.sh local + make re-render + source .envrc + docker compose up -d --build + ./bin/quickstart.sh + ./bin/init-onchain.sh + ./bin/init-lightning.sh + # The quickstart stack mocks IBEX, so balances/invoices/payments don't + # resolve — SMOKE_BACKEND_FULL stays false and those specs skip. This + # tier still covers API health, auth/session, identity, validation, and + # feature-flag/resolver-crash regressions. Run the full tier against a + # provisioned environment (TEST) with SMOKE_BACKEND_FULL=true. + - name: Run smoke suite (CI tier) + run: yarn test:smoke + - name: Dump API logs on failure + if: failure() + run: docker logs quickstart-flash-1 --tail 200 || true diff --git a/Makefile b/Makefile index f66b09457..288784904 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,24 @@ integration: reset-deps-integration reset-integration: reset-deps-integration integration +# UAT smoke suite (test/flash/smoke) — black-box GraphQL tests against a +# running stack. `make smoke-env-up` boots the quickstart stack (requires +# ytt/vendir + docker); `make smoke` runs the suite against it. +smoke-env-up: + cd quickstart && \ + ./bin/bump-galoy-image-digest.sh local && \ + $(MAKE) re-render && \ + . ./.envrc && \ + docker compose up -d --build && \ + ./bin/quickstart.sh && \ + ./bin/init-lightning.sh + +smoke: + SMOKE_DOCKER_HELPERS=$${SMOKE_DOCKER_HELPERS:-true} \ + $(BIN_DIR)/jest --config ./test/flash/smoke/jest.config.js --runInBand --verbose + +smoke-all: smoke-env-up smoke + bats: yarn build && \ if [ -d test/bats ]; then bats -t test/bats; else echo "No test/bats suite found; skipping"; fi diff --git a/package.json b/package.json index 0e7d578f3..d32ae86de 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "daily-notif": "yarn build && node lib/servers/daily-balance-notification.js", "test": "export JEST_JUNIT_OUTPUT_DIR=\"./artifacts\" && jest --ci --reporters=default --reporters=jest-junit", "test:unit": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/unit/jest.config.js --bail --verbose $TEST", + "test:smoke": "jest --config ./test/flash/smoke/jest.config.js --runInBand --verbose $TEST", "test:legacy-integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/legacy-integration/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", "test:integration": ". ./.env && LOGLEVEL=warn jest --config ./test/flash/integration/jest.config.js --bail --runInBand --verbose $TEST", "test:bridge-sandbox-e2e": ". ./.env && { [ ! -f ./.env.local ] || . ./.env.local; } && RUN_BRIDGE_SANDBOX_E2E=true LOGLEVEL=warn jest --config ./test/flash/bridge-sandbox-e2e/jest.config.js --bail --runInBand --verbose $TEST | yarn pino-pretty -c -l", diff --git a/test/flash/smoke/README.md b/test/flash/smoke/README.md new file mode 100644 index 000000000..ac683adeb --- /dev/null +++ b/test/flash/smoke/README.md @@ -0,0 +1,102 @@ +# UAT Smoke Suite + +Black-box smoke tests for the integrated UAT matrix. Everything runs through +the **public GraphQL API over HTTP** — no app imports, no database access — so +the same suite targets the local quickstart stack, CI, or a deployed +environment (TEST) after a release. + +## Running + +Local, against the quickstart stack (boots docker, funds via lnd-outside): + +```bash +make smoke # boots quickstart + runs the full suite +# or, with a stack already running: +SMOKE_DOCKER_HELPERS=true yarn test:smoke +``` + +Against a deployed environment (read-only-ish: no docker funding; payments +only if the accounts are pre-funded): + +```bash +SMOKE_ENDPOINT=https://api.test.flashapp.me/graphql \ +SMOKE_PHONE_A=+1876XXXXXXX SMOKE_PHONE_B=+1876YYYYYYY \ +SMOKE_CODE= \ +SMOKE_ALLOW_PAYMENTS=false \ +yarn test:smoke +``` + +Set `SMOKE_EXPECT_FLAGS_OFF=false` once bridge/topup/cashout are enabled in the +target environment — the `topupEnabled` flag spec then asserts a boolean +instead of asserting `false`. + +## Two tiers + +The suite is tiered because the quickstart stack **mocks IBEX** — it can't +resolve wallet balances, create Lightning invoices, register device tokens, or +persist usernames, and its synthetic wallet ids don't satisfy the `WalletId` +scalar. + +- **CI tier (default, `SMOKE_BACKEND_FULL=false`)** — everything reachable + without a provisioned backend: API health, login/session, account+wallet + identity, transaction-history shape, username-set (accepted), recipient + non-resolution, payment-validation rejections, `globals.topupEnabled`, and + that bridge/cashout resolvers respond structurally (no 5xx). This is what + runs in CI against quickstart and catches the bulk of API-contract, auth, + and resolver-crash regressions. +- **Full tier (`SMOKE_BACKEND_FULL=true`)** — balances, invoice creation, + onchain address, default-wallet mutation, device-token registration, + username resolution, and (with `SMOKE_DOCKER_HELPERS=true` / + `SMOKE_ALLOW_PAYMENTS=true`) external funding and two-account payments. Run + against TEST or any environment with a real IBEX backend. + +## UAT matrix coverage + +| UAT ID | Automated here | Notes | +|--------|----------------|-------| +Tier: **C** = CI (runs against quickstart mock), **F** = full backend only. + +| UAT ID | Tier | Spec | Notes | +|--------|------|------|-------| +| SETUP-01 | C | `00-environment` | endpoint healthy, network echo | +| AUTH-01/02 | C | `01-auth-account` | login + session validity (API level) | +| AUTH-03 | C/F | `01-auth-account` | username set (C); recipient resolution (F) | +| AUTH-04 | C | `01-auth-account` | re-login, account/wallets unchanged | +| HOME-01 | C/F | `02-wallets-home` | wallet shape+default (C); balance numeric (F) | +| TX-00 | C | `02-wallets-home` | history shape clean | +| TX-01/02 | F | `05-payments-internal` | both directions, memo, status after a send | +| RECV-03/04/05 | F | `03-receive` | invoice create paths; expired-UI is manual | +| FUND-01/02 | F | `04-funding-external` | needs lnd-outside + real balances | +| EXT-01/02 | F | `04-funding-external` | needs lnd-outside; TEST external wallet is manual | +| EXT-03 | — | manual | LNURL hostname routing is env-specific | +| SEND-01/02 | F | `05-payments-internal` | intraledger both directions with memo | +| SEND-03 | C | `06-validation` | unknown username does not resolve | +| SEND-04/05 | C | `06-validation` | self-send, zero, too-high all rejected | +| CONTACT-01 | F | `05-payments-internal` | contacts populated after payment | +| WALLET-01 | F | `02-wallets-home` | default-wallet mutation (needs real WalletId) | +| WALLET-02/03 | — | manual | conversion UX + max-amount UI flow | +| ONCHAIN-01 | F | `02-wallets-home` | when a BTC wallet exists | +| ONCHAIN-02 | — | manual | below-min UX validation | +| BRIDGE-01 | C | `07-flags-bridge` | `globals.topupEnabled` matches flag state | +| BRIDGE-03/05 | C | `07-flags-bridge` | resolvers respond structurally (no 5xx); WebView/UI manual | +| BRIDGE-02/04 | — | manual | WebView + order/instruction UI | +| NOTIF-01 | F | `07-flags-bridge` | device-token registration; push delivery is manual | +| NOTIF-02/03 | — | manual | push arrival + deep links need devices | +| SCAN-00..03 | — | manual | camera/permissions need devices | +| SETTINGS-*, MAP, REPORT, CHAT | — | manual | device UI | +| WIDGET-01, WATCH-01 | — | manual | iOS platform extensions | + +Roughly **14 rows run in CI** (quickstart mock) and **~10 more in the full +tier** against a real backend; the remainder are device-bound UI/UX rows that +stay manual. The CI tier is deliberately the crash/contract/auth/flag safety +net; the full tier is the money-movement confidence you point at TEST. + +## Design notes + +- Specs are ordered (`00-` … `07-`) and mirror the UAT phase plan; later + phases depend on backend state created earlier (accounts, funding), not on + in-process state — every spec re-logs-in with the same deterministic phones. +- Quickstart logins use `UNSECURE_DEFAULT_LOGIN_CODE=000000`; deployed + environments must list the smoke phones under `test_accounts` in config. +- Money movement is opt-out (`SMOKE_ALLOW_PAYMENTS=false`) for shared + environments; docker funding is opt-in (`SMOKE_DOCKER_HELPERS=true`). diff --git a/test/flash/smoke/client.ts b/test/flash/smoke/client.ts new file mode 100644 index 000000000..4cf37e15d --- /dev/null +++ b/test/flash/smoke/client.ts @@ -0,0 +1,201 @@ +import { execFileSync } from "child_process" + +import { SMOKE } from "./config" + +type GqlResponse> = { + data?: T + errors?: Array<{ message: string; extensions?: { code?: string } }> +} + +export const gql = async >( + query: string, + variables: Record = {}, + token?: string, +): Promise> => { + const res = await fetch(SMOKE.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }) + if (!res.ok && res.status !== 400) { + throw new Error(`GraphQL HTTP ${res.status}: ${await res.text()}`) + } + return (await res.json()) as GqlResponse +} + +// Throws with the full error list when the response has transport-level errors. +export const gqlOk = async >( + query: string, + variables: Record = {}, + token?: string, +): Promise => { + const res = await gql(query, variables, token) + if (res.errors?.length) { + throw new Error(`GraphQL errors: ${JSON.stringify(res.errors)}`) + } + if (!res.data) throw new Error("GraphQL response had no data") + return res.data +} + +// True when the response carries a schema-validation error for `field` — i.e. +// the target backend predates that field. Lets version-dependent specs skip +// gracefully instead of failing against an older deployment. +export const isUnknownFieldError = ( + errors: + | Array<{ message: string; extensions?: { code?: string; field?: string } }> + | undefined, + field: string, +): boolean => + (errors ?? []).some( + (e) => + // Apollo Server validation (direct API endpoints) + (e.extensions?.code === "GRAPHQL_VALIDATION_FAILED" && e.message.includes(field)) || + // galoy custom validation (e.g. quickstart's stale supergraph route) + (e.extensions?.code === "INVALID_FIELD" && e.extensions?.field === field), + ) + +export const login = async (phone: string, code: string): Promise => { + const data = await gqlOk<{ + userLogin: { authToken: string | null; errors: Array<{ message: string }> } + }>( + `mutation smokeLogin($input: UserLoginInput!) { + userLogin(input: $input) { authToken errors { message } } + }`, + { input: { phone, code } }, + ) + if (!data.userLogin.authToken) { + throw new Error(`login failed for ${phone}: ${JSON.stringify(data.userLogin.errors)}`) + } + return data.userLogin.authToken +} + +export type WalletInfo = { + id: string + walletCurrency: string +} + +export type MeInfo = { + id: string + username: string | null + defaultAccount: { + id: string + defaultWalletId: string + wallets: WalletInfo[] + } +} + +// Identity + wallet shape only. Deliberately omits `balance`: it resolves +// through IBEX, which the quickstart stack mocks without a balance path, so +// selecting it would poison every identity/session assertion. Balance is a +// separate best-effort fetch (getBalance) used only by full-backend specs. +export const getMe = async (token: string): Promise => { + const data = await gqlOk<{ me: MeInfo }>( + `query smokeMe { + me { + id + username + defaultAccount { + id + defaultWalletId + wallets { id walletCurrency } + } + } + }`, + {}, + token, + ) + return data.me +} + +// Best-effort: returns null when the environment can't resolve balances +// (e.g. IBEX-mock quickstart) instead of throwing. +export const getBalance = async ( + token: string, + walletId: string, +): Promise => { + const res = await gql<{ + me: { defaultAccount: { wallets: Array<{ id: string; balance: number }> } } + }>( + `query smokeBalance { + me { defaultAccount { wallets { id balance } } } + }`, + {}, + token, + ) + const wallet = res.data?.me.defaultAccount.wallets.find((w) => w.id === walletId) + return typeof wallet?.balance === "number" ? wallet.balance : null +} + +export type TxNode = { + id: string + direction: string + status: string + memo: string | null + settlementAmount: number + settlementCurrency: string + createdAt: number +} + +export const getTransactions = async (token: string, first = 10): Promise => { + const data = await gqlOk<{ + me: { defaultAccount: { transactions: { edges: Array<{ node: TxNode }> } | null } } + }>( + `query smokeTxs($first: Int) { + me { + defaultAccount { + transactions(first: $first) { + edges { + node { + id direction status memo settlementAmount settlementCurrency createdAt + } + } + } + } + } + }`, + { first }, + token, + ) + return (data.me.defaultAccount.transactions?.edges ?? []).map((e) => e.node) +} + +export const usdWalletOf = (me: MeInfo): WalletInfo => { + const w = me.defaultAccount.wallets.find((x) => x.walletCurrency === "USD") + if (!w) throw new Error(`no USD wallet on account ${me.defaultAccount.id}`) + return w +} + +// Docker-exec helpers against the quickstart containers (local/CI only). +export const dockerExec = (container: string, args: string[]): string => + execFileSync("docker", ["exec", `${SMOKE.composeProject}-${container}-1`, ...args], { + encoding: "utf8", + timeout: 30000, + }) + +export const lndOutside = (args: string[]): string => + dockerExec("lnd-outside-1", [ + "lncli", + "--macaroonpath", + "/root/.lnd/data/chain/bitcoin/regtest/admin.macaroon", + "--tlscertpath", + "/root/.lnd/tls.cert", + ...args, + ]) + +export const retry = async ( + fn: () => Promise, + check: (v: T) => boolean, + attempts = 30, + delayMs = 1000, +): Promise => { + let last: T = await fn() + for (let i = 0; i < attempts; i++) { + if (check(last)) return last + await new Promise((r) => setTimeout(r, delayMs)) + last = await fn() + } + return last +} diff --git a/test/flash/smoke/config.ts b/test/flash/smoke/config.ts new file mode 100644 index 000000000..85758a478 --- /dev/null +++ b/test/flash/smoke/config.ts @@ -0,0 +1,41 @@ +// Environment-driven configuration so the same suite targets any deployment. +// +// SMOKE_ENDPOINT GraphQL URL (default: local quickstart via oathkeeper) +// SMOKE_PHONE_A/B test account phones (must be in test_accounts, or the +// env must run with UNSECURE_DEFAULT_LOGIN_CODE) +// SMOKE_CODE OTP for both accounts (default 000000 = quickstart) +// SMOKE_EXPECT_FLAGS_OFF assert bridge/topup/cashout are disabled (launch +// baseline). Set to "false" for a flags-on environment. +// SMOKE_ALLOW_PAYMENTS run money-movement specs (intraledger sends). Default +// on; set to "false" for read-only runs against shared +// environments you don't want to mutate. +// SMOKE_DOCKER_HELPERS allow docker-exec funding via the quickstart +// bitcoind/lnd-outside containers (local/CI only). +// SMOKE_BACKEND_FULL the target has a fully-provisioned backend (real IBEX +// wallets, invoice creation, device tokens). Off by +// default because the quickstart stack mocks IBEX and +// cannot resolve balances / create invoices — those +// specs then skip. Set true against TEST/PROD-like +// environments to exercise money-movement + provisioning. + +const bool = (v: string | undefined, dflt: boolean): boolean => + v === undefined ? dflt : v !== "false" && v !== "0" + +// Unique-per-run suffix so username registration never collides across runs +// against a persistent environment. Pinned in globalSetup so every spec file +// derives the same value. +const runId = process.env.SMOKE_RUN_ID || "local" + +export const SMOKE = { + endpoint: process.env.SMOKE_ENDPOINT || "http://localhost:4002/graphql", + phoneA: process.env.SMOKE_PHONE_A || "+16505550001", + phoneB: process.env.SMOKE_PHONE_B || "+16505550002", + code: process.env.SMOKE_CODE || "000000", + expectFlagsOff: bool(process.env.SMOKE_EXPECT_FLAGS_OFF, true), + allowPayments: bool(process.env.SMOKE_ALLOW_PAYMENTS, true), + dockerHelpers: bool(process.env.SMOKE_DOCKER_HELPERS, false), + backendFull: bool(process.env.SMOKE_BACKEND_FULL, false), + composeProject: process.env.COMPOSE_PROJECT_NAME || "quickstart", + usernameA: process.env.SMOKE_USERNAME_A || `smoke_a_${runId}`, + usernameB: process.env.SMOKE_USERNAME_B || `smoke_b_${runId}`, +} diff --git a/test/flash/smoke/globalSetup.js b/test/flash/smoke/globalSetup.js new file mode 100644 index 000000000..00d3031b9 --- /dev/null +++ b/test/flash/smoke/globalSetup.js @@ -0,0 +1,7 @@ +// Pin one run id for the whole run so every spec file derives the same +// usernames (each jest spec file gets its own module registry). +module.exports = async () => { + if (!process.env.SMOKE_RUN_ID) { + process.env.SMOKE_RUN_ID = Math.random().toString(36).slice(2, 8) + } +} diff --git a/test/flash/smoke/jest.config.js b/test/flash/smoke/jest.config.js new file mode 100644 index 000000000..4e59898d3 --- /dev/null +++ b/test/flash/smoke/jest.config.js @@ -0,0 +1,19 @@ +const swcConfig = require("../../swc-config.json") + +// UAT smoke suite: black-box tests against a RUNNING Flash GraphQL endpoint. +// No app imports, no database access — everything goes through the public API, +// so the same suite runs against the local quickstart stack, CI, or TEST. +// See ./README.md for the UAT-matrix coverage map and required env vars. +module.exports = { + moduleFileExtensions: ["js", "json", "ts"], + rootDir: "../../../", + roots: ["/test/flash/smoke/specs"], + transform: { + "^.+\\.(t|j)s$": ["@swc/jest", swcConfig], + }, + testRegex: ".*\\.spec\\.ts$", + testEnvironment: "node", + globalSetup: "/test/flash/smoke/globalSetup.js", + testSequencer: "/test/flash/smoke/sequencer.js", + testTimeout: 60000, +} diff --git a/test/flash/smoke/sequencer.js b/test/flash/smoke/sequencer.js new file mode 100644 index 000000000..17d67de5e --- /dev/null +++ b/test/flash/smoke/sequencer.js @@ -0,0 +1,11 @@ +const Sequencer = require("@jest/test-sequencer").default + +// Run specs in filename order (00-, 01-, ...): later phases depend on state +// created by earlier ones (accounts, funding), mirroring the UAT phase plan. +class SmokeSequencer extends Sequencer { + sort(tests) { + return [...tests].sort((a, b) => a.path.localeCompare(b.path)) + } +} + +module.exports = SmokeSequencer diff --git a/test/flash/smoke/specs/00-environment.spec.ts b/test/flash/smoke/specs/00-environment.spec.ts new file mode 100644 index 000000000..7f8e0f3a9 --- /dev/null +++ b/test/flash/smoke/specs/00-environment.spec.ts @@ -0,0 +1,22 @@ +import { gqlOk } from "../client" +import { SMOKE } from "../config" + +// UAT: SETUP-01 (environment reachable and healthy) +describe("SETUP: environment", () => { + it("SETUP-01: GraphQL endpoint responds with globals", async () => { + const data = await gqlOk<{ + globals: { network: string; lightningAddressDomain: string } + }>( + `query smokeGlobals { + globals { network lightningAddressDomain } + }`, + ) + expect(data.globals.network).toBeTruthy() + expect(data.globals.lightningAddressDomain).toBeTruthy() + // Surface where we're pointed so CI logs are self-describing. + // eslint-disable-next-line no-console + console.log( + `smoke target: ${SMOKE.endpoint} network=${data.globals.network} flagsOff=${SMOKE.expectFlagsOff}`, + ) + }) +}) diff --git a/test/flash/smoke/specs/01-auth-account.spec.ts b/test/flash/smoke/specs/01-auth-account.spec.ts new file mode 100644 index 000000000..3d9f26842 --- /dev/null +++ b/test/flash/smoke/specs/01-auth-account.spec.ts @@ -0,0 +1,77 @@ +import { getMe, gql, gqlOk, login } from "../client" +import { SMOKE } from "../config" + +// UAT: AUTH-01/02 (session works across requests), AUTH-03 (usernames), +// AUTH-04 (re-login preserves account state) +describe("Phase 0: account + session", () => { + let tokenA: string + let tokenB: string + + it("AUTH-01/02: both test accounts can log in with phone code", async () => { + tokenA = await login(SMOKE.phoneA, SMOKE.code) + tokenB = await login(SMOKE.phoneB, SMOKE.code) + expect(tokenA).toBeTruthy() + expect(tokenB).toBeTruthy() + }) + + it("AUTH-01: session token stays valid across repeated requests", async () => { + const first = await getMe(tokenA) + const second = await getMe(tokenA) + expect(second.id).toBe(first.id) + expect(second.defaultAccount.id).toBe(first.defaultAccount.id) + }) + + it("AUTH-03: usernames can be set (or already exist) and resolve to a wallet", async () => { + const setUsername = async (token: string, username: string): Promise => { + const me = await getMe(token) + if (me.username) return me.username // persistent env: keep existing + const res = await gql<{ + userUpdateUsername: { + errors: Array<{ message: string }> + user: { username: string | null } | null + } + }>( + `mutation smokeSetUsername($input: UserUpdateUsernameInput!) { + userUpdateUsername(input: $input) { + errors { message } + user { username } + } + }`, + { input: { username } }, + token, + ) + // The mutation must not error; some environments return the updated + // user immediately, others (deprecated resolver) return null — fall back + // to the requested value and verify persistence via resolution below. + expect(res.data?.userUpdateUsername.errors ?? []).toEqual([]) + return res.data?.userUpdateUsername.user?.username ?? username + } + + const usernameA = await setUsername(tokenA, SMOKE.usernameA) + const usernameB = await setUsername(tokenB, SMOKE.usernameB) + expect(usernameA).not.toBe(usernameB) + + // Recipient resolution — the same lookup Send-by-username uses. Only + // meaningful once usernames actually persist (full backend); the mock + // accepts the mutation but does not persist the handle. + if (!SMOKE.backendFull) return + const resolved = await gqlOk<{ accountDefaultWallet: { id: string } }>( + `query smokeResolve($username: Username!) { + accountDefaultWallet(username: $username) { id } + }`, + { username: usernameB }, + tokenA, + ) + expect(resolved.accountDefaultWallet.id).toBeTruthy() + }) + + it("AUTH-04: re-login returns a fresh token for the same account", async () => { + const before = await getMe(tokenA) + const fresh = await login(SMOKE.phoneA, SMOKE.code) + const after = await getMe(fresh) + expect(after.defaultAccount.id).toBe(before.defaultAccount.id) + expect(after.defaultAccount.wallets.map((w) => w.id).sort()).toEqual( + before.defaultAccount.wallets.map((w) => w.id).sort(), + ) + }) +}) diff --git a/test/flash/smoke/specs/02-wallets-home.spec.ts b/test/flash/smoke/specs/02-wallets-home.spec.ts new file mode 100644 index 000000000..62bed0236 --- /dev/null +++ b/test/flash/smoke/specs/02-wallets-home.spec.ts @@ -0,0 +1,106 @@ +import { + getBalance, + getMe, + getTransactions, + gql, + gqlOk, + login, + usdWalletOf, +} from "../client" +import { SMOKE } from "../config" + +// UAT: HOME-01 (wallet cards data), TX-00 (history loads cleanly), +// WALLET-01 (default wallet), ONCHAIN-01 (onchain receive address) +describe("Phase 0/3: wallets + home data", () => { + let token: string + + beforeAll(async () => { + token = await login(SMOKE.phoneA, SMOKE.code) + }) + + it("HOME-01: account exposes wallets with valid currencies and a default", async () => { + const me = await getMe(token) + expect(me.defaultAccount.wallets.length).toBeGreaterThan(0) + for (const w of me.defaultAccount.wallets) { + expect(["USD", "BTC", "USDT"]).toContain(w.walletCurrency) + expect(w.id).toBeTruthy() + } + expect(usdWalletOf(me).id).toBeTruthy() + expect(me.defaultAccount.defaultWalletId).toBeTruthy() + }) + + it("TX-00: transaction history query returns a clean, well-formed list", async () => { + const txs = await getTransactions(token, 20) + for (const tx of txs) { + expect(["RECEIVE", "SEND"]).toContain(tx.direction) + expect(tx.id).toBeTruthy() + expect(typeof tx.settlementAmount).toBe("number") + } + }) + + // Balance resolution and walletId-scoped mutations require a provisioned + // IBEX backend — the quickstart mock issues synthetic wallet ids that don't + // satisfy the WalletId scalar. Skipped unless SMOKE_BACKEND_FULL=true. + const describeFull = SMOKE.backendFull ? describe : describe.skip + + describeFull("full backend", () => { + it("WALLET-01: default wallet id can be set through the API", async () => { + const me = await getMe(token) + // Setting to the current value exercises the mutation without changing + // state — safe against persistent environments. + const res = await gqlOk<{ + accountUpdateDefaultWalletId: { + errors: Array<{ message: string }> + account: { defaultWalletId: string } | null + } + }>( + `mutation smokeDefaultWallet($input: AccountUpdateDefaultWalletIdInput!) { + accountUpdateDefaultWalletId(input: $input) { + errors { message } + account { defaultWalletId } + } + }`, + { input: { walletId: me.defaultAccount.defaultWalletId } }, + token, + ) + expect(res.accountUpdateDefaultWalletId.errors).toEqual([]) + expect(res.accountUpdateDefaultWalletId.account?.defaultWalletId).toBe( + me.defaultAccount.defaultWalletId, + ) + }) + + it("HOME-01: USD wallet balance resolves to a non-negative number", async () => { + const me = await getMe(token) + const balance = await getBalance(token, usdWalletOf(me).id) + expect(balance).not.toBeNull() + expect(balance).toBeGreaterThanOrEqual(0) + }) + + it("ONCHAIN-01: onchain receive address is generated when a BTC wallet exists", async () => { + const me = await getMe(token) + const btc = me.defaultAccount.wallets.find((w) => w.walletCurrency === "BTC") + if (!btc) { + // eslint-disable-next-line no-console + console.log("ONCHAIN-01 skipped: account has no BTC wallet in this environment") + return + } + const res = await gql<{ + onChainAddressCreate: { + errors: Array<{ message: string }> + address: string | null + } + }>( + `mutation smokeOnchain($input: OnChainAddressCreateInput!) { + onChainAddressCreate(input: $input) { errors { message } address } + }`, + { input: { walletId: btc.id } }, + token, + ) + const payload = res.data?.onChainAddressCreate + expect(payload).toBeTruthy() + const addressOk = (payload?.address?.length ?? 0) > 20 + const erroredCleanly = (payload?.errors.length ?? 0) > 0 + expect(addressOk || erroredCleanly).toBe(true) + }) + }) +}) diff --git a/test/flash/smoke/specs/03-receive.spec.ts b/test/flash/smoke/specs/03-receive.spec.ts new file mode 100644 index 000000000..3a959cd28 --- /dev/null +++ b/test/flash/smoke/specs/03-receive.spec.ts @@ -0,0 +1,92 @@ +import { getMe, gqlOk, login, usdWalletOf } from "../client" +import { SMOKE } from "../config" + +// UAT: RECV-00 (receive available pre-funding), RECV-03 (fixed invoice), +// RECV-04 (flexible/no-amount invoice), RECV-05 (expiry is set/marked) +// +// Invoice creation routes through IBEX — requires a provisioned backend. +// Skipped against the IBEX-mock quickstart stack. +const describeMaybe = SMOKE.backendFull ? describe : describe.skip + +describeMaybe("Phase 1: receive", () => { + let token: string + let usdWalletId: string + + beforeAll(async () => { + token = await login(SMOKE.phoneA, SMOKE.code) + usdWalletId = usdWalletOf(await getMe(token)).id + }) + + it("RECV-03: fixed-amount USD Lightning invoice is created", async () => { + const res = await gqlOk<{ + lnUsdInvoiceCreate: { + errors: Array<{ message: string }> + invoice: { paymentRequest: string; paymentHash: string } | null + } + }>( + `mutation smokeUsdInvoice($input: LnUsdInvoiceCreateInput!) { + lnUsdInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest paymentHash } + } + }`, + { input: { walletId: usdWalletId, amount: 210, memo: "smoke RECV-03" } }, + token, + ) + expect(res.lnUsdInvoiceCreate.errors).toEqual([]) + const pr = res.lnUsdInvoiceCreate.invoice?.paymentRequest ?? "" + expect(pr.toLowerCase().startsWith("ln")).toBe(true) + }) + + it("RECV-04: no-amount (flexible) invoice is created or cleanly unsupported", async () => { + const res = await gqlOk<{ + lnNoAmountInvoiceCreate: { + errors: Array<{ message: string }> + invoice: { paymentRequest: string } | null + } + }>( + `mutation smokeNoAmountInvoice($input: LnNoAmountInvoiceCreateInput!) { + lnNoAmountInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest } + } + }`, + { input: { walletId: usdWalletId, memo: "smoke RECV-04" } }, + token, + ) + const payload = res.lnNoAmountInvoiceCreate + // Either a valid invoice or an explicit unsupported error — never a crash. + const invoiceOk = Boolean( + payload.invoice?.paymentRequest.toLowerCase().startsWith("ln"), + ) + const erroredCleanly = payload.errors.length > 0 + expect(invoiceOk || erroredCleanly).toBe(true) + }) + + it("RECV-05: invoice honors a short expiry", async () => { + const res = await gqlOk<{ + lnUsdInvoiceCreate: { + errors: Array<{ message: string }> + invoice: { paymentRequest: string; satoshis: number | null } | null + } + }>( + `mutation smokeExpiryInvoice($input: LnUsdInvoiceCreateInput!) { + lnUsdInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest satoshis } + } + }`, + { + input: { + walletId: usdWalletId, + amount: 100, + memo: "smoke RECV-05", + expiresIn: 1, + }, + }, + token, + ) + expect(res.lnUsdInvoiceCreate.errors).toEqual([]) + expect(res.lnUsdInvoiceCreate.invoice?.paymentRequest).toBeTruthy() + }) +}) diff --git a/test/flash/smoke/specs/04-funding-external.spec.ts b/test/flash/smoke/specs/04-funding-external.spec.ts new file mode 100644 index 000000000..4261398d3 --- /dev/null +++ b/test/flash/smoke/specs/04-funding-external.spec.ts @@ -0,0 +1,89 @@ +import { + getBalance, + getMe, + gqlOk, + lndOutside, + login, + retry, + usdWalletOf, +} from "../client" +import { SMOKE } from "../config" + +// UAT: FUND-01/02 (seed balances), EXT-02 (external wallet -> Flash), +// EXT-01 (Flash -> external wallet) +// +// Requires the quickstart docker stack (lnd-outside) AND a backend that +// actually settles balances. Gated behind SMOKE_DOCKER_HELPERS + a resolvable +// balance (checked at runtime), so it skips against the IBEX-mock stack. +const describeMaybe = SMOKE.dockerHelpers && SMOKE.backendFull ? describe : describe.skip + +const usdBalance = async (token: string, walletId: string): Promise => { + const b = await getBalance(token, walletId) + if (b === null) throw new Error(`balance unresolved for wallet ${walletId}`) + return b +} + +describeMaybe("Phase 1/3: external funding via lnd-outside", () => { + let token: string + let usdWalletId: string + + beforeAll(async () => { + token = await login(SMOKE.phoneA, SMOKE.code) + usdWalletId = usdWalletOf(await getMe(token)).id + }) + + it("FUND-01 / EXT-02: paying a Flash invoice from an external wallet credits the balance", async () => { + const before = await usdBalance(token, usdWalletId) + + const res = await gqlOk<{ + lnUsdInvoiceCreate: { + errors: Array<{ message: string }> + invoice: { paymentRequest: string } | null + } + }>( + `mutation smokeFundInvoice($input: LnUsdInvoiceCreateInput!) { + lnUsdInvoiceCreate(input: $input) { + errors { message } + invoice { paymentRequest } + } + }`, + { input: { walletId: usdWalletId, amount: 500, memo: "smoke FUND-01" } }, + token, + ) + expect(res.lnUsdInvoiceCreate.errors).toEqual([]) + const paymentRequest = res.lnUsdInvoiceCreate.invoice?.paymentRequest + expect(paymentRequest).toBeTruthy() + + lndOutside(["payinvoice", "--force", `${paymentRequest}`]) + + const after = await retry( + async () => usdBalance(token, usdWalletId), + (b) => b > before, + ) + expect(after).toBeGreaterThan(before) + }) + + it("EXT-01: Flash pays a small external Lightning invoice", async () => { + const added = JSON.parse( + lndOutside(["addinvoice", "--amt", "100", "--memo", "smoke EXT-01"]), + ) as { payment_request: string } + + const res = await gqlOk<{ + lnInvoicePaymentSend: { + errors: Array<{ message: string }> + status: string | null + } + }>( + `mutation smokeExternalPay($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { errors { message } status } + }`, + { + input: { walletId: usdWalletId, paymentRequest: added.payment_request }, + }, + token, + ) + expect( + `${res.lnInvoicePaymentSend.status} ${JSON.stringify(res.lnInvoicePaymentSend.errors)}`, + ).toBe("SUCCESS []") + }) +}) diff --git a/test/flash/smoke/specs/05-payments-internal.spec.ts b/test/flash/smoke/specs/05-payments-internal.spec.ts new file mode 100644 index 000000000..d0d0ecb52 --- /dev/null +++ b/test/flash/smoke/specs/05-payments-internal.spec.ts @@ -0,0 +1,126 @@ +import { + getBalance, + getMe, + getTransactions, + gqlOk, + login, + retry, + usdWalletOf, +} from "../client" +import { SMOKE } from "../config" + +// UAT: SEND-01/02 (username/paycode sends both directions with memo), +// TX-01 (history both sides), TX-02 (detail fields), CONTACT-01 (contacts) +// +// Requires real balances (funded accounts) — needs a provisioned backend and +// payments enabled. Skipped against the IBEX-mock quickstart stack. +const describeMaybe = SMOKE.allowPayments && SMOKE.backendFull ? describe : describe.skip + +const MEMO_AB = `smoke SEND-01 ${process.env.SMOKE_RUN_ID || ""}`.trim() +const MEMO_B_TO_A = `smoke SEND-02 ${process.env.SMOKE_RUN_ID || ""}`.trim() + +const sendUsd = async ( + token: string, + walletId: string, + recipientWalletId: string, + amount: number, + memo: string, +) => + gqlOk<{ + intraLedgerUsdPaymentSend: { + errors: Array<{ message: string }> + status: string | null + } + }>( + `mutation smokeIntraledger($input: IntraLedgerUsdPaymentSendInput!) { + intraLedgerUsdPaymentSend(input: $input) { errors { message } status } + }`, + { input: { walletId, recipientWalletId, amount, memo } }, + token, + ) + +describeMaybe("Phase 2: two-account internal payments", () => { + let tokenA: string + let tokenB: string + let walletA: string + let walletB: string + + beforeAll(async () => { + tokenA = await login(SMOKE.phoneA, SMOKE.code) + tokenB = await login(SMOKE.phoneB, SMOKE.code) + walletA = usdWalletOf(await getMe(tokenA)).id + walletB = usdWalletOf(await getMe(tokenB)).id + }) + + const usdBalance = async (token: string, walletId: string): Promise => { + const b = await getBalance(token, walletId) + if (b === null) throw new Error(`balance unresolved for wallet ${walletId}`) + return b + } + + it("SEND-01: A pays B by wallet with memo; balances update on both sides", async () => { + const balanceA = await usdBalance(tokenA, walletA) + if (balanceA < 10) { + throw new Error( + `account A has insufficient smoke balance (${balanceA}); run the funding phase (SMOKE_DOCKER_HELPERS=true) or pre-fund ${SMOKE.phoneA}`, + ) + } + const balanceBBefore = await usdBalance(tokenB, walletB) + + const res = await sendUsd(tokenA, walletA, walletB, 5, MEMO_AB) + expect(res.intraLedgerUsdPaymentSend.errors).toEqual([]) + expect(res.intraLedgerUsdPaymentSend.status).toBe("SUCCESS") + + const balanceBAfter = await retry( + async () => usdBalance(tokenB, walletB), + (b) => b > balanceBBefore, + ) + expect(balanceBAfter).toBeGreaterThan(balanceBBefore) + }) + + it("SEND-02: B pays A back", async () => { + const balanceABefore = await usdBalance(tokenA, walletA) + const res = await sendUsd(tokenB, walletB, walletA, 2, MEMO_B_TO_A) + expect(res.intraLedgerUsdPaymentSend.errors).toEqual([]) + expect(res.intraLedgerUsdPaymentSend.status).toBe("SUCCESS") + + const balanceAAfter = await retry( + async () => usdBalance(tokenA, walletA), + (b) => b > balanceABefore, + ) + expect(balanceAAfter).toBeGreaterThan(balanceABefore) + }) + + it("TX-01/TX-02: both sides show the payment with direction, status, and memo", async () => { + const txsA = await getTransactions(tokenA, 10) + const txsB = await getTransactions(tokenB, 10) + + const sentFromA = txsA.find((t) => t.memo === MEMO_AB) + const receivedByB = txsB.find((t) => t.memo === MEMO_AB) + + expect(sentFromA?.direction).toBe("SEND") + expect(receivedByB?.direction).toBe("RECEIVE") + for (const tx of [sentFromA, receivedByB]) { + expect(tx?.status).toBe("SUCCESS") + expect(typeof tx?.settlementAmount).toBe("number") + expect(tx?.createdAt).toBeGreaterThan(0) + } + }) + + it("CONTACT-01: counterparty appears in contacts after payment", async () => { + const data = await gqlOk<{ + me: { contacts: Array<{ username: string; transactionsCount: number }> } + }>( + `query smokeContacts { + me { contacts { username transactionsCount } } + }`, + {}, + tokenA, + ) + const meB = await getMe(tokenB) + if (!meB.username) return // usernames unset in this environment + const contact = data.me.contacts.find((c) => c.username === meB.username) + expect(contact).toBeTruthy() + expect(contact?.transactionsCount).toBeGreaterThan(0) + }) +}) diff --git a/test/flash/smoke/specs/06-validation.spec.ts b/test/flash/smoke/specs/06-validation.spec.ts new file mode 100644 index 000000000..b8a4d27ef --- /dev/null +++ b/test/flash/smoke/specs/06-validation.spec.ts @@ -0,0 +1,77 @@ +import { getMe, gql, login, usdWalletOf } from "../client" +import { SMOKE } from "../config" + +// UAT: SEND-03 (invalid username), SEND-04 (self-send blocked), +// SEND-05 (zero / too-high amounts blocked) +describe("Phase 2: payment validation", () => { + let token: string + let walletId: string + + beforeAll(async () => { + token = await login(SMOKE.phoneA, SMOKE.code) + walletId = usdWalletOf(await getMe(token)).id + }) + + const sendUsd = (input: Record) => + gql<{ + intraLedgerUsdPaymentSend: { + errors: Array<{ message: string }> + status: string | null + } | null + }>( + `mutation smokeValidation($input: IntraLedgerUsdPaymentSendInput!) { + intraLedgerUsdPaymentSend(input: $input) { errors { message } status } + }`, + { input }, + token, + ) + + it("SEND-03: unknown username does not resolve to a recipient", async () => { + const res = await gql<{ accountDefaultWallet: { id: string } | null }>( + `query smokeBadUsername($username: Username!) { + accountDefaultWallet(username: $username) { id } + }`, + { username: "smoke_no_such_user_xyz" }, + token, + ) + expect(res.data?.accountDefaultWallet ?? null).toBeNull() + expect(res.errors?.length).toBeGreaterThan(0) + }) + + it("SEND-04: self-send is blocked with a clear error", async () => { + const res = await sendUsd({ + walletId, + recipientWalletId: walletId, + amount: 1, + memo: "smoke SEND-04", + }) + const payload = res.data?.intraLedgerUsdPaymentSend + expect(payload?.status ?? null).not.toBe("SUCCESS") + expect((payload?.errors.length ?? 0) + (res.errors?.length ?? 0)).toBeGreaterThan(0) + }) + + it("SEND-05: zero amount is rejected", async () => { + const res = await sendUsd({ + walletId, + recipientWalletId: walletId, + amount: 0, + memo: "smoke SEND-05", + }) + const payload = res.data?.intraLedgerUsdPaymentSend + expect(payload?.status ?? null).not.toBe("SUCCESS") + expect((payload?.errors.length ?? 0) + (res.errors?.length ?? 0)).toBeGreaterThan(0) + }) + + it("SEND-05: absurdly high amount is rejected (insufficient balance)", async () => { + const meB = { walletId } // self as target is fine: balance check trips first + const res = await sendUsd({ + walletId, + recipientWalletId: meB.walletId, + amount: 10_000_000_000, + memo: "smoke SEND-05-max", + }) + const payload = res.data?.intraLedgerUsdPaymentSend + expect(payload?.status ?? null).not.toBe("SUCCESS") + expect((payload?.errors.length ?? 0) + (res.errors?.length ?? 0)).toBeGreaterThan(0) + }) +}) diff --git a/test/flash/smoke/specs/07-flags-bridge.spec.ts b/test/flash/smoke/specs/07-flags-bridge.spec.ts new file mode 100644 index 000000000..541970616 --- /dev/null +++ b/test/flash/smoke/specs/07-flags-bridge.spec.ts @@ -0,0 +1,124 @@ +import { getMe, gql, gqlOk, isUnknownFieldError, login, usdWalletOf } from "../client" +import { SMOKE } from "../config" + +// UAT: BRIDGE-01..05 (flags-off behavior: entry points cleanly disabled, no +// crashes and no accidental enablement), NOTIF-01 (device token accepted) +// +// With SMOKE_EXPECT_FLAGS_OFF=true (launch baseline) this suite asserts the +// kill switches actually hold at the API layer — the backend counterpart of +// the mobile bridgeTopupEnabled remote-config gate. +describe("Phase 4: feature flags + bridge surface", () => { + let token: string + let walletId: string + + beforeAll(async () => { + token = await login(SMOKE.phoneA, SMOKE.code) + walletId = usdWalletOf(await getMe(token)).id + }) + + // Reports a failure string, or null when the check passed or is not + // applicable (target schema predates the field — logged and treated as a + // skip). Keeps a single unconditional expect per test. + const evaluate = ( + res: { data?: Record; errors?: Array<{ message: string }> }, + field: string, + assess: () => string | null, + ): string | null => { + if (isUnknownFieldError(res.errors, field)) { + // eslint-disable-next-line no-console + console.log(`skipped: backend schema has no ${field}`) + return null + } + return assess() + } + + it("BRIDGE-01/topup: topupEnabled reflects the flag state when the backend exposes it", async () => { + // topupEnabled ships in flash #421 — older deployments lack it and skip. + const res = await gql<{ globals: { topupEnabled?: boolean } }>( + `query smokeTopupFlag { globals { topupEnabled } }`, + ) + const failure = evaluate(res, "topupEnabled", () => { + if (res.errors?.length) return `GraphQL errors: ${JSON.stringify(res.errors)}` + const value = res.data?.globals.topupEnabled + const ok = SMOKE.expectFlagsOff ? value === false : typeof value === "boolean" + return ok + ? null + : `unexpected topupEnabled=${value} (expectFlagsOff=${SMOKE.expectFlagsOff})` + }) + expect(failure).toBeNull() + }) + + // Bridge/cashout mutations: the reliable, environment-independent smoke goal + // is that the resolver still RESPONDS with a structured GraphQL result + // (business payload or GraphQL errors) rather than crashing (HTTP 5xx / null + // data with an internal-error extension). Precise flag-enforcement semantics + // vary by mutation and are covered by the mobile gating tests + manual UAT; + // the authoritative flag signal here is globals.topupEnabled above. + // + // The Bridge mutations ship in flash #413 — skip (via evaluate) when the + // target schema predates them rather than mistaking the unknown-field + // validation error for a real structured response. + const structuredFailure = ( + res: { data?: Record; errors?: unknown[] }, + field: string, + ): string | null => + Boolean(res.data && res.data[field] !== null) || (res.errors?.length ?? 0) > 0 + ? null + : `${field} did not return a structured response` + + it("BRIDGE-03: bridgeInitiateKyc responds structurally (no resolver crash)", async () => { + const res = await gql<{ + bridgeInitiateKyc: { errors: Array<{ message: string }> } | null + }>( + `mutation smokeKyc($input: BridgeInitiateKycInput!) { + bridgeInitiateKyc(input: $input) { errors { message } } + }`, + { input: { email: "smoke@example.com", full_name: "Smoke Test" } }, + token, + ) + const failure = evaluate(res, "bridgeInitiateKyc", () => + structuredFailure(res, "bridgeInitiateKyc"), + ) + expect(failure).toBeNull() + }) + + it("BRIDGE-05/cashout: requestCashout responds structurally (no resolver crash)", async () => { + const res = await gql<{ requestCashout: { __typename: string } | null }>( + `mutation smokeCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { __typename } + }`, + { input: { walletId, amount: 100, bankAccountId: "smoke-nonexistent" } }, + token, + ) + const failure = evaluate(res, "requestCashout", () => + structuredFailure(res, "requestCashout"), + ) + expect(failure).toBeNull() + }) + + // Device-token registration touches notification infra that the mock stack + // doesn't provide — full-backend only. + const describeFull = SMOKE.backendFull ? describe : describe.skip + + describeFull("full backend", () => { + it("NOTIF-01: device notification token registration succeeds", async () => { + const res = await gqlOk<{ + deviceNotificationTokenCreate: { + errors: Array<{ message: string }> + success: boolean | null + } + }>( + `mutation smokeDeviceToken($input: DeviceNotificationTokenCreateInput!) { + deviceNotificationTokenCreate(input: $input) { + errors { message } + success + } + }`, + { input: { deviceToken: `smoke-token-${process.env.SMOKE_RUN_ID || "local"}` } }, + token, + ) + expect(res.deviceNotificationTokenCreate.errors).toEqual([]) + expect(res.deviceNotificationTokenCreate.success).toBe(true) + }) + }) +})