Building — and testing — a production mobile app, solo

Chasing Tens is a React Native collection tracker and on-device card pre-grading app, backed by Supabase and a self-healing nightly data platform. This page is about how it is engineered and, mostly, how it is verified.

Pre-release Solo build Android · React Native + Expo TypeScript (strict) Supabase / Postgres
1,243automated tests
172test files
98%statement coverage gate
4test layers in CI
157kcard catalog, self-hosted

01 What it does

Point a phone at a trading card. The app identifies it on-device, prices it against its own market data, and files it into a collection. Optionally, a deeper guided scan produces a grading prediction — the estimate a collector needs before paying $25 to send a card to a professional grader.

The engineering constraint that shapes everything: all image analysis runs on the device. No cloud inference, no per-scan cost, works with no signal at a card show. That pushes real computer-vision work into custom Kotlin modules instead of an API call.

Collection dashboard showing a portfolio value chart, total value, and top daily movers.
CollectionValue history, movers, live pricing
Market Movers screen showing a market index, sector board, release rhythm and unusual price moves.
Market MoversDaily signals computed in-house
Pre-grading screen listing previous scan reports with predicted grades.
Pre-gradingEvery scan keeps a full report
A scan report showing a predicted grade of 3.0, a detected crease, a preliminary-grading disclosure, and a grade probability range.
Scan reportDefect findings + honest confidence

Screenshots are from a development device. The collection shown is seeded test data — built from import/export testing and repeated duplicate scans — not a real portfolio, and the totals are not meaningful.

One product decision worth calling out, because it is really a quality decision. The scan report in the fourth screenshot says its own grade is preliminary, and it says so in the UI, not in a footnote. The scoring model is mid-recalibration, so the app distinguishes what it has actually measured from what it is estimating, and it declines to output a subgrade it could not measure rather than inventing a plausible number.

Shipping a confident wrong number is the worst failure mode this product has. Most of the testing below exists to make that failure mode loud.

02 Quality engineering

There is no QA team here — which meant the automation had to be good enough to act as one. Four independent test layers run on every push, gated in CI, with a fifth layer of read-only probes running against production every night.

The four layers, and what each one is actually for

LayerRunnerWhat it proves
Unit & component
138 files
Jest Pure logic and React components in isolation — grade math, ROI arithmetic, price-ladder resolution, formatting, hooks, rendering states.
Golden Jest Locks report math against committed fixtures, so a refactor cannot silently move a grade or a financial figure. Numeric drift becomes a diff.
Stack (integration)
29 files
Vitest vs. a real local Supabase Runs against an actual Postgres started by the Supabase CLI in CI — real RLS, real SQL functions, real constraints. This is where security invariants are asserted.
Edge functions
13 files
Deno Server-side handlers tested in their own runtime rather than mocked away — pricing, telemetry, webhooks, notifications.
Production canaries Nightly GitHub Actions Read-only assertions against live production: security posture still holds, data pipelines are still fresh. Catches drift no code test can see.

How the integration layer actually runs

The stack suite has an explicit rule behind it: no mocks for anything the database owns. Row-level security policies, SQL functions, triggers, constraints and grants are the parts most likely to be wrong and the least likely to be caught by a mock that returns whatever the test author expected. So CI stands up a real one.

Every run boots an actual Postgres through the Supabase CLI, applies the migrations, and binds the suite to whatever that instance reports — rather than to hardcoded environment values that can silently drift from it:

.github/workflows/ci.yml — the stack job
- name: Start local Supabase
  run: npx supabase start -x studio,imgproxy,inbucket,edge-runtime,logflare,vector

# Bind the suite to the instance that actually started, not to hardcoded values.
- name: Export stack keys
  run: npx supabase status -o env | sed 's/"//g' >> "$GITHUB_ENV"

- name: Stack suite
  run: npm run test:stack
  env:
    SUPABASE_URL: ${{ env.API_URL }}
    SUPABASE_ANON_KEY: ${{ env.ANON_KEY }}
    SUPABASE_SERVICE_ROLE_KEY: ${{ env.SERVICE_ROLE_KEY }}

Services under test are the same modules the app ships, not reimplementations. Getting React Native code to run under Node meant aliasing the native surface — AsyncStorage to an in-memory store, plus thin stubs for the Expo modules some services import at load time — so the service layer executes unmodified while the native surface it merely touches gets out of the way. Test files run serially rather than in parallel, because they share seeded database state; that is a deliberate trade of wall time for determinism.

The interesting part is the limitation. In the local CLI stack, the service-role client does not bypass RLS for writes through the REST layer, and the JWT role claims the staff guards read are never populated. So a whole class of privileged setup — a staff profile, an internal message, a resolved ticket — simply cannot be created the way production creates it.

The escape hatch is an explicit one: a direct superuser Postgres connection used only for seeding, with session_replication_role = replica to skip triggers, pointed at the dockerized dev database and never at production. It is documented in the helper it lives in, and the suite is scoped to what it can honestly prove.

