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:
Maurice
2026-09-14 12:28:47 +02:00
parent accdd5905e
commit 00d00be1c0
37 changed files with 507 additions and 21 deletions
+88 -1
View File
@@ -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);
}
});
});