mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChargingService } from '../../../src/nest/roadtrip/charging.service';
|
||||
import { chargingLookupSchema } from '@trek/shared';
|
||||
import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service';
|
||||
import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller';
|
||||
import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { matchChargingLocation, normalizeCharging, type ChargingLocation, type ChargingSource, type ChargingTariff } from '../../../src/nest/roadtrip/charging.helpers';
|
||||
|
||||
const now = Date.parse('2026-09-12T12:00:00Z');
|
||||
@@ -59,3 +63,86 @@ describe('Open charging data', () => {
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same data for a station nobody has added yet (#1797 feedback: "it would be useful
|
||||
* to see this data also before the add"). One service, one cache, one answer: the only
|
||||
* difference is how the station is named.
|
||||
*/
|
||||
describe('Open charging data before the stop exists', () => {
|
||||
const answers = (tariffs: unknown) => vi.fn(async (url: string) => {
|
||||
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
|
||||
if (url.includes('/locations')) return new Response(JSON.stringify({ items: [station], total_count: 1 }));
|
||||
return new Response(JSON.stringify(tariffs));
|
||||
});
|
||||
|
||||
it('answers for a coordinate and lets the saved stop reuse that answer', async () => {
|
||||
const fetcher = answers({ items: [tariff], total_count: 1 });
|
||||
vi.stubGlobal('fetch', fetcher);
|
||||
const service = new ChargingService({ get: () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }) } as never);
|
||||
|
||||
const ahead = await service.lookup(48, 11, 'Test');
|
||||
expect(ahead.status).toBe('ok');
|
||||
// The count of individual charge points, which is what "how many chargers" means.
|
||||
expect(ahead).toMatchObject({ total: 3, available: 1, unknown: 1 });
|
||||
expect(ahead.tariffs[0].components[0]).toMatchObject({ kind: 'ENERGY', taxIncluded: true });
|
||||
// Added to the trip, the same coordinate and name key the same cache entry, so the
|
||||
// stop on the rail does not pay for the answer the dialog already got.
|
||||
expect(await service.read(1, 2)).toEqual(ahead);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('never reports a station it could not identify as free', async () => {
|
||||
// Two operators within the radius is the dense case a station found along a route
|
||||
// routinely is. Returning the nearest one would attach another operator's prices.
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (url.includes('/sources')) return new Response(JSON.stringify({ items: [source] }));
|
||||
return new Response(JSON.stringify({ items: [station, { ...station, id: 'other', operator: { name: 'Other' } }], total_count: 2 }));
|
||||
}));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Charging');
|
||||
expect(info.status).toBe('ambiguous');
|
||||
expect(info.available).toBeNull();
|
||||
});
|
||||
|
||||
it('turns an unreachable source into an empty answer, not a failed request', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 503 })));
|
||||
const info = await new ChargingService({ get: () => null } as never).lookup(48, 11, 'Test');
|
||||
expect(info).toEqual({ ...empty('unavailable'), checkedAt: info.checkedAt });
|
||||
});
|
||||
|
||||
it('does not go to the database for a station that is not in it', async () => {
|
||||
vi.stubGlobal('fetch', answers({ items: [], total_count: 0 }));
|
||||
const get = vi.fn();
|
||||
await new ChargingService({ get } as never).lookup(48, 11, 'Test');
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the route and the MCP tool from one contract and one access check', async () => {
|
||||
const charging = { read: vi.fn(), lookup: vi.fn(async () => empty('unknown')) };
|
||||
const db = { canAccessTrip: vi.fn(() => false) };
|
||||
const tool = new ChargingMcp(charging as never, db as never, {} as never);
|
||||
const input = { lat: 48.1, lng: 11.5, name: 'Ladepark Nord' };
|
||||
|
||||
// A trip the caller cannot reach is refused before anything is fetched, exactly as
|
||||
// TripAccessGuard refuses it on the route.
|
||||
expect((await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never)).isError).toBe(true);
|
||||
expect(charging.lookup).not.toHaveBeenCalled();
|
||||
db.canAccessTrip.mockReturnValue(true);
|
||||
await tool.lookup({ tripId: 3, ...input }, { userId: 5 } as never);
|
||||
await new ChargingLookupController(charging as never).lookup(input as unknown as ChargingLookupDto);
|
||||
|
||||
expect(charging.lookup.mock.calls).toEqual([[48.1, 11.5, 'Ladepark Nord'], [48.1, 11.5, 'Ladepark Nord']]);
|
||||
// Both surfaces validate through the shared contract rather than a restatement of
|
||||
// it, so a bound or a length limit cannot be tightened on one side only.
|
||||
for (const bad of [{ ...input, lat: 91 }, { ...input, lng: -181 }, { ...input, name: 'x'.repeat(201) }]) {
|
||||
expect(ChargingLookupDto.schema.safeParse(bad).success).toBe(false);
|
||||
expect(chargingLookupSchema.safeParse(bad).success).toBe(false);
|
||||
}
|
||||
expect(ChargingLookupDto.schema.safeParse(input).success).toBe(true);
|
||||
const declared = getEntry(ChargingMcp as unknown as ClassRef, 'lookup')?.options as { inputSchema: Record<string, unknown> };
|
||||
expect(Object.keys(declared.inputSchema).sort()).toEqual(['lat', 'lng', 'name', 'tripId']);
|
||||
for (const [field, type] of Object.entries(chargingLookupSchema.shape)) {
|
||||
expect(declared.inputSchema[field]).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user