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.
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.




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.
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.
| Layer | Runner | What 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. |
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:
- 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.
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.
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.
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 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:
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([]);
});
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.
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 correlatedLATERAL … LIMIT 1: 8s timeout → ~700ms.
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.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.Feature-sliced React Native app, a strictly layered data path, five custom native modules, and a scheduled data platform that repairs itself.
Screen → Hook → Service → Supabase client. Route files are thin shells with no logic.auth, scan, grading, collection, market, profile, admin — each owning its screens, hooks, services and schemas.any, no @ts-ignore, with zod validating both form input and model output.| Layer | Technology |
|---|---|
| App | React Native, Expo (dev client), TypeScript (strict), NativeWind |
| Navigation & state | Expo Router (file-based), React Query, Zustand |
| Validation | react-hook-form + zod (forms and model output) |
| Native | Kotlin, CameraX — 5 custom Expo modules |
| On-device AI | TFLite (react-native-fast-tflite), ML Kit text recognition |
| ML training | PyTorch pipeline, exported to TFLite (separate Python environment) |
| Backend | Supabase — Auth, Postgres + RLS, Storage, Edge Functions (Deno) |
| Testing | Jest, Vitest (against a real local Supabase), Deno test |
| CI/CD & jobs | GitHub Actions, pg_cron, EAS Build |
| Web | Static site + React admin panel, Cloudflare Pages |
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.