v0.1.5: detect free windows in the estimate fallback + add API tests
All checks were successful
build-apk / build (push) Successful in 49m41s
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:
parent
f8d9e2970c
commit
7212badd2d
9 changed files with 276 additions and 13 deletions
32
parksmarter-client/test/estimates.test.mjs
Normal file
32
parksmarter-client/test/estimates.test.mjs
Normal 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);
|
||||
});
|
||||
81
parksmarter-client/test/http.test.mjs
Normal file
81
parksmarter-client/test/http.test.mjs
Normal 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);
|
||||
});
|
||||
70
parksmarter-client/test/integration.test.mjs
Normal file
70
parksmarter-client/test/integration.test.mjs
Normal 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');
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue