v0.1.5: detect free windows in the estimate fallback + add API tests
All checks were successful
build-apk / build (push) Successful in 49m41s

App:
- When the single-estimate fallback sees MaxTime 0 (or an all-$0.00 ladder),
  show "Parking is currently free" instead of a bogus $0.00 purchase ladder.
  Free-detection extracted to tested client helpers (isFreeEstimate/ladderAllFree).

Client + tests (node:test, zero deps):
- src/estimates.ts: pure isFreeEstimate/ladderAllFree helpers (exported).
- test/estimates + test/http (mocked fetch): headers, query serialization,
  error->ParkSmarterApiError, rolling Auth_Token refresh, 401 clears token.
- test/integration: live checks against the Sandpoint (DSB) zones; targets the
  `test` instance by default and SKIPS when it's unreachable (non-prod is
  IP-restricted -> 403). Verified 3/3 green against prodv2 read-only.
- CI runs `npm test` as a build gate.

Note: dev/stage/test instances return 403 "Web App - Unavailable" from the
public internet (IP-restricted). API schema is otherwise unchanged vs Jul 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-12 20:42:58 -07:00
parent f8d9e2970c
commit 7212badd2d
9 changed files with 276 additions and 13 deletions

View file

@ -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: |

View file

@ -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",

View file

@ -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<RootStackParamList, 'StartSession'>;
@ -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<ParkingDetail[]> {
}): Promise<SingleLadderResult> {
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<string | null>(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 (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
<View style={[styles.freeBanner, { backgroundColor: '#e8f5e9', marginTop: 16, alignSelf: 'stretch' }]}>
<Text style={{ color: '#2e7d32', fontWeight: '700', fontSize: 16 }}>
Parking is currently free
</Text>
<Text style={{ color: colors.subtext, marginTop: 6 }}>
No payment needed right now just park.
</Text>
</View>
</View>
);
}
if (ladderError && !ladder.length) {
return (
<View style={[styles.center, { backgroundColor: colors.bg, padding: 24 }]}>

View file

@ -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"

View file

@ -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);
}

View file

@ -17,4 +17,5 @@ export type {
HttpMethod,
HttpClientConfig,
} from './http.js';
export { isFreeEstimate, ladderAllFree } from './estimates.js';
export * from './types.js';

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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');
}
});