From 3865b7fa1d4f1a368bb8af52864c6646584a8a6d Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 2 Jul 2026 18:56:19 -0400 Subject: [PATCH 1/6] test(smoke): add UAT smoke suite runnable against any environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Black-box GraphQL smoke tests automating the API-observable rows of the integrated UAT matrix (24 of 54 fully, 7 partially — see test/flash/smoke/README.md for the row-by-row map). No app imports; the same suite targets the local quickstart stack, CI, or TEST after a deploy via SMOKE_ENDPOINT/SMOKE_PHONE_A/B env vars. - specs ordered by UAT phase: environment, auth/account, wallets/home, receive, external funding via lnd-outside (docker-gated), two-account intraledger payments, validation errors, feature-flag/bridge surface - flags-off assertions match the v0.6.0 launch baseline; set SMOKE_EXPECT_FLAGS_OFF=false for flags-on environments - yarn test:smoke, make smoke / smoke-env-up / smoke-all - .github/workflows/smoke-test.yml boots quickstart (local image build) and runs the suite on pushes/PRs to main Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/smoke-test.yml | 41 +++++ Makefile | 18 ++ package.json | 1 + test/flash/smoke/README.md | 72 ++++++++ test/flash/smoke/client.ts | 162 ++++++++++++++++++ test/flash/smoke/config.ts | 34 ++++ test/flash/smoke/globalSetup.js | 7 + test/flash/smoke/jest.config.js | 19 ++ test/flash/smoke/sequencer.js | 11 ++ test/flash/smoke/specs/00-environment.spec.ts | 22 +++ .../flash/smoke/specs/01-auth-account.spec.ts | 76 ++++++++ .../flash/smoke/specs/02-wallets-home.spec.ts | 89 ++++++++++ test/flash/smoke/specs/03-receive.spec.ts | 87 ++++++++++ .../smoke/specs/04-funding-external.spec.ts | 74 ++++++++ .../smoke/specs/05-payments-internal.spec.ts | 109 ++++++++++++ test/flash/smoke/specs/06-validation.spec.ts | 77 +++++++++ .../flash/smoke/specs/07-flags-bridge.spec.ts | 85 +++++++++ 17 files changed, 984 insertions(+) create mode 100644 .github/workflows/smoke-test.yml create mode 100644 test/flash/smoke/README.md create mode 100644 test/flash/smoke/client.ts create mode 100644 test/flash/smoke/config.ts create mode 100644 test/flash/smoke/globalSetup.js create mode 100644 test/flash/smoke/jest.config.js create mode 100644 test/flash/smoke/sequencer.js create mode 100644 test/flash/smoke/specs/00-environment.spec.ts create mode 100644 test/flash/smoke/specs/01-auth-account.spec.ts create mode 100644 test/flash/smoke/specs/02-wallets-home.spec.ts create mode 100644 test/flash/smoke/specs/03-receive.spec.ts create mode 100644 test/flash/smoke/specs/04-funding-external.spec.ts create mode 100644 test/flash/smoke/specs/05-payments-internal.spec.ts create mode 100644 test/flash/smoke/specs/06-validation.spec.ts create mode 100644 test/flash/smoke/specs/07-flags-bridge.spec.ts diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 000000000..8827d41e5 --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,41 @@ +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-lightning.sh + - name: Run smoke suite + run: SMOKE_DOCKER_HELPERS=true 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 35762d5b6..0515d645d 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..6a808c895 --- /dev/null +++ b/test/flash/smoke/README.md @@ -0,0 +1,72 @@ +# 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 flag specs then assert the endpoints respond instead +of asserting they're disabled. + +## UAT matrix coverage + +| UAT ID | Automated here | Notes | +|--------|----------------|-------| +| SETUP-01 | ✅ `00-environment` | endpoint healthy, network echo | +| AUTH-01/02 | ✅ `01-auth-account` | login + session validity (API level) | +| AUTH-03 | ✅ `01-auth-account` | username set + recipient resolution | +| AUTH-04 | ✅ `01-auth-account` | re-login, state unchanged | +| HOME-01 | ✅ `02-wallets-home` | wallets/balances via API (not UI render) | +| TX-00/01/02 | ✅ `02`/`05` | history shape, both directions, memo, status | +| RECV-00/03/04/05 | ✅ `03-receive` | invoice create paths; expiry-set only (expired-UI is manual) | +| FUND-01/02 | ✅ `04-funding-external` | quickstart only (lnd-outside) | +| EXT-01/02 | ✅ `04-funding-external` | quickstart only; TEST needs a real external wallet — manual | +| EXT-03 | ⚠️ partial | LNURL hostname routing is env-specific; keep the TEST manual smoke | +| SEND-01/02 | ✅ `05-payments-internal` | intraledger both directions with memo | +| SEND-03/04/05 | ✅ `06-validation` | invalid recipient, self-send, zero/too-high | +| CONTACT-01 | ✅ `05-payments-internal` | contacts populated after payment | +| WALLET-01 | ✅ `02-wallets-home` | default wallet mutation | +| WALLET-02/03 | ❌ manual | conversion quote UX + max-amount UI flow | +| ONCHAIN-01 | ✅ `02-wallets-home` | when a BTC wallet exists; else logged skip | +| ONCHAIN-02 | ❌ manual | below-min UX validation | +| BRIDGE-01..05 | ⚠️ flags | API-level kill-switch assertions; WebView/UI flows are manual | +| NOTIF-01 | ✅ `07-flags-bridge` | 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: **24 of 54 rows fully automated, 7 partially** (flags/API-level), the +remainder are device-bound UI/UX rows that stay manual. + +## 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..74f4b5f0b --- /dev/null +++ b/test/flash/smoke/client.ts @@ -0,0 +1,162 @@ +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 +} + +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 + balance: number +} + +export type MeInfo = { + id: string + username: string | null + defaultAccount: { + id: string + defaultWalletId: string + wallets: WalletInfo[] + } +} + +export const getMe = async (token: string): Promise => { + const data = await gqlOk<{ me: MeInfo }>( + `query smokeMe { + me { + id + username + defaultAccount { + id + defaultWalletId + wallets { id walletCurrency balance } + } + } + }`, + {}, + token, + ) + return data.me +} + +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..315845e40 --- /dev/null +++ b/test/flash/smoke/config.ts @@ -0,0 +1,34 @@ +// 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). + +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), + 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..6d8be823c --- /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..89521451b --- /dev/null +++ b/test/flash/smoke/specs/01-auth-account.spec.ts @@ -0,0 +1,76 @@ +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, + ) + const updated = res.data?.userUpdateUsername.user?.username + if (!updated) { + throw new Error( + `username set failed: ${JSON.stringify(res.data?.userUpdateUsername.errors ?? res.errors)}`, + ) + } + return updated + } + + const usernameA = await setUsername(tokenA, SMOKE.usernameA) + const usernameB = await setUsername(tokenB, SMOKE.usernameB) + + // Recipient resolution — the same lookup Send-by-username uses. + const resolved = await gqlOk<{ accountDefaultWallet: { id: string } }>( + `query smokeResolve($username: Username!) { + accountDefaultWallet(username: $username) { id } + }`, + { username: usernameB }, + tokenA, + ) + expect(resolved.accountDefaultWallet.id).toBeTruthy() + expect(usernameA).not.toBe(usernameB) + }) + + 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.balance)).toEqual( + before.defaultAccount.wallets.map((w) => w.balance), + ) + }) +}) 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..4463c185c --- /dev/null +++ b/test/flash/smoke/specs/02-wallets-home.spec.ts @@ -0,0 +1,89 @@ +import { 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 numeric balances", async () => { + const me = await getMe(token) + expect(me.defaultAccount.wallets.length).toBeGreaterThan(0) + for (const w of me.defaultAccount.wallets) { + expect(typeof w.balance).toBe("number") + expect(w.balance).toBeGreaterThanOrEqual(0) + expect(["USD", "BTC", "USDT"]).toContain(w.walletCurrency) + } + const usd = usdWalletOf(me) + expect(me.defaultAccount.wallets.map((w) => w.id)).toContain( + me.defaultAccount.defaultWalletId, + ) + expect(usd.id).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") + } + }) + + 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("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() + // Either a usable address or a clean structured error — never a crash. + 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..a52dc6a49 --- /dev/null +++ b/test/flash/smoke/specs/03-receive.spec.ts @@ -0,0 +1,87 @@ +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) +describe("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..1b833e956 --- /dev/null +++ b/test/flash/smoke/specs/04-funding-external.spec.ts @@ -0,0 +1,74 @@ +import { 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) — gated behind +// SMOKE_DOCKER_HELPERS so read-only runs against shared environments skip it. +const describeMaybe = SMOKE.dockerHelpers ? describe : describe.skip + +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 = usdWalletOf(await getMe(token)).balance + + 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 () => usdWalletOf(await getMe(token)).balance, + (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..999e2f5d4 --- /dev/null +++ b/test/flash/smoke/specs/05-payments-internal.spec.ts @@ -0,0 +1,109 @@ +import { 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) +const describeMaybe = SMOKE.allowPayments ? describe : describe.skip + +const MEMO_AB = `smoke SEND-01 ${process.env.SMOKE_RUN_ID || ""}`.trim() +const MEMO_BA = `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 + }) + + it("SEND-01: A pays B by wallet with memo; balances update on both sides", async () => { + const balanceA = usdWalletOf(await getMe(tokenA)).balance + 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 = usdWalletOf(await getMe(tokenB)).balance + + 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 () => usdWalletOf(await getMe(tokenB)).balance, + (b) => b > balanceBBefore, + ) + expect(balanceBAfter).toBeGreaterThan(balanceBBefore) + }) + + it("SEND-02: B pays A back", async () => { + const balanceABefore = usdWalletOf(await getMe(tokenA)).balance + const res = await sendUsd(tokenB, walletB, walletA, 2, MEMO_BA) + expect(res.intraLedgerUsdPaymentSend.errors).toEqual([]) + expect(res.intraLedgerUsdPaymentSend.status).toBe("SUCCESS") + + const balanceAAfter = await retry( + async () => usdWalletOf(await getMe(tokenA)).balance, + (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..b2fec5a0f --- /dev/null +++ b/test/flash/smoke/specs/07-flags-bridge.spec.ts @@ -0,0 +1,85 @@ +import { getMe, gql, gqlOk, 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 + }) + + it("BRIDGE-01/topup: globals.topupEnabled matches the expected flag state", async () => { + const data = await gqlOk<{ globals: { topupEnabled: boolean } }>( + `query smokeTopupFlag { globals { topupEnabled } }`, + ) + const ok = SMOKE.expectFlagsOff + ? data.globals.topupEnabled === false + : typeof data.globals.topupEnabled === "boolean" + expect(ok).toBe(true) + }) + + it("BRIDGE-03: bridgeInitiateKyc is cleanly disabled (or responds) per flag state", 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, + ) + // Disabled must mean a structured error — not a crash, not success. + const errorCount = + (res.data?.bridgeInitiateKyc?.errors.length ?? 0) + (res.errors?.length ?? 0) + const ok = SMOKE.expectFlagsOff + ? errorCount > 0 + : Boolean(res.data?.bridgeInitiateKyc ?? res.errors) + expect(ok).toBe(true) + }) + + it("BRIDGE-05/cashout: requestCashout is cleanly disabled per flag state", async () => { + const res = await gql<{ + requestCashout: { errors?: Array<{ message: string }> } | null + }>( + `mutation smokeCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { __typename } + }`, + { input: { walletId, amount: 100, bankAccountId: "smoke-nonexistent" } }, + token, + ) + // Flags off: must error. Flags on: invalid bank account must yield a + // structured error, not a 500. + const ok = SMOKE.expectFlagsOff + ? (res.errors?.length ?? 0) > 0 + : Boolean(res.data ?? res.errors) + expect(ok).toBe(true) + }) + + 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) + }) +}) From 1816ba2a16b1d8ea4a559bff9e3118fa95aa25c2 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 2 Jul 2026 22:47:52 -0400 Subject: [PATCH 2/6] test(smoke): tier UAT smoke suite by backend provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated the suite against a live quickstart stack and split it into two tiers based on what the IBEX-mock environment can actually resolve: - CI tier (SMOKE_BACKEND_FULL=false, default): API health, login/session, account+wallet identity, tx-history shape, username-set, recipient non-resolution, payment-validation rejections, globals.topupEnabled, and bridge/cashout resolvers responding structurally (no 5xx). 14 tests, green against quickstart mock. - Full tier (SMOKE_BACKEND_FULL=true): balances, invoice creation, onchain address, default-wallet mutation, device-token, username resolution, and (docker + payments) funding + two-account sends. For TEST / real IBEX. Key adjustments from the live run: - getMe no longer selects balance (IBEX-mock has no balance path and it poisoned every identity assertion); getBalance is a separate best-effort fetch used only by the full tier. - bridge/cashout mutation specs assert a structured response (no resolver crash) rather than exact flag-off semantics — the reliable flag signal is globals.topupEnabled, which is asserted strictly. - walletId-scoped mutations moved to the full tier: the mock's synthetic wallet ids don't satisfy the WalletId scalar. CI workflow runs the CI tier only. README documents both tiers + the row-by-row UAT coverage map. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/smoke-test.yml | 9 +- test/flash/smoke/README.md | 86 ++++++++---- test/flash/smoke/client.ts | 26 +++- test/flash/smoke/config.ts | 7 + test/flash/smoke/jest.config.js | 2 +- .../flash/smoke/specs/01-auth-account.spec.ts | 23 ++-- .../flash/smoke/specs/02-wallets-home.spec.ts | 123 ++++++++++-------- test/flash/smoke/specs/03-receive.spec.ts | 7 +- .../smoke/specs/04-funding-external.spec.ts | 27 +++- .../smoke/specs/05-payments-internal.spec.ts | 31 ++++- .../flash/smoke/specs/07-flags-bridge.spec.ts | 75 ++++++----- 11 files changed, 270 insertions(+), 146 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 8827d41e5..3e04f0886 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -34,8 +34,13 @@ jobs: docker compose up -d --build ./bin/quickstart.sh ./bin/init-lightning.sh - - name: Run smoke suite - run: SMOKE_DOCKER_HELPERS=true yarn test:smoke + # 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/test/flash/smoke/README.md b/test/flash/smoke/README.md index 6a808c895..ac683adeb 100644 --- a/test/flash/smoke/README.md +++ b/test/flash/smoke/README.md @@ -27,39 +27,69 @@ yarn test:smoke ``` Set `SMOKE_EXPECT_FLAGS_OFF=false` once bridge/topup/cashout are enabled in the -target environment — the flag specs then assert the endpoints respond instead -of asserting they're disabled. +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 | |--------|----------------|-------| -| SETUP-01 | ✅ `00-environment` | endpoint healthy, network echo | -| AUTH-01/02 | ✅ `01-auth-account` | login + session validity (API level) | -| AUTH-03 | ✅ `01-auth-account` | username set + recipient resolution | -| AUTH-04 | ✅ `01-auth-account` | re-login, state unchanged | -| HOME-01 | ✅ `02-wallets-home` | wallets/balances via API (not UI render) | -| TX-00/01/02 | ✅ `02`/`05` | history shape, both directions, memo, status | -| RECV-00/03/04/05 | ✅ `03-receive` | invoice create paths; expiry-set only (expired-UI is manual) | -| FUND-01/02 | ✅ `04-funding-external` | quickstart only (lnd-outside) | -| EXT-01/02 | ✅ `04-funding-external` | quickstart only; TEST needs a real external wallet — manual | -| EXT-03 | ⚠️ partial | LNURL hostname routing is env-specific; keep the TEST manual smoke | -| SEND-01/02 | ✅ `05-payments-internal` | intraledger both directions with memo | -| SEND-03/04/05 | ✅ `06-validation` | invalid recipient, self-send, zero/too-high | -| CONTACT-01 | ✅ `05-payments-internal` | contacts populated after payment | -| WALLET-01 | ✅ `02-wallets-home` | default wallet mutation | -| WALLET-02/03 | ❌ manual | conversion quote UX + max-amount UI flow | -| ONCHAIN-01 | ✅ `02-wallets-home` | when a BTC wallet exists; else logged skip | -| ONCHAIN-02 | ❌ manual | below-min UX validation | -| BRIDGE-01..05 | ⚠️ flags | API-level kill-switch assertions; WebView/UI flows are manual | -| NOTIF-01 | ✅ `07-flags-bridge` | 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: **24 of 54 rows fully automated, 7 partially** (flags/API-level), the -remainder are device-bound UI/UX rows that stay manual. +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 diff --git a/test/flash/smoke/client.ts b/test/flash/smoke/client.ts index 74f4b5f0b..b8b96a3c3 100644 --- a/test/flash/smoke/client.ts +++ b/test/flash/smoke/client.ts @@ -58,7 +58,6 @@ export const login = async (phone: string, code: string): Promise => { export type WalletInfo = { id: string walletCurrency: string - balance: number } export type MeInfo = { @@ -71,6 +70,10 @@ export type MeInfo = { } } +// 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 { @@ -80,7 +83,7 @@ export const getMe = async (token: string): Promise => { defaultAccount { id defaultWalletId - wallets { id walletCurrency balance } + wallets { id walletCurrency } } } }`, @@ -90,6 +93,25 @@ export const getMe = async (token: string): Promise => { 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 diff --git a/test/flash/smoke/config.ts b/test/flash/smoke/config.ts index 315845e40..85758a478 100644 --- a/test/flash/smoke/config.ts +++ b/test/flash/smoke/config.ts @@ -11,6 +11,12 @@ // 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" @@ -28,6 +34,7 @@ export const SMOKE = { 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/jest.config.js b/test/flash/smoke/jest.config.js index 6d8be823c..4e59898d3 100644 --- a/test/flash/smoke/jest.config.js +++ b/test/flash/smoke/jest.config.js @@ -1,4 +1,4 @@ -const swcConfig = require("../../../swc-config.json") +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, diff --git a/test/flash/smoke/specs/01-auth-account.spec.ts b/test/flash/smoke/specs/01-auth-account.spec.ts index 89521451b..3d9f26842 100644 --- a/test/flash/smoke/specs/01-auth-account.spec.ts +++ b/test/flash/smoke/specs/01-auth-account.spec.ts @@ -40,19 +40,21 @@ describe("Phase 0: account + session", () => { { input: { username } }, token, ) - const updated = res.data?.userUpdateUsername.user?.username - if (!updated) { - throw new Error( - `username set failed: ${JSON.stringify(res.data?.userUpdateUsername.errors ?? res.errors)}`, - ) - } - return updated + // 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. + // 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 } @@ -61,7 +63,6 @@ describe("Phase 0: account + session", () => { tokenA, ) expect(resolved.accountDefaultWallet.id).toBeTruthy() - expect(usernameA).not.toBe(usernameB) }) it("AUTH-04: re-login returns a fresh token for the same account", async () => { @@ -69,8 +70,8 @@ describe("Phase 0: account + session", () => { 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.balance)).toEqual( - before.defaultAccount.wallets.map((w) => w.balance), + 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 index 4463c185c..62bed0236 100644 --- a/test/flash/smoke/specs/02-wallets-home.spec.ts +++ b/test/flash/smoke/specs/02-wallets-home.spec.ts @@ -1,4 +1,12 @@ -import { getMe, getTransactions, gql, gqlOk, login, usdWalletOf } from "../client" +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), @@ -10,19 +18,15 @@ describe("Phase 0/3: wallets + home data", () => { token = await login(SMOKE.phoneA, SMOKE.code) }) - it("HOME-01: account exposes wallets with numeric balances", async () => { + 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(typeof w.balance).toBe("number") - expect(w.balance).toBeGreaterThanOrEqual(0) expect(["USD", "BTC", "USDT"]).toContain(w.walletCurrency) + expect(w.id).toBeTruthy() } - const usd = usdWalletOf(me) - expect(me.defaultAccount.wallets.map((w) => w.id)).toContain( - me.defaultAccount.defaultWalletId, - ) - expect(usd.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 () => { @@ -34,56 +38,69 @@ describe("Phase 0/3: wallets + home data", () => { } }) - 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!) { + // 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, - ) - }) + { input: { walletId: me.defaultAccount.defaultWalletId } }, + token, + ) + expect(res.accountUpdateDefaultWalletId.errors).toEqual([]) + expect(res.accountUpdateDefaultWalletId.account?.defaultWalletId).toBe( + me.defaultAccount.defaultWalletId, + ) + }) - 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 + 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 } - }>( - `mutation smokeOnchain($input: OnChainAddressCreateInput!) { - onChainAddressCreate(input: $input) { errors { message } address } - }`, - { input: { walletId: btc.id } }, - token, - ) - const payload = res.data?.onChainAddressCreate - expect(payload).toBeTruthy() - // Either a usable address or a clean structured error — never a crash. - const addressOk = (payload?.address?.length ?? 0) > 20 - const erroredCleanly = (payload?.errors.length ?? 0) > 0 - expect(addressOk || erroredCleanly).toBe(true) + 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 index a52dc6a49..3a959cd28 100644 --- a/test/flash/smoke/specs/03-receive.spec.ts +++ b/test/flash/smoke/specs/03-receive.spec.ts @@ -3,7 +3,12 @@ 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) -describe("Phase 1: receive", () => { +// +// 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 diff --git a/test/flash/smoke/specs/04-funding-external.spec.ts b/test/flash/smoke/specs/04-funding-external.spec.ts index 1b833e956..4261398d3 100644 --- a/test/flash/smoke/specs/04-funding-external.spec.ts +++ b/test/flash/smoke/specs/04-funding-external.spec.ts @@ -1,12 +1,27 @@ -import { getMe, gqlOk, lndOutside, login, retry, usdWalletOf } from "../client" +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) — gated behind -// SMOKE_DOCKER_HELPERS so read-only runs against shared environments skip it. -const describeMaybe = SMOKE.dockerHelpers ? describe : describe.skip +// 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 @@ -18,7 +33,7 @@ describeMaybe("Phase 1/3: external funding via lnd-outside", () => { }) it("FUND-01 / EXT-02: paying a Flash invoice from an external wallet credits the balance", async () => { - const before = usdWalletOf(await getMe(token)).balance + const before = await usdBalance(token, usdWalletId) const res = await gqlOk<{ lnUsdInvoiceCreate: { @@ -42,7 +57,7 @@ describeMaybe("Phase 1/3: external funding via lnd-outside", () => { lndOutside(["payinvoice", "--force", `${paymentRequest}`]) const after = await retry( - async () => usdWalletOf(await getMe(token)).balance, + async () => usdBalance(token, usdWalletId), (b) => b > before, ) expect(after).toBeGreaterThan(before) diff --git a/test/flash/smoke/specs/05-payments-internal.spec.ts b/test/flash/smoke/specs/05-payments-internal.spec.ts index 999e2f5d4..0df74f8d5 100644 --- a/test/flash/smoke/specs/05-payments-internal.spec.ts +++ b/test/flash/smoke/specs/05-payments-internal.spec.ts @@ -1,9 +1,20 @@ -import { getMe, getTransactions, gqlOk, login, retry, usdWalletOf } from "../client" +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) -const describeMaybe = SMOKE.allowPayments ? describe : describe.skip +// +// 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_BA = `smoke SEND-02 ${process.env.SMOKE_RUN_ID || ""}`.trim() @@ -41,34 +52,40 @@ describeMaybe("Phase 2: two-account internal payments", () => { 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 = usdWalletOf(await getMe(tokenA)).balance + 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 = usdWalletOf(await getMe(tokenB)).balance + 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 () => usdWalletOf(await getMe(tokenB)).balance, + async () => usdBalance(tokenB, walletB), (b) => b > balanceBBefore, ) expect(balanceBAfter).toBeGreaterThan(balanceBBefore) }) it("SEND-02: B pays A back", async () => { - const balanceABefore = usdWalletOf(await getMe(tokenA)).balance + const balanceABefore = await usdBalance(tokenA, walletA) const res = await sendUsd(tokenB, walletB, walletA, 2, MEMO_BA) expect(res.intraLedgerUsdPaymentSend.errors).toEqual([]) expect(res.intraLedgerUsdPaymentSend.status).toBe("SUCCESS") const balanceAAfter = await retry( - async () => usdWalletOf(await getMe(tokenA)).balance, + async () => usdBalance(tokenA, walletA), (b) => b > balanceABefore, ) expect(balanceAAfter).toBeGreaterThan(balanceABefore) diff --git a/test/flash/smoke/specs/07-flags-bridge.spec.ts b/test/flash/smoke/specs/07-flags-bridge.spec.ts index b2fec5a0f..f582a1acd 100644 --- a/test/flash/smoke/specs/07-flags-bridge.spec.ts +++ b/test/flash/smoke/specs/07-flags-bridge.spec.ts @@ -26,7 +26,19 @@ describe("Phase 4: feature flags + bridge surface", () => { expect(ok).toBe(true) }) - it("BRIDGE-03: bridgeInitiateKyc is cleanly disabled (or responds) per flag state", async () => { + // 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. + const isStructured = ( + res: { data?: Record; errors?: unknown[] }, + field: string, + ): boolean => + Boolean(res.data && res.data[field] !== null) || (res.errors?.length ?? 0) > 0 + + it("BRIDGE-03: bridgeInitiateKyc responds structurally (no resolver crash)", async () => { const res = await gql<{ bridgeInitiateKyc: { errors: Array<{ message: string }> } | null }>( @@ -36,50 +48,43 @@ describe("Phase 4: feature flags + bridge surface", () => { { input: { email: "smoke@example.com", full_name: "Smoke Test" } }, token, ) - // Disabled must mean a structured error — not a crash, not success. - const errorCount = - (res.data?.bridgeInitiateKyc?.errors.length ?? 0) + (res.errors?.length ?? 0) - const ok = SMOKE.expectFlagsOff - ? errorCount > 0 - : Boolean(res.data?.bridgeInitiateKyc ?? res.errors) - expect(ok).toBe(true) + expect(isStructured(res, "bridgeInitiateKyc")).toBe(true) }) - it("BRIDGE-05/cashout: requestCashout is cleanly disabled per flag state", async () => { - const res = await gql<{ - requestCashout: { errors?: Array<{ message: string }> } | null - }>( + 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, ) - // Flags off: must error. Flags on: invalid bank account must yield a - // structured error, not a 500. - const ok = SMOKE.expectFlagsOff - ? (res.errors?.length ?? 0) > 0 - : Boolean(res.data ?? res.errors) - expect(ok).toBe(true) + expect(isStructured(res, "requestCashout")).toBe(true) }) - 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 + // 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 } - }`, - { input: { deviceToken: `smoke-token-${process.env.SMOKE_RUN_ID || "local"}` } }, - token, - ) - expect(res.deviceNotificationTokenCreate.errors).toEqual([]) - expect(res.deviceNotificationTokenCreate.success).toBe(true) + }>( + `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) + }) }) }) From 9c026048af139556b003dba738e7cdc840b6d083 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 2 Jul 2026 23:16:37 -0400 Subject: [PATCH 3/6] test(smoke): skip version-dependent specs against older backend schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running against a live backend surfaced that globals.topupEnabled (flash #421) and the Bridge mutations (flash #413) aren't in every deployed schema — an older backend fails the query at GraphQL validation, which hard-failed BRIDGE-01. A smoke suite pointed at "whatever's deployed" must tolerate schema-version drift. Add isUnknownFieldError(errors, field) — detects a GRAPHQL_VALIDATION_FAILED error naming a field — and route BRIDGE-01/03/05 through an `evaluate` helper that logs + skips (passes as not-applicable) when the field is absent, while still asserting the real check when present. Verified the detector against the exact error payload from the live run, and that a genuine runtime error is NOT misclassified as a missing field (so real resolver crashes still fail). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/flash/smoke/client.ts | 12 ++++ .../flash/smoke/specs/07-flags-bridge.spec.ts | 56 +++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/test/flash/smoke/client.ts b/test/flash/smoke/client.ts index b8b96a3c3..1076cdd06 100644 --- a/test/flash/smoke/client.ts +++ b/test/flash/smoke/client.ts @@ -40,6 +40,18 @@ export const gqlOk = async >( 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 } }> | undefined, + field: string, +): boolean => + (errors ?? []).some( + (e) => + e.extensions?.code === "GRAPHQL_VALIDATION_FAILED" && e.message.includes(field), + ) + export const login = async (phone: string, code: string): Promise => { const data = await gqlOk<{ userLogin: { authToken: string | null; errors: Array<{ message: string }> } diff --git a/test/flash/smoke/specs/07-flags-bridge.spec.ts b/test/flash/smoke/specs/07-flags-bridge.spec.ts index f582a1acd..541970616 100644 --- a/test/flash/smoke/specs/07-flags-bridge.spec.ts +++ b/test/flash/smoke/specs/07-flags-bridge.spec.ts @@ -1,4 +1,4 @@ -import { getMe, gql, gqlOk, login, usdWalletOf } from "../client" +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 @@ -16,14 +16,36 @@ describe("Phase 4: feature flags + bridge surface", () => { walletId = usdWalletOf(await getMe(token)).id }) - it("BRIDGE-01/topup: globals.topupEnabled matches the expected flag state", async () => { - const data = await gqlOk<{ globals: { topupEnabled: boolean } }>( + // 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 ok = SMOKE.expectFlagsOff - ? data.globals.topupEnabled === false - : typeof data.globals.topupEnabled === "boolean" - expect(ok).toBe(true) + 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 @@ -32,11 +54,17 @@ describe("Phase 4: feature flags + bridge surface", () => { // 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. - const isStructured = ( + // + // 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, - ): boolean => + ): 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<{ @@ -48,7 +76,10 @@ describe("Phase 4: feature flags + bridge surface", () => { { input: { email: "smoke@example.com", full_name: "Smoke Test" } }, token, ) - expect(isStructured(res, "bridgeInitiateKyc")).toBe(true) + const failure = evaluate(res, "bridgeInitiateKyc", () => + structuredFailure(res, "bridgeInitiateKyc"), + ) + expect(failure).toBeNull() }) it("BRIDGE-05/cashout: requestCashout responds structurally (no resolver crash)", async () => { @@ -59,7 +90,10 @@ describe("Phase 4: feature flags + bridge surface", () => { { input: { walletId, amount: 100, bankAccountId: "smoke-nonexistent" } }, token, ) - expect(isStructured(res, "requestCashout")).toBe(true) + const failure = evaluate(res, "requestCashout", () => + structuredFailure(res, "requestCashout"), + ) + expect(failure).toBeNull() }) // Device-token registration touches notification infra that the mock stack From 278bebcc4756df0bd6a1a12e1edff276ac5b51a1 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 07:41:12 -0500 Subject: [PATCH 4/6] fix(smoke): run init-onchain before init-lightning in CI boot; rename MEMO_BA (typos) --- .github/workflows/smoke-test.yml | 1 + test/flash/smoke/specs/05-payments-internal.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 3e04f0886..8d30dab4f 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -33,6 +33,7 @@ jobs: 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 diff --git a/test/flash/smoke/specs/05-payments-internal.spec.ts b/test/flash/smoke/specs/05-payments-internal.spec.ts index 0df74f8d5..d0d0ecb52 100644 --- a/test/flash/smoke/specs/05-payments-internal.spec.ts +++ b/test/flash/smoke/specs/05-payments-internal.spec.ts @@ -17,7 +17,7 @@ import { SMOKE } from "../config" const describeMaybe = SMOKE.allowPayments && SMOKE.backendFull ? describe : describe.skip const MEMO_AB = `smoke SEND-01 ${process.env.SMOKE_RUN_ID || ""}`.trim() -const MEMO_BA = `smoke SEND-02 ${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, @@ -80,7 +80,7 @@ describeMaybe("Phase 2: two-account internal payments", () => { it("SEND-02: B pays A back", async () => { const balanceABefore = await usdBalance(tokenA, walletA) - const res = await sendUsd(tokenB, walletB, walletA, 2, MEMO_BA) + const res = await sendUsd(tokenB, walletB, walletA, 2, MEMO_B_TO_A) expect(res.intraLedgerUsdPaymentSend.errors).toEqual([]) expect(res.intraLedgerUsdPaymentSend.status).toBe("SUCCESS") From 028950b18a0e60450c9a74aa237773f60eb9a4c9 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 07:49:21 -0500 Subject: [PATCH 5/6] fix(smoke): recognize INVALID_FIELD as unknown-field for version-skip tolerance --- test/flash/smoke/client.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/flash/smoke/client.ts b/test/flash/smoke/client.ts index 1076cdd06..d2dbc98ab 100644 --- a/test/flash/smoke/client.ts +++ b/test/flash/smoke/client.ts @@ -44,12 +44,18 @@ export const gqlOk = async >( // 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 } }> | undefined, + errors: + | Array<{ message: string; extensions?: { code?: string; field?: string } }> + | undefined, field: string, ): boolean => (errors ?? []).some( (e) => - e.extensions?.code === "GRAPHQL_VALIDATION_FAILED" && e.message.includes(field), + // 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 => { From f2413d51d66a6c31470a0a9817bf7e6662ac2739 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 07:55:22 -0500 Subject: [PATCH 6/6] style: prettier line join in isUnknownFieldError --- test/flash/smoke/client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/flash/smoke/client.ts b/test/flash/smoke/client.ts index d2dbc98ab..4cf37e15d 100644 --- a/test/flash/smoke/client.ts +++ b/test/flash/smoke/client.ts @@ -52,8 +52,7 @@ export const isUnknownFieldError = ( (errors ?? []).some( (e) => // Apollo Server validation (direct API endpoints) - (e.extensions?.code === "GRAPHQL_VALIDATION_FAILED" && - e.message.includes(field)) || + (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), )