diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index 1e02585..5a37e39 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -48,6 +48,11 @@ jobs: - name: Build the API client run: npm run build --workspace parksmarter-client + # Unit tests (mocked fetch) gate the build; integration tests skip here + # because the runner can't reach the IP-restricted non-prod instances. + - name: Run client tests + run: npm test --workspace parksmarter-client + # RN 0.79 wants a specific NDK; install it if the image doesn't ship it (best-effort). - name: Ensure NDK run: | diff --git a/app/app.json b/app/app.json index c552a52..3b6ab44 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.1.4", + "version": "0.1.5", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 4, + "versionCode": 5, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/screens/StartSessionScreen.tsx b/app/src/screens/StartSessionScreen.tsx index 32ae51c..a525d5c 100644 --- a/app/src/screens/StartSessionScreen.tsx +++ b/app/src/screens/StartSessionScreen.tsx @@ -17,10 +17,12 @@ import { useTheme } from '@/theme/ThemeContext'; import { scheduleExpiryReminder } from '@/notifications/localReminders'; import { logLine } from '@/features/diagnostics/fileLogger'; import type { RootStackParamList } from '@/navigation/RootNavigator'; -import type { - CreditCardDetail, - ParkingDetail, - VehicleDetail, +import { + isFreeEstimate, + ladderAllFree, + type CreditCardDetail, + type ParkingDetail, + type VehicleDetail, } from 'parksmarter-client'; type SessionRoute = RouteProp; @@ -40,16 +42,26 @@ const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`; * works. Build a price ladder from ParkingEstimate (single) across the zone's * Min/Max time so those zones stay purchasable instead of showing a blank screen. */ -async function buildSingleLadder(base: { +export interface SingleLadderResult { + /** MaxTime 0 / all-$0.00 => currently free; show the free banner, not a ladder. */ + free: boolean; + ladder: ParkingDetail[]; +} + +export async function buildSingleLadder(base: { zoneId: number; spaceId: number; customerId: number; vehicleId: number; -}): Promise { +}): Promise { const probe = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: 30, creditCardId: 0 }); if ((probe as any)?.Response?.Status === 'Error') { throw new Error((probe as any).Response?.Message || 'Estimate unavailable for this space.'); } + // MaxTime 0 means no paid time is available right now — this is a free window, + // not a purchasable zone. Don't build a bogus $0.00 ladder. + if (isFreeEstimate(probe)) return { free: true, ladder: [] }; + const min = Math.max(5, Number(probe.MinTime) || 5); const max = Math.max(min, Number(probe.MaxTime) || min); const span = max - min; @@ -70,7 +82,10 @@ async function buildSingleLadder(base: { } }), ); - return rungs.filter((r): r is ParkingDetail => r != null); + const ladder = rungs.filter((r): r is ParkingDetail => r != null); + // If every rung came back $0.00, it's effectively a free window too. + if (ladderAllFree(ladder)) return { free: true, ladder: [] }; + return { free: false, ladder }; } /** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */ @@ -107,6 +122,8 @@ export function StartSessionScreen() { const [loading, setLoading] = useState(true); const [paying, setPaying] = useState(false); const [ladderError, setLadderError] = useState(null); + // Free detected from the estimate (MaxTime 0 / $0.00) rather than the policy. + const [estimatedFree, setEstimatedFree] = useState(false); // Load the account's vehicles + cards and pick the defaults. useEffect(() => { @@ -129,6 +146,7 @@ export function StartSessionScreen() { if (vehicleId == null || space?.SpaceId == null) return; setLoading(true); setLadderError(null); + setEstimatedFree(false); const base = { zoneId: zone.ZoneId!, spaceId: space.SpaceId!, @@ -151,7 +169,13 @@ export function StartSessionScreen() { `[ESTIMATE] multi ${multiErrored ? 'errored' : 'empty'} for zone=${base.zoneId} ` + `space=${base.spaceId}; falling back to single`, ); - details = await buildSingleLadder(base); + const fb = await buildSingleLadder(base); + if (fb.free) { + logLine(`[ESTIMATE] zone=${base.zoneId} is currently free (MaxTime 0)`); + setEstimatedFree(true); + return; + } + details = fb.ladder; } if (!details.length) { throw new Error('No parking options are available for this space right now.'); @@ -283,6 +307,22 @@ export function StartSessionScreen() { ); } + if (estimatedFree) { + return ( + + {zone.ZoneName} + + + Parking is currently free + + + No payment needed right now — just park. + + + + ); + } + if (ladderError && !ladder.length) { return ( diff --git a/parksmarter-client/package.json b/parksmarter-client/package.json index f78b4f5..5a13323 100644 --- a/parksmarter-client/package.json +++ b/parksmarter-client/package.json @@ -12,12 +12,22 @@ "import": "./dist/index.js" } }, - "files": ["dist", "src", "README.md"], + "files": [ + "dist", + "src", + "README.md" + ], "scripts": { "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test test/*.test.mjs" }, - "keywords": ["parksmarter", "parking", "ips", "api-client"], + "keywords": [ + "parksmarter", + "parking", + "ips", + "api-client" + ], "license": "MIT", "devDependencies": { "typescript": "^5.4.0" diff --git a/parksmarter-client/src/estimates.ts b/parksmarter-client/src/estimates.ts new file mode 100644 index 0000000..7eff5f2 --- /dev/null +++ b/parksmarter-client/src/estimates.ts @@ -0,0 +1,24 @@ +/** + * Helpers for interpreting ParkSmarter parking estimates. + * + * Pulled out as pure functions so the "is this a free window?" decision is + * testable without the app, the network, or React Native. The BigBrainParking + * app's single-estimate fallback uses these to avoid showing a bogus "$0.00" + * purchase ladder during free windows. + */ + +/** + * A free window: the single estimate reports no purchasable time. Live captures + * show free zones return `MaxTime: 0` (and `$0.00`), while paid zones return a + * positive MaxTime. Anything <= 0 (or non-numeric) counts as free. + */ +export function isFreeEstimate(probe: { MaxTime?: number | string | null } | null | undefined): boolean { + return (Number(probe?.MaxTime) || 0) <= 0; +} + +/** Every rung of a built ladder is $0.00 — effectively a free window too. */ +export function ladderAllFree( + rungs: ReadonlyArray<{ ParkingCost?: number | string | null }>, +): boolean { + return rungs.length > 0 && rungs.every((r) => (Number(r?.ParkingCost) || 0) === 0); +} diff --git a/parksmarter-client/src/index.ts b/parksmarter-client/src/index.ts index 7e89e7e..c59e334 100644 --- a/parksmarter-client/src/index.ts +++ b/parksmarter-client/src/index.ts @@ -17,4 +17,5 @@ export type { HttpMethod, HttpClientConfig, } from './http.js'; +export { isFreeEstimate, ladderAllFree } from './estimates.js'; export * from './types.js'; diff --git a/parksmarter-client/test/estimates.test.mjs b/parksmarter-client/test/estimates.test.mjs new file mode 100644 index 0000000..b73142c --- /dev/null +++ b/parksmarter-client/test/estimates.test.mjs @@ -0,0 +1,32 @@ +// Pure unit tests for the estimate-classification helpers (no network). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isFreeEstimate, ladderAllFree } from '../dist/index.js'; + +test('isFreeEstimate: MaxTime 0 is a free window', () => { + assert.equal(isFreeEstimate({ MaxTime: 0 }), true); + assert.equal(isFreeEstimate({ MaxTime: '0' }), true); // API sends numbers as strings +}); + +test('isFreeEstimate: positive MaxTime is a paid zone', () => { + assert.equal(isFreeEstimate({ MaxTime: 33 }), false); + assert.equal(isFreeEstimate({ MaxTime: '33' }), false); +}); + +test('isFreeEstimate: missing/null MaxTime => no paid time => free', () => { + assert.equal(isFreeEstimate({}), true); + assert.equal(isFreeEstimate(null), true); + assert.equal(isFreeEstimate(undefined), true); +}); + +test('ladderAllFree: every rung $0.00 => true', () => { + assert.equal(ladderAllFree([{ ParkingCost: '0.00' }, { ParkingCost: 0 }]), true); +}); + +test('ladderAllFree: any priced rung => false', () => { + assert.equal(ladderAllFree([{ ParkingCost: '0.00' }, { ParkingCost: '0.10' }]), false); +}); + +test('ladderAllFree: empty ladder => false (nothing to conclude)', () => { + assert.equal(ladderAllFree([]), false); +}); diff --git a/parksmarter-client/test/http.test.mjs b/parksmarter-client/test/http.test.mjs new file mode 100644 index 0000000..004e404 --- /dev/null +++ b/parksmarter-client/test/http.test.mjs @@ -0,0 +1,81 @@ +// Client HTTP behavior via an injected mock fetch (no network). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ParkSmarterClient, ParkSmarterApiError, MemoryTokenStore } from '../dist/index.js'; + +/** Build a fake fetch that records calls and returns canned responses. */ +function mockFetch(handler) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), init }); + const { status = 200, body = {} } = handler({ url: String(url), init, calls }) ?? {}; + const text = typeof body === 'string' ? body : JSON.stringify(body); + return { + status, + ok: status >= 200 && status < 300, + text: async () => text, + headers: new Map(), + }; + }; + return { fetchImpl, calls }; +} + +test('every request carries Application_Token and a fresh X-Request-Id', async () => { + const { fetchImpl, calls } = mockFetch(() => ({ body: { Response: { Status: 'Success' } } })); + const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl }); + await ps.getApplicationValidity(); + const h = calls[0].init.headers; + assert.ok(h['Application_Token'], 'Application_Token present'); + assert.match(h['X-Request-Id'], /[0-9a-f]{8}-[0-9a-f]{4}/i, 'X-Request-Id is a UUID'); +}); + +test('single-estimate query serializes the ParkSmarter param names', async () => { + const { fetchImpl, calls } = mockFetch(() => ({ + body: { ParkingDetail: {}, MinTime: 5, MaxTime: 33, Response: { Status: 'Success' } }, + })); + const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl }); + await ps.getParkingEstimateSingle({ + zoneId: 113165, spaceId: 329522, customerId: 217, vehicleId: 42, + durationInMinutes: 30, creditCardId: 0, + }); + const url = calls[0].url; + assert.match(url, /ZoneID=113165/); + assert.match(url, /SpaceID=329522/); + assert.match(url, /CustomerID=217/); + assert.match(url, /ParkingDuration=30/); + assert.match(url, /VehicleID=42/); +}); + +test('a non-2xx response throws ParkSmarterApiError carrying the status', async () => { + const { fetchImpl } = mockFetch(() => ({ status: 500, body: { message: 'boom' } })); + const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl }); + await assert.rejects( + () => ps.getApplicationValidity(), + (e) => e instanceof ParkSmarterApiError && e.status === 500, + ); +}); + +test('rolling Auth_Token from the Response envelope is stored and re-sent', async () => { + const tokens = new MemoryTokenStore(); + const { fetchImpl, calls } = mockFetch(() => ({ + body: { Response: { Status: 'Success', Auth_Token: 'REFRESHED123' } }, + })); + const ps = new ParkSmarterClient({ environment: 'prodv2', fetchImpl, tokens }); + await ps.getUserDetail(); + await ps.getUserDetail(); + assert.equal(calls[1].init.headers['Auth_Token'], 'REFRESHED123'); + assert.equal(await tokens.getAuthToken(), 'REFRESHED123'); +}); + +test('a 401 clears the auth token and invokes onUnauthorized', async () => { + const tokens = new MemoryTokenStore(); + await tokens.setAuthToken('OLD'); + let unauthorized = 0; + const { fetchImpl } = mockFetch(() => ({ status: 401, body: { Response: { Status: 'Error' } } })); + const ps = new ParkSmarterClient({ + environment: 'prodv2', fetchImpl, tokens, onUnauthorized: () => { unauthorized += 1; }, + }); + await assert.rejects(() => ps.getUserDetail()); + assert.equal(unauthorized, 1); + assert.equal(await tokens.getAuthToken(), null); +}); diff --git a/parksmarter-client/test/integration.test.mjs b/parksmarter-client/test/integration.test.mjs new file mode 100644 index 0000000..797627c --- /dev/null +++ b/parksmarter-client/test/integration.test.mjs @@ -0,0 +1,70 @@ +// Live integration tests against a ParkSmarter instance, using the Sandpoint +// zones. Targets the `test` instance by default (PS_TEST_ENV to override). +// +// NOTE: the non-prod instances (dev/stage/test) are IP-restricted to IPS's +// network and return 403 "Web App - Unavailable" from the public internet, so +// these SKIP with a clear reason unless the instance is reachable. To run them +// live against prod (read-only) with the Sandpoint zones: +// PS_TEST_ENV=prodv2 node --test test/integration.test.mjs +// (needs parksmarter-client/.creds.json for the authenticated calls). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { ParkSmarterClient, isFreeEstimate } from '../dist/index.js'; + +const ENV = process.env.PS_TEST_ENV || 'test'; +const SANDPOINT = { latitude: 48.27538, longitude: -116.54779 }; +const credsUrl = new URL('../.creds.json', import.meta.url); +const creds = existsSync(credsUrl) ? JSON.parse(readFileSync(credsUrl, 'utf8')) : {}; + +// Probe reachability once; if the instance blocks us, skip the whole suite. +let reason = false; +try { + const av = await new ParkSmarterClient({ environment: ENV, timeoutMs: 12000 }).getApplicationValidity(); + if (!av || (!av.SessionId && !av.Config)) throw new Error('no bootstrap payload'); +} catch (e) { + reason = `'${ENV}' instance unreachable (${e.name} ${e.status ?? ''}); non-prod is IP-restricted. ` + + `Run PS_TEST_ENV=prodv2 with .creds.json to exercise these live.`; +} + +async function client() { + const ps = new ParkSmarterClient({ environment: ENV, timeoutMs: 15000 }); + if (creds.phoneNumber) await ps.loginWithPhone({ phoneNumber: creds.phoneNumber, password: creds.password }); + return ps; +} + +test(`[${ENV}] ApplicationValidity bootstraps a session`, { skip: reason }, async () => { + const av = await new ParkSmarterClient({ environment: ENV }).getApplicationValidity(); + assert.ok(av.SessionId || av.Config, 'expected a bootstrap payload'); +}); + +test(`[${ENV}] Sandpoint (DSB) zones are returned near 48.27,-116.55`, { skip: reason }, async () => { + const ps = await client(); + const m = await ps.getMetersByLocation(SANDPOINT); + assert.ok(Array.isArray(m.Zones) && m.Zones.length > 0, 'expected zones near Sandpoint'); + const names = m.Zones.map((z) => z.ZoneName || ''); + assert.ok(names.some((n) => /^DSB/i.test(n)), `expected DSB* zones, got: ${names.slice(0, 8).join(',')}`); +}); + +test(`[${ENV}] single estimate is well-formed and free/paid is coherent`, { skip: reason }, async () => { + const ps = await client(); + const me = creds.phoneNumber ? await ps.getUserDetail() : null; + const vehicleId = me?.VehicleDetails?.[0]?.VehicleID ?? 0; + const m = await ps.getMetersByLocation(SANDPOINT); + const z = m.Zones[0]; + const sp = z.Spaces?.[0]; + const base = { zoneId: z.ZoneId, spaceId: sp.SpaceId, customerId: z.CustomerId, vehicleId }; + + const single = await ps.getParkingEstimateSingle({ ...base, durationInMinutes: 30, creditCardId: 0 }); + assert.equal(single.Response?.Status, 'Success', 'single estimate should succeed'); + + // Multi may be empty or error for some zones/windows — assert only the shape. + const multi = await ps.getParkingEstimateMulti(base); + assert.ok(Object.prototype.hasOwnProperty.call(multi, 'ParkingDetail'), 'multi has ParkingDetail'); + + if (isFreeEstimate(single)) { + assert.equal(Number(single.MaxTime) || 0, 0, 'free window => MaxTime 0'); + } else { + assert.ok(Number(single.ParkingDetail?.ParkingCost) >= 0, 'paid window => a numeric cost'); + } +});