mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||
import { ChargingLookupController, ChargingLookupDto } from '../../../src/nest/roadtrip/charging-lookup.controller';
|
||
import {
|
||
matchChargingLocation,
|
||
normalizeCharging,
|
||
type ChargingLocation,
|
||
type ChargingSource,
|
||
type ChargingTariff,
|
||
} from '../../../src/nest/roadtrip/charging.helpers';
|
||
import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp';
|
||
import { ChargingService, empty } from '../../../src/nest/roadtrip/charging.service';
|
||
import { chargingLookupSchema } from '@trek/shared';
|
||
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
const now = Date.parse('2026-09-12T12:00:00Z');
|
||
const source: ChargingSource = {
|
||
uid: 'test',
|
||
name: 'Test operator',
|
||
public_url: 'https://example.com',
|
||
attribution_license: 'CC-0',
|
||
attribution_contributor: null,
|
||
realtime_data_updated_at: new Date(now).toISOString(),
|
||
realtime_status: 'ACTIVE',
|
||
};
|
||
const station: ChargingLocation = {
|
||
id: '1',
|
||
source: 'test',
|
||
name: 'Test charging',
|
||
operator: { name: 'Test' },
|
||
address: 'Main street',
|
||
city: 'City',
|
||
last_updated: new Date(now).toISOString(),
|
||
coordinates: { latitude: 48, longitude: 11 },
|
||
charging_pool: [
|
||
{
|
||
evses: [
|
||
{
|
||
uid: '1',
|
||
evse_id: 'DE*TEST*1',
|
||
status: 'AVAILABLE',
|
||
last_updated: new Date(now - 86400000).toISOString(),
|
||
connectors: [{ tariff_ids: ['original'] }],
|
||
},
|
||
{ uid: '2', status: 'CHARGING', last_updated: new Date(now).toISOString(), connectors: [] },
|
||
{ uid: '3', status: 'STATIC', last_updated: new Date(now).toISOString(), connectors: [] },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
const tariff: ChargingTariff = {
|
||
id: '2',
|
||
original_id: 'original',
|
||
source: 'test',
|
||
currency: 'EUR',
|
||
last_updated: new Date(now).toISOString(),
|
||
elements: [
|
||
{ price_components: [{ type: 'ENERGY', price: 0.5, taxes: [{ percentage: 19 }] }] },
|
||
{ restrictions: { min_duration: 1800 }, price_components: [{ type: 'TIME', price: 12, taxes: [] }] },
|
||
],
|
||
};
|
||
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
describe('Open charging data', () => {
|
||
it('keeps unchanged availability live when the source has refreshed, and does not count unknown as free', () => {
|
||
const info = normalizeCharging(station, source, [], now);
|
||
expect(info).toMatchObject({ available: 1, total: 3, unknown: 1, stale: false });
|
||
});
|
||
it('never shows old, future or failed source status as current availability', () => {
|
||
for (const date of [new Date(now - 3600000).toISOString(), new Date(now + 3600000).toISOString(), null]) {
|
||
expect(normalizeCharging(station, { ...source, realtime_data_updated_at: date }, [], now).available).toBeNull();
|
||
}
|
||
expect(normalizeCharging(station, { ...source, realtime_status: 'FAILED' }, [], now).stale).toBe(true);
|
||
});
|
||
it('matches tariff identifiers only within their source and retains taxes and conditions', () => {
|
||
const info = normalizeCharging(station, source, [tariff, { ...tariff, source: 'other' }], now);
|
||
expect(info.tariffs).toHaveLength(1);
|
||
expect(info.tariffs[0].components[0]).toMatchObject({ price: 0.595, taxIncluded: true, conditional: false });
|
||
expect(info.tariffs[0].components[1]).toMatchObject({ afterSeconds: 1800, conditional: true });
|
||
});
|
||
it('rejects distant or ambiguous stations instead of attaching another operator’s data', () => {
|
||
expect(matchChargingLocation([station], 0, 0, 'Test')).toBeNull();
|
||
expect(
|
||
matchChargingLocation([station, { ...station, id: 'other', operator: { name: 'Other' } }], 48, 11, 'Charging'),
|
||
).toBe('ambiguous');
|
||
expect(matchChargingLocation([station], 48, 11, 'Test')).toEqual(station);
|
||
});
|
||
it('checks trip/place scope and ignores non-charging places', async () => {
|
||
const findChargingProbe = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(undefined)
|
||
.mockResolvedValueOnce({ name: 'Hotel', lat: 48, lng: 11, stop_type: 'hotel' });
|
||
const service = new ChargingService({ findChargingProbe } as never);
|
||
await expect(service.read(1, 2)).rejects.toThrow();
|
||
expect((await service.read(1, 2)).status).toBe('unknown');
|
||
expect(findChargingProbe).toHaveBeenCalledWith(2, 1);
|
||
});
|
||
it('coalesces station requests and preserves availability if tariffs fail', async () => {
|
||
const fetcher = 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('', { status: 503 });
|
||
});
|
||
vi.stubGlobal('fetch', fetcher);
|
||
const service = new ChargingService({
|
||
findChargingProbe: async () => ({ name: 'Test', lat: 48, lng: 11, stop_type: 'charging' }),
|
||
} as never);
|
||
const [first, second] = await Promise.all([service.read(1, 2), service.read(1, 2)]);
|
||
expect(first).toEqual(second);
|
||
expect(first.status).toBe('ok');
|
||
expect(first.pricesUnavailable).toBe(true);
|
||
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', () => {
|
||
/**
|
||
* The clock, pinned to the instant the fixtures above are written at.
|
||
*
|
||
* `normalizeCharging` takes the current time as an argument and the tests further up
|
||
* pass it; the service does not, so it reads the real one, and its freshness window is
|
||
* twenty minutes. Left alone, every answer that goes through the service is stale by
|
||
* construction and `available` comes back null, which is the field this is here to
|
||
* prove survives the trip from the registry to the dialog.
|
||
*
|
||
* Only `Date.now` is pinned, never the timers: the outbound requests carry an
|
||
* `AbortSignal.timeout` that has to keep running.
|
||
*/
|
||
beforeEach(() => {
|
||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||
});
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
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({
|
||
findChargingProbe: async () => ({ 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({ findChargingProbe: async () => undefined } 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({ findChargingProbe: async () => undefined } 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 findChargingProbe = vi.fn();
|
||
await new ChargingService({ findChargingProbe } as never).lookup(48, 11, 'Test');
|
||
expect(findChargingProbe).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 tripsRepo = { findAccessible: vi.fn(async () => false) };
|
||
const tool = new ChargingMcp(charging as never, tripsRepo 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();
|
||
tripsRepo.findAccessible.mockResolvedValue(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);
|
||
}
|
||
});
|
||
});
|