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
9 changes: 8 additions & 1 deletion apps/mobile/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
# key, which must never leave the server).
#
# ---------------------------------------------------------------------------
# How these reach the app: app.config.js copies them into `expo.extra`, and
# src/lib/config.ts reads them from there and THROWS at import if any of the
# three below is missing — naming it. A build without them fails on launch with
# that message rather than reporting `undefined` endpoints as network errors.
#
# This file covers LOCAL builds only. `.env` is gitignored and EAS does not
# upload it, so a cloud build (`eas build --profile ...`) sees only the `env`
# block in eas.json — today that is EXPO_PUBLIC_APP_ENV and nothing else.
Expand All @@ -19,7 +24,9 @@
# which also decides whether the values live in eas.json or on expo.dev.
# ---------------------------------------------------------------------------

# development | staging | production — drives log level, mirrors APP_ENV on web/api.
# development | staging | production — drives log level, mirrors APP_ENV on
# web/api. Anything else (including unset) is treated as production, i.e. warn
# and error only.
EXPO_PUBLIC_APP_ENV=development

EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/__tests__/authStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Session } from '@supabase/supabase-js';

import { useAuthStore } from '../src/store/authStore';

const session = { access_token: 'token', user: { id: 'user-1' } } as unknown as Session;

const originalClearSession = useAuthStore.getState().clearSession;

beforeEach(() => {
// clearSession is restored too: one test replaces it to observe the
// delegation, and zustand's setState merges rather than resets.
useAuthStore.setState({ session: null, clearSession: originalClearSession });
});

describe('useAuthStore', () => {
it('starts signed out', () => {
expect(useAuthStore.getState().session).toBeNull();
});

it('stores the session passed to setSession', () => {
useAuthStore.getState().setSession(session);

expect(useAuthStore.getState().session).toBe(session);
});

it('accepts null through setSession — onAuthStateChange emits it on SIGNED_OUT', () => {
useAuthStore.getState().setSession(session);
useAuthStore.getState().setSession(null);

expect(useAuthStore.getState().session).toBeNull();
});

it('resets to null on clearSession', () => {
useAuthStore.getState().setSession(session);
useAuthStore.getState().clearSession();

expect(useAuthStore.getState().session).toBeNull();
});

it('routes setSession(null) through clearSession, so teardown cannot be skipped', () => {
// Both null paths are live — onAuthStateChange uses setSession(null) and
// screens call clearSession — so cleanup added to clearSession later has
// to run for either caller.
const clearSession = jest.fn(() => {
useAuthStore.setState({ session: null });
});
useAuthStore.setState({ session, clearSession });

useAuthStore.getState().setSession(null);

expect(clearSession).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().session).toBeNull();
});
});
72 changes: 72 additions & 0 deletions apps/mobile/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Boundary: the build-time configuration gate. Everything downstream assumes
* Config's three URL/key fields are non-empty strings, so this is the only
* place that can catch a build shipped without them (issue #95).
*/
import { mockConfig } from './helpers/mockConfig';

type Extra = Record<string, unknown> | undefined;

function loadConfig(extra: Extra) {
let loaded: typeof import('../src/lib/config') | undefined;
jest.isolateModules(() => {
jest.doMock('expo-constants', () => ({
__esModule: true,
default: { expoConfig: extra === undefined ? null : { extra } },
}));
// eslint-disable-next-line @typescript-eslint/no-require-imports
loaded = require('../src/lib/config');
});
return loaded!;
}

// The same fixture the consumers mock Config with — here it stands in for the
// manifest `extra` those values are read out of, so the two cannot drift.
const COMPLETE: Record<string, unknown> = mockConfig();

afterEach(() => {
jest.resetModules();
jest.dontMock('expo-constants');
});

describe('Config', () => {
it('exposes every value when the manifest is complete', () => {
const { Config } = loadConfig(COMPLETE);

expect(Config.supabaseUrl).toBe('https://project.supabase.co');
expect(Config.supabasePublishableKey).toBe('sb_publishable_test');
expect(Config.apiUrl).toBe('https://api.example.com');
expect(Config.appEnv).toBe('development');
});

it('throws naming every missing variable when the manifest has no extra', () => {
expect(() => loadConfig(undefined)).toThrow(
/Missing required env vars: supabaseUrl, supabasePublishableKey, apiUrl/
);
});

it('names only the variable that is missing', () => {
expect(() => loadConfig({ ...COMPLETE, apiUrl: undefined })).toThrow(
/Missing required env vars: apiUrl/
);
});

it('treats an empty string as missing — an unset EAS variable arrives that way', () => {
expect(() => loadConfig({ ...COMPLETE, supabaseUrl: '' })).toThrow(
/Missing required env vars: supabaseUrl/
);
});

it.each(['development', 'staging', 'production'])('passes through appEnv %s', (appEnv) => {
expect(loadConfig({ ...COMPLETE, appEnv }).Config.appEnv).toBe(appEnv);
});

it.each([
['absent', undefined],
['unrecognised', 'prod'],
])('falls back to production when appEnv is %s', (_label, appEnv) => {
// CLAUDE.md > Logging Strategy: an undefined environment logs at the
// quietest level rather than the most verbose.
expect(loadConfig({ ...COMPLETE, appEnv }).Config.appEnv).toBe('production');
});
});
34 changes: 34 additions & 0 deletions apps/mobile/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* The threshold numbers are the whole of REQ-17's watchlist states 5 and 6 —
* they are the spec, not an implementation detail — so they are asserted
* against the requirement rather than against themselves.
*/
import { colors } from '../src/constants/colors';
import { STALE_DISCONNECTED_MS, STALE_WARNING_MS } from '../src/constants/thresholds';

describe('staleness thresholds', () => {
it('warns at 60 seconds', () => {
expect(STALE_WARNING_MS).toBe(60 * 1000);
});

it('reports disconnected at 5 minutes', () => {
expect(STALE_DISCONNECTED_MS).toBe(5 * 60 * 1000);
});

it('orders the warning before the disconnected state', () => {
expect(STALE_WARNING_MS).toBeLessThan(STALE_DISCONNECTED_MS);
});
});

describe('colors', () => {
it('exposes every token as a 6-digit hex value', () => {
Object.entries(colors).forEach(([token, value]) => {
expect(`${token}: ${value}`).toMatch(/: #[0-9A-F]{6}$/);
});
});

it('keeps the two banner families distinct', () => {
expect(colors.warningBackground).not.toBe(colors.dangerBackground);
expect(colors.warningText).not.toBe(colors.dangerText);
});
});
19 changes: 19 additions & 0 deletions apps/mobile/__tests__/helpers/mockConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { AppEnv } from '../../src/lib/config';

/**
* The Config shape as every consumer's test mocks it.
*
* Shared because a jest.mock factory is never checked against the real
* module: when Config gains a field, a stale copy hands the module under test
* `undefined` silently instead of failing the build. One fixture means one
* place to add it.
*
* The AppEnv import is type-only, so it is erased at compile time and does not
* execute config.ts's import-time throw.
*/
export const mockConfig = (appEnv: AppEnv = 'development') => ({
supabaseUrl: 'https://project.supabase.co',
supabasePublishableKey: 'sb_publishable_test',
apiUrl: 'https://api.example.com',
appEnv,
});
Loading