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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions test/flash/smoke/README.md
Original file line number Diff line number Diff line change
@@ -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=<test-account otp> \
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`).
201 changes: 201 additions & 0 deletions test/flash/smoke/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { execFileSync } from "child_process"

import { SMOKE } from "./config"

type GqlResponse<T = Record<string, unknown>> = {
data?: T
errors?: Array<{ message: string; extensions?: { code?: string } }>
}

export const gql = async <T = Record<string, unknown>>(
query: string,
variables: Record<string, unknown> = {},
token?: string,
): Promise<GqlResponse<T>> => {
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<T>
}

// Throws with the full error list when the response has transport-level errors.
export const gqlOk = async <T = Record<string, unknown>>(
query: string,
variables: Record<string, unknown> = {},
token?: string,
): Promise<T> => {
const res = await gql<T>(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<string> => {
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<MeInfo> => {
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<number | null> => {
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<TxNode[]> => {
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 <T>(
fn: () => Promise<T>,
check: (v: T) => boolean,
attempts = 30,
delayMs = 1000,
): Promise<T> => {
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
}
Loading
Loading