mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
170 lines
7.4 KiB
TypeScript
170 lines
7.4 KiB
TypeScript
import { GoogleRouteService } from '../../../src/nest/roadtrip/google-route.service';
|
|
import { safeFetchFollow } from '../../../src/utils/ssrfGuard';
|
|
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
|
|
|
import Database from 'better-sqlite3';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchFollow: vi.fn() }));
|
|
|
|
/** The stub db is a bare object, so the UnitOfWork rides its own handle; nothing
|
|
* in this suite opens a real transaction. */
|
|
const routeTestDb = new Database(':memory:');
|
|
|
|
async function setup() {
|
|
const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn().mockResolvedValue({ name: null, address: null }) };
|
|
const tripsRepo = { findAccessible: vi.fn(async () => ({ user_id: 7 })) };
|
|
const usersRepo = { getRole: vi.fn(async () => 'user') };
|
|
const places = { create: vi.fn((_trip: string, stop: { name: string }) => ({ id: stop.name })), broadcast: vi.fn() };
|
|
const assignments = {
|
|
dayExists: vi.fn(() => true),
|
|
createAssignment: vi.fn((dayId: number, placeId: string) => ({ dayId, placeId })),
|
|
broadcast: vi.fn(),
|
|
reconcile: vi.fn(),
|
|
};
|
|
const permissions = { checkPermission: vi.fn(() => true) };
|
|
const service = new GoogleRouteService(
|
|
maps as never,
|
|
places as never,
|
|
assignments as never,
|
|
permissions as never,
|
|
await createTestUnitOfWork(routeTestDb),
|
|
tripsRepo as never,
|
|
usersRepo as never,
|
|
);
|
|
return { service, maps, tripsRepo, usersRepo, places, assignments, permissions };
|
|
}
|
|
|
|
const input = {
|
|
dayId: 3,
|
|
stops: [
|
|
{ name: 'Munich', lat: 48, lng: 11 },
|
|
{ name: 'Rome', lat: 41, lng: 12 },
|
|
],
|
|
};
|
|
|
|
describe('Google route import', () => {
|
|
it('reads coordinates in route order without geocoding or writing', async () => {
|
|
const { service, maps, places } = await setup();
|
|
const preview = await service.preview(
|
|
'https://www.google.com/maps/dir/?api=1&origin=48,11&destination=41,12&waypoints=47,11',
|
|
);
|
|
expect(preview.stops.map((stop) => stop.lat)).toEqual([48, 47, 41]);
|
|
expect(maps.geocodeQuery).not.toHaveBeenCalled();
|
|
expect(places.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps unresolved and dynamic locations visible in their original position', async () => {
|
|
const { service, maps } = await setup();
|
|
maps.geocodeQuery.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ lat: 41, lng: 12 });
|
|
const preview = await service.preview('https://www.google.com/maps/dir/Your+location/Munich/Rome');
|
|
expect(preview.stops).toEqual([
|
|
{ name: 'Your location', lat: null, lng: null },
|
|
{ name: 'Munich', lat: null, lng: null },
|
|
{ name: 'Rome', lat: 41, lng: 12 },
|
|
]);
|
|
expect(maps.geocodeQuery.mock.calls).toEqual([['Munich'], ['Rome']]);
|
|
});
|
|
|
|
it('names coordinate stops without moving their supplied positions', async () => {
|
|
const { service, maps } = await setup();
|
|
maps.reverseGeocode
|
|
.mockResolvedValueOnce({ name: 'Munich', address: 'Munich' })
|
|
.mockRejectedValueOnce(new Error('unavailable'));
|
|
const preview = await service.preview('https://google.com/maps/dir/48,11/41,12');
|
|
expect(preview.stops).toEqual([
|
|
{ name: 'Munich', lat: 48, lng: 11 },
|
|
{ name: '41, 12', lat: 41, lng: 12 },
|
|
]);
|
|
expect(maps.reverseGeocode).toHaveBeenCalledWith('48', '11', undefined, {
|
|
lane: 'background',
|
|
timeoutMs: 5000,
|
|
locality: true,
|
|
});
|
|
});
|
|
|
|
it('rejects unrelated URLs and oversized routes without fetching or truncating', async () => {
|
|
const { service, maps } = await setup();
|
|
for (const url of [
|
|
'https://example.com/maps/dir/A/B',
|
|
'http://google.com/maps/dir/A/B',
|
|
'https://user:pass@google.com/maps/dir/A/B',
|
|
'https://google.com/search?q=A',
|
|
]) {
|
|
await expect(service.preview(url)).rejects.toThrow('Use a Google Maps directions link.');
|
|
}
|
|
await expect(
|
|
service.preview(`https://google.com/maps/dir/${Array.from({ length: 31 }, (_, i) => `A${i}`).join('/')}`),
|
|
).rejects.toThrow();
|
|
expect(maps.geocodeQuery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves short links through the safe fetcher and checks the destination', async () => {
|
|
const { service } = await setup();
|
|
const cancel = vi.fn();
|
|
vi.mocked(safeFetchFollow).mockResolvedValueOnce({
|
|
url: 'https://google.com/maps/dir/48,11/41,12',
|
|
body: { cancel },
|
|
} as never);
|
|
expect((await service.preview('https://maps.app.goo.gl/example')).stops).toHaveLength(2);
|
|
expect(cancel).toHaveBeenCalled();
|
|
vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://example.com/maps/dir/A/B', body: null } as never);
|
|
await expect(service.preview('https://maps.app.goo.gl/example')).rejects.toThrow();
|
|
});
|
|
|
|
it('checks membership, both editing permissions and the target day before writing', async () => {
|
|
const { service, tripsRepo, permissions, assignments, places } = await setup();
|
|
tripsRepo.findAccessible.mockResolvedValueOnce(undefined as never);
|
|
await expect(service.import(1, 7, input)).rejects.toThrow();
|
|
permissions.checkPermission.mockReturnValueOnce(false);
|
|
await expect(service.import(1, 7, input)).rejects.toThrow();
|
|
assignments.dayExists.mockReturnValueOnce(false);
|
|
await expect(service.import(1, 7, input)).rejects.toThrow();
|
|
expect(places.create).not.toHaveBeenCalled();
|
|
expect(assignments.dayExists).toHaveBeenCalledWith('3', '1');
|
|
});
|
|
|
|
it('appends stops in order with car transport and broadcasts after saving', async () => {
|
|
const { service, places, assignments, permissions } = await setup();
|
|
expect(await service.import(1, 7, input)).toEqual({ imported: 2 });
|
|
expect(places.create.mock.calls.map((call) => call[1].name)).toEqual(['Munich', 'Rome']);
|
|
expect(places.create).toHaveBeenCalledWith(
|
|
'1',
|
|
expect.objectContaining({ transport_mode: 'car', duration_minutes: 0 }),
|
|
);
|
|
expect(assignments.createAssignment.mock.calls).toEqual([
|
|
[3, 'Munich'],
|
|
[3, 'Rome'],
|
|
]);
|
|
expect(permissions.checkPermission).toHaveBeenCalledWith('day_edit', 'user', 7, 7, false);
|
|
expect(assignments.reconcile).toHaveBeenCalledWith(1, undefined);
|
|
});
|
|
|
|
it('rolls back every created stop when any assignment fails and sends no events', async () => {
|
|
// The write goes through this.uow.transactional now, not db.transaction, and
|
|
// the UnitOfWork MikroORM binds in setup() shares routeTestDb's connection —
|
|
// so a raw statement against that same handle is what proves the rollback.
|
|
const { service, places, assignments } = await setup();
|
|
routeTestDb.exec('CREATE TABLE stops (name TEXT)');
|
|
places.create.mockImplementation((_trip, stop) => {
|
|
// test-sql-allow: the stops probe table exists only for this test; no entity maps it.
|
|
routeTestDb.prepare('INSERT INTO stops VALUES (?)').run(stop.name);
|
|
return { id: stop.name };
|
|
});
|
|
assignments.createAssignment
|
|
.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' }))
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('write failed');
|
|
});
|
|
try {
|
|
await expect(service.import(1, 7, input)).rejects.toThrow('write failed');
|
|
// test-sql-allow: reads back the unmapped stops probe table on the same handle.
|
|
expect(routeTestDb.prepare('SELECT * FROM stops').all()).toEqual([]);
|
|
expect(places.broadcast).not.toHaveBeenCalled();
|
|
expect(assignments.broadcast).not.toHaveBeenCalled();
|
|
} finally {
|
|
routeTestDb.exec('DROP TABLE stops');
|
|
}
|
|
});
|
|
});
|