A green suite should mean what it says. Papering over that gap with a mock would have produced more passing tests and less information.

The coverage gate is a policy, not a number

CI enforces 98% statements, 98% lines, 99% functions, 92% branches and fails the build below any of them. The rule attached to it matters more than the threshold: when a gate fails, it gets fixed with a test — never by lowering the number. A threshold that moves to accommodate untested code is not a gate.

A related habit, learned the hard way twice: a branch is never called green off a bare jest run. Local Jest and the CI invocation are different commands with different results, and only the exact CI command counts.

Security regressions become permanent tests

Every security finding — from a penetration review, an incident, or a code audit — is closed with a fix and a test that fails if the fix is ever reverted. The stack suite reads like an incident log, because that is what it is: privilege-column guards, paywall-bypass prevention, anonymous-enumeration checks, cross-user foreign-key isolation.

tests/stack/trainingIngest.deid.stack.test.ts — a compliance rule asserted as schema
describe('training-data de-identification invariant (@security)', () => {
  it('training_candidates carries no user_id / scan_report_id join column', async () => {
    const cols = await sql<{ column_name: string }>(
      `select column_name from information_schema.columns
       where table_schema = 'public' and table_name = 'training_candidates'`,
    );
    const names = cols.map((c) => c.column_name);
    // The two join paths to a user, dropped by the de-identification migration.
    expect(names).not.toContain('user_id');
    expect(names).not.toContain('scan_report_id');
  });
});

Model-training images must never be joinable back to a user. That is a privacy promise in a policy document — and a policy document cannot fail a build. Asserting it against information_schema means the promise is enforced by CI: reintroduce the column, and the pipeline goes red.

A test written directly from an incident

A production trigger had a project URL hardcoded into a SQL function body. Because every CI run spins up its own database from the same migrations, that hardcoded URL meant test runs were firing outbound calls at the production service — a quiet cross-environment leak that showed up as a flood of gateway errors before anyone connected it to CI.

The fix moved URLs into a config table. The tripwire below is what makes sure nobody reintroduces the pattern — it greps every function body in the database for the production project reference:

tests/stack/supportNotifyConfig.stack.test.ts — the tripwire
it('no public function body hardcodes the prod project ref (tripwire)', async () => {
  const offenders = await sql<{ proname: string }>(
    `select p.proname
     from pg_proc p
     join pg_namespace n on n.oid = p.pronamespace
     where n.nspname = 'public' and p.prosrc ilike '%<prod-project-ref>%'`,
  );
  expect(offenders).toEqual([]);
});

CI, and a quality dashboard fed by it

The pipeline runs five jobs — lint/typecheck, unit, stack, edge functions, and a record-run job that collects every suite's results as artifacts and writes each run into a Postgres table. An admin panel in the app reads that table, so pass/fail, coverage, and covered areas have a visible trend over time instead of living only in expiring GitHub logs.

The same suites run again nightly on a schedule, plus the production canaries. A nightly failure opens or updates a dated GitHub issue automatically with what broke and when, so a silent overnight regression cannot go unnoticed.

A query plan that only failed at scale

Market queries computed a date cutoff inside the statement. Postgres chose a plan that scanned backwards discarding rows, and every window hit an 8-second statement timeout once the dataset grew.

Rewritten to resolve dates in PL/pgSQL and lock the plan with a correlated LATERAL … LIMIT 1: 8s timeout → ~700ms.

A limitation documented, not hidden

The local Supabase CLI's service key cannot bypass RLS for PostgREST writes, so staff-role and service-table operations are not fully assertable through the client in the stack suite.

Scoped the suite to what it can honestly prove, and wrote down the gap and its fix (a direct superuser connection) rather than letting a green suite imply more coverage than it has.

Model output is schema-validated

On-device inference output is parsed through a zod schema before it can reach the UI. A malformed or out-of-range result is rejected as a failed scan.

The same schemas are exercised in unit tests, so the contract between model and app is verified in CI rather than trusted at runtime.

03 Architecture

Feature-sliced React Native app, a strictly layered data path, five custom native modules, and a scheduled data platform that repairs itself.

App

On-device imaging

Backend & data platform

04 Stack

LayerTechnology
AppReact Native, Expo (dev client), TypeScript (strict), NativeWind
Navigation & stateExpo Router (file-based), React Query, Zustand
Validationreact-hook-form + zod (forms and model output)
NativeKotlin, CameraX — 5 custom Expo modules
On-device AITFLite (react-native-fast-tflite), ML Kit text recognition
ML trainingPyTorch pipeline, exported to TFLite (separate Python environment)
BackendSupabase — Auth, Postgres + RLS, Storage, Edge Functions (Deno)
TestingJest, Vitest (against a real local Supabase), Deno test
CI/CD & jobsGitHub Actions, pg_cron, EAS Build
WebStatic site + React admin panel, Cloudflare Pages

05 Source access & status

Chasing Tens is a commercial product in pre-release, and the repository is private — it carries partner agreements under confidentiality terms alongside the application code.