mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Merge dev into fix/prettier
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were resolved to dev's content, then every file dev brought in was run through prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
@@ -130,6 +130,61 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => {
|
||||
const legacyGeometry = JSON.stringify([
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
]);
|
||||
const tourGeometry = JSON.stringify([
|
||||
[48.02, 11.03, 700],
|
||||
[48.03, 11.04, 750],
|
||||
]);
|
||||
db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (3, 5, ?, ?), (4, 5, ?, ?)').run(
|
||||
'Legacy track',
|
||||
legacyGeometry,
|
||||
'Tour',
|
||||
tourGeometry,
|
||||
);
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run();
|
||||
const assignmentIds = [
|
||||
seedAssignment(3, 2, 0),
|
||||
seedAssignment(3, 3, 1),
|
||||
seedAssignment(3, 4, 2),
|
||||
seedAssignment(3, 4, 3),
|
||||
];
|
||||
|
||||
const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const assignments = res.body.assignments as Array<{
|
||||
id: number;
|
||||
place_id: number;
|
||||
order_index: number;
|
||||
tour_place_id: number | null;
|
||||
tour_route_geometry: string | null;
|
||||
}>;
|
||||
expect(assignments).toHaveLength(4);
|
||||
expect(assignments.map((a) => a.id)).toEqual(assignmentIds);
|
||||
expect(assignments.map((a) => a.order_index)).toEqual([0, 1, 2, 3]);
|
||||
expect(assignments[0]).toMatchObject({ place_id: 2, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({
|
||||
route_geometry: legacyGeometry,
|
||||
});
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined();
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 });
|
||||
expect(assignments[1]).toMatchObject({ place_id: 3, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(
|
||||
assignments.slice(2).map((a) => ({
|
||||
place_id: a.place_id,
|
||||
tour_place_id: a.tour_place_id,
|
||||
tour_route_geometry: a.tour_route_geometry,
|
||||
})),
|
||||
).toEqual([
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
]);
|
||||
});
|
||||
|
||||
it('201 create, 404 place', async () => {
|
||||
reconcileTripSkeletons.mockClear();
|
||||
const ok = await request(server)
|
||||
@@ -155,6 +210,49 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(miss.body).toEqual({ error: 'Place not found' });
|
||||
});
|
||||
|
||||
it('prevents duplicate Tour/day assignments through concurrent REST requests', async () => {
|
||||
const tourPlaceId = 20;
|
||||
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Ridge walk');
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId);
|
||||
try {
|
||||
const createTourAssignment = () =>
|
||||
request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: tourPlaceId });
|
||||
const results = await Promise.all([createTourAssignment(), createTourAssignment()]);
|
||||
|
||||
expect(results.map((result) => result.status).sort()).toEqual([201, 409]);
|
||||
expect(
|
||||
db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = 3 AND place_id = ?').get(tourPlaceId),
|
||||
).toEqual({ n: 1 });
|
||||
|
||||
const otherDay = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: tourPlaceId });
|
||||
expect(otherDay.status).toBe(201);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlaceId)).toEqual({
|
||||
n: 2,
|
||||
});
|
||||
|
||||
const firstOrdinary = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: 2 });
|
||||
const secondOrdinary = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ place_id: 2 });
|
||||
expect(firstOrdinary.status).toBe(201);
|
||||
expect(secondOrdinary.status).toBe(201);
|
||||
} finally {
|
||||
db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('200 delete assignment reconciles journey skeletons', async () => {
|
||||
reconcileTripSkeletons.mockClear();
|
||||
const id = seedAssignment();
|
||||
@@ -224,6 +322,42 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
|
||||
});
|
||||
|
||||
it('409 moving a Tour onto a day that already holds it, the row stays put; a reorder within its day still works', async () => {
|
||||
const tourPlaceId = 21;
|
||||
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (?, 5, ?)').run(tourPlaceId, 'Lake loop');
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')").run(tourPlaceId);
|
||||
try {
|
||||
seedAssignment(4, tourPlaceId, 0);
|
||||
const moving = seedAssignment(3, tourPlaceId, 0);
|
||||
const conflict = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ new_day_id: 4, order_index: 1 });
|
||||
expect(conflict.status).toBe(409);
|
||||
expect(conflict.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
expect(db.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(moving)).toEqual({
|
||||
day_id: 3,
|
||||
order_index: 0,
|
||||
});
|
||||
|
||||
const reorder = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ new_day_id: 3, order_index: 2 });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.assignment).toMatchObject({
|
||||
id: moving,
|
||||
day_id: 3,
|
||||
order_index: 2,
|
||||
tour_place_id: tourPlaceId,
|
||||
});
|
||||
} finally {
|
||||
db.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM tours WHERE place_id = ?').run(tourPlaceId);
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(tourPlaceId);
|
||||
}
|
||||
});
|
||||
|
||||
it('200 notes roundtrip: create with note, PUT edits it, GET list shows the new value (#2163)', async () => {
|
||||
const create = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare('DELETE FROM day_notes').run();
|
||||
db.prepare('DELETE FROM day_assignments').run();
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -104,6 +105,59 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', (
|
||||
expect(res.body.days[0]).toMatchObject({ id: 3, trip_id: 5, day_number: 1, assignments: [], notes_items: [] });
|
||||
});
|
||||
|
||||
it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => {
|
||||
const legacyGeometry = JSON.stringify([
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
]);
|
||||
const tourGeometry = JSON.stringify([
|
||||
[48.02, 11.03, 700],
|
||||
[48.03, 11.04, 750],
|
||||
]);
|
||||
db.prepare(
|
||||
'INSERT INTO places (id, trip_id, name, route_geometry) VALUES (2, 5, ?, ?), (3, 5, ?, ?), (4, 5, ?, ?)',
|
||||
).run('Ordinary', null, 'Legacy track', legacyGeometry, 'Tour', tourGeometry);
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run();
|
||||
const assignmentIds = [2, 3, 4, 4].map((placeId, orderIndex) =>
|
||||
Number(
|
||||
db
|
||||
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, ?)')
|
||||
.run(placeId, orderIndex).lastInsertRowid,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const assignments = res.body.days[0].assignments as Array<{
|
||||
id: number;
|
||||
order_index: number;
|
||||
tour_place_id: number | null;
|
||||
tour_route_geometry: string | null;
|
||||
place: { id: number };
|
||||
}>;
|
||||
expect(assignments).toHaveLength(4);
|
||||
expect(assignments.map((a) => a.id)).toEqual(assignmentIds);
|
||||
expect(assignments.map((a) => a.order_index)).toEqual([0, 1, 2, 3]);
|
||||
expect(assignments[0]).toMatchObject({ place: { id: 2 }, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({
|
||||
route_geometry: legacyGeometry,
|
||||
});
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined();
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 });
|
||||
expect(assignments[1]).toMatchObject({ place: { id: 3 }, tour_place_id: null, tour_route_geometry: null });
|
||||
expect(
|
||||
assignments.slice(2).map((a) => ({
|
||||
place_id: a.place.id,
|
||||
tour_place_id: a.tour_place_id,
|
||||
tour_route_geometry: a.tour_route_geometry,
|
||||
})),
|
||||
).toEqual([
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
||||
]);
|
||||
});
|
||||
|
||||
it('201 create day (real insert, auto day_number), 404 trip when not accessible', async () => {
|
||||
const ok = await request(server)
|
||||
.post('/api/trips/5/days')
|
||||
|
||||
@@ -125,6 +125,20 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.body.places[0]).toMatchObject({ id: 1, name: 'Spot', trip_id: 5, tags: [], ratings: [] });
|
||||
});
|
||||
|
||||
it('marks Tour-backed Places without changing ordinary Place rows', async () => {
|
||||
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (1, 5, 'Plain'), (2, 5, 'Tour')").run();
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (2, 'hike')").run();
|
||||
|
||||
const all = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
|
||||
expect(all.status).toBe(200);
|
||||
expect(all.body.places).toHaveLength(2);
|
||||
const tourPlaceIdById = new Map(
|
||||
all.body.places.map((place: { id: number; tour_place_id: number | null }) => [place.id, place.tour_place_id]),
|
||||
);
|
||||
expect(tourPlaceIdById.get(1)).toBeNull();
|
||||
expect(tourPlaceIdById.get(2)).toBe(2);
|
||||
});
|
||||
|
||||
it('200 list scoped to the trip', async () => {
|
||||
db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Mine')").run();
|
||||
db.prepare("INSERT INTO places (trip_id, name) VALUES (6, 'Theirs')").run();
|
||||
@@ -162,7 +176,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ ids: [1, 2, 3] });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ deleted: [1, 2], count: 2 });
|
||||
expect(ok.body).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [] });
|
||||
// The foreign trip's place is untouched.
|
||||
expect(db.prepare('SELECT id FROM places ORDER BY id').all()).toEqual([{ id: 3 }]);
|
||||
|
||||
@@ -331,7 +345,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
|
||||
const ok = await request(server).delete('/api/trips/5/places/9').set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
expect(ok.body).toEqual({ success: true, tourPlaceIds: [] });
|
||||
expect(db.prepare('SELECT id FROM places WHERE id = 9').get()).toBeUndefined();
|
||||
|
||||
const foreign = await request(server).delete('/api/trips/5/places/10').set('Cookie', sessionCookie(1));
|
||||
@@ -339,6 +353,25 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(foreign.body).toEqual({ error: 'Place not found' });
|
||||
});
|
||||
|
||||
it('DELETE :id and bulk-delete report the Tour place ids they removed', async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO places (id, trip_id, name) VALUES (20, 5, 'Tour A'), (21, 5, 'Tour B'), (22, 5, 'Plain')",
|
||||
).run();
|
||||
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (20, 'hike'), (21, 'hike')").run();
|
||||
|
||||
const single = await request(server).delete('/api/trips/5/places/20').set('Cookie', sessionCookie(1));
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body).toEqual({ success: true, tourPlaceIds: [20] });
|
||||
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 20').get()).toBeUndefined();
|
||||
|
||||
const bulk = await request(server)
|
||||
.post('/api/trips/5/places/bulk-delete')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ ids: [21, 22] });
|
||||
expect(bulk.status).toBe(200);
|
||||
expect(bulk.body).toEqual({ deleted: [21, 22], count: 2, tourPlaceIds: [21] });
|
||||
});
|
||||
|
||||
it('DELETE :id takes the expense linked to the place with it (#1298)', async () => {
|
||||
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Louvre')").run();
|
||||
db.prepare(
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Tours module e2e: every /api/trips/:tripId/tours endpoint through the real
|
||||
* JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated and
|
||||
* seeded temp SQLite db (createSnapshotTestDb()). ToursService, PlacesService,
|
||||
* AssignmentsService and TripsService run their real SQL; only the permission
|
||||
* check (a container spy), the journey hooks and the WebSocket broadcast are
|
||||
* stubbed.
|
||||
*
|
||||
* Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the
|
||||
* permission spy denies 'place_edit', user 3 has no access to either trip.
|
||||
* AssignmentsModule and TripsModule are mounted next to ToursModule so the
|
||||
* one-tour-per-day 409 on move and the trip copy run through their real routes.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
||||
import { BudgetService } from '../../src/nest/budget/budget.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { TripsModule } from '../../src/nest/trips/trips.module';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast }));
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { journeyHooks } = vi.hoisted(() => ({
|
||||
journeyHooks: {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
const OUTSIDER = 3;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
const tourBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Ridge walk',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 3,
|
||||
duration_seconds: 3600,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const TRACK_GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Alpine loop</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
|
||||
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
<trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt>
|
||||
</trkseg></trk></gpx>`;
|
||||
const FLAT_GPX = `<?xml version="1.0"?>
|
||||
<gpx version="1.1" creator="e2e"><trk><name>Flat path</name><trkseg>
|
||||
<trkpt lat="50" lon="13"/><trkpt lat="50.01" lon="13.01"/>
|
||||
</trkseg></trk></gpx>`;
|
||||
// Large enough that a guard rejecting before multer drained the body would
|
||||
// surface as a connection reset instead of a JSON response.
|
||||
const BIG_GPX = TRACK_GPX.replace('</gpx>', `<!-- ${'x'.repeat(512 * 1024)} --></gpx>`);
|
||||
|
||||
describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
RealtimeModule,
|
||||
ToursModule,
|
||||
AssignmentsModule,
|
||||
TripsModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const setAddon = (enabled: boolean) =>
|
||||
db.prepare("UPDATE addons SET enabled = ? WHERE id = 'tours'").run(enabled ? 1 : 0);
|
||||
|
||||
const createViaApi = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
||||
const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
const importGpx = (userId: number, xml: string | null, tripId = 5) => {
|
||||
const req = request(server).post(`/api/trips/${tripId}/tours/import/gpx`).set('Cookie', sessionCookie(userId));
|
||||
return xml === null
|
||||
? req.field('note', 'none')
|
||||
: req.attach('file', Buffer.from(xml), { filename: 'track.gpx', contentType: 'application/gpx+xml' });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES
|
||||
(1, 'owner', 'owner@example.test', 'x', 'user', 0),
|
||||
(2, 'viewer', 'viewer@example.test', 'x', 'user', 0),
|
||||
(3, 'outsider', 'outsider@example.test', 'x', 'user', 0)`,
|
||||
).run();
|
||||
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Alps'), (6, 1, 'Other')").run();
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run();
|
||||
db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1), (4, 5, 2)').run();
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]);
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The viewer is a trip member whose 'place_edit' is withheld; every other
|
||||
// action and user passes, so the owner can create, move and copy.
|
||||
checkPermission.mockImplementation(
|
||||
(action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === VIEWER),
|
||||
);
|
||||
setAddon(true);
|
||||
broadcast.mockClear();
|
||||
db.prepare('DELETE FROM day_assignments').run();
|
||||
db.prepare('DELETE FROM places').run();
|
||||
db.prepare('DELETE FROM trips WHERE id NOT IN (5, 6)').run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('addon and access gates', () => {
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/tours')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('404 on every route while the addon is disabled', async () => {
|
||||
const placeId = await createViaApi();
|
||||
setAddon(false);
|
||||
const off = { error: 'Tours addon is not enabled' };
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const responses = [
|
||||
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
||||
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Renamed' })),
|
||||
await importGpx(OWNER, BIG_GPX),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual(off);
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' });
|
||||
});
|
||||
|
||||
it('404 Trip not found for a user without access to the trip', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(OUTSIDER);
|
||||
const responses = [
|
||||
await request(server).get('/api/trips/5/tours').set('Cookie', cookie),
|
||||
await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie),
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server).put(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie).send(tourBody()),
|
||||
await importGpx(OUTSIDER, BIG_GPX),
|
||||
await request(server).get('/api/trips/999/tours').set('Cookie', sessionCookie(OWNER)),
|
||||
];
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('a member without place_edit can read but gets 403 on every write', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(VIEWER);
|
||||
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', cookie);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toHaveLength(1);
|
||||
expect((await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie)).status).toBe(200);
|
||||
|
||||
const writes = [
|
||||
await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody()),
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ name: 'Hijacked' })),
|
||||
await importGpx(VIEWER, BIG_GPX),
|
||||
];
|
||||
for (const res of writes) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST and GET', () => {
|
||||
it('201 creates the place, the facet and its waypoints, then lists and reads them back', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.set('X-Socket-Id', 'sock-1')
|
||||
.send(tourBody());
|
||||
expect(res.status).toBe(201);
|
||||
const placeId = res.body.tour.place_id as number;
|
||||
expect(res.body.tour).toMatchObject({
|
||||
place_id: placeId,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
duration: 60,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 3,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
});
|
||||
expect(res.body.tour.distance).toBeGreaterThan(0);
|
||||
expect(res.body.tour.elevation_gain).toBe(100);
|
||||
expect(res.body.tour.elevation_loss).toBe(50);
|
||||
expect(res.body.waypoints).toEqual(tourBody().waypoints);
|
||||
|
||||
expect(
|
||||
db.prepare('SELECT trip_id, lat, lng, transport_mode, route_geometry FROM places WHERE id = ?').get(placeId),
|
||||
).toEqual({
|
||||
trip_id: 5,
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: JSON.stringify(GEOMETRY),
|
||||
});
|
||||
const events = broadcast.mock.calls.map((c) => c[1]);
|
||||
expect(events).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
||||
expect(broadcast.mock.calls.every((c) => c[3] === 'sock-1')).toBe(true);
|
||||
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toEqual([res.body.tour]);
|
||||
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body).toEqual(res.body);
|
||||
});
|
||||
|
||||
it('lists newest first and reports planned once a day holds the tour', async () => {
|
||||
const first = await createViaApi(5, tourBody({ name: 'First' }));
|
||||
const second = await createViaApi(5, tourBody({ name: 'Second' }));
|
||||
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, 0)').run(first);
|
||||
const res = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.body.tours.map((t: { place_id: number; planned: boolean }) => [t.place_id, t.planned])).toEqual([
|
||||
[second, false],
|
||||
[first, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('400 for an invalid body, nothing written', async () => {
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const badRole = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(
|
||||
tourBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'via', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(badRole.status).toBe(400);
|
||||
const shortRoute = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ route_geometry: [[47, 11, 600]] }));
|
||||
expect(shortRoute.status).toBe(400);
|
||||
const badDifficulty = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', cookie)
|
||||
.send(tourBody({ max_hiking_difficulty: 7 }));
|
||||
expect(badDifficulty.status).toBe(400);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('GET 404 for a tour of another trip, a plain place and a non-numeric id', async () => {
|
||||
const foreign = await createViaApi(6);
|
||||
const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid);
|
||||
for (const id of [foreign, plain, 'abc', 999999]) {
|
||||
const res = await request(server).get(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Tour not found' });
|
||||
}
|
||||
const list = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.body.tours).toEqual([]);
|
||||
});
|
||||
|
||||
it('GET falls back to the geometry endpoints for a tour without saved waypoints', async () => {
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
const placeId = res.body.tours[0].place_id as number;
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.tour).toMatchObject({ place_id: placeId, has_waypoints: false });
|
||||
expect(detail.body.waypoints).toEqual([
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.02, lng: 12.02, role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('200 replaces the route, metrics and waypoints', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const geometry = [
|
||||
[46, 10, 1000],
|
||||
[46.01, 10.01, 1200],
|
||||
[46.02, 10.02, 1100],
|
||||
];
|
||||
const waypoints = [
|
||||
{ lat: 46, lng: 10, role: 'start', sequence: 0 },
|
||||
{ lat: 46.01, lng: 10.01, role: 'via', sequence: 1 },
|
||||
{ lat: 46.02, lng: 10.02, role: 'end', sequence: 2 },
|
||||
];
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(
|
||||
tourBody({
|
||||
name: 'Summit',
|
||||
route_geometry: geometry,
|
||||
waypoints,
|
||||
max_hiking_difficulty: 5,
|
||||
duration_seconds: null,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
place_id: placeId,
|
||||
name: 'Summit',
|
||||
elevation_gain: 200,
|
||||
elevation_loss: 100,
|
||||
duration: null,
|
||||
max_hiking_difficulty: 5,
|
||||
});
|
||||
expect(res.body.waypoints).toEqual(waypoints);
|
||||
expect(db.prepare('SELECT name, lat, lng, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({
|
||||
name: 'Summit',
|
||||
lat: 46,
|
||||
lng: 10,
|
||||
route_geometry: JSON.stringify(geometry),
|
||||
});
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 });
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
||||
});
|
||||
|
||||
it('404 for a tour of another trip, a plain place and a non-numeric id, nothing changes', async () => {
|
||||
const foreign = await createViaApi(6);
|
||||
const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid);
|
||||
for (const id of [foreign, plain, 'abc']) {
|
||||
const res = await request(server)
|
||||
.put(`/api/trips/5/tours/${id}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ name: 'Moved' }));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Tour not found' });
|
||||
}
|
||||
expect(db.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(foreign)).toEqual({
|
||||
trip_id: 6,
|
||||
name: 'Ridge walk',
|
||||
});
|
||||
expect(db.prepare('SELECT name, route_geometry FROM places WHERE id = ?').get(plain)).toEqual({
|
||||
name: 'Cafe',
|
||||
route_geometry: null,
|
||||
});
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(plain)).toEqual({ n: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(foreign)).toEqual({ n: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST import/gpx', () => {
|
||||
it('201 imports each track as a hike tour', async () => {
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
|
||||
expect(res.body.tours).toHaveLength(1);
|
||||
const tour = res.body.tours[0];
|
||||
expect(tour).toMatchObject({
|
||||
name: 'Alpine loop',
|
||||
tour_type: 'hike',
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
has_waypoints: false,
|
||||
});
|
||||
expect(db.prepare('SELECT trip_id FROM places WHERE id = ?').get(tour.place_id)).toEqual({ trip_id: 5 });
|
||||
expect(db.prepare('SELECT tour_type FROM tours WHERE place_id = ?').get(tour.place_id)).toEqual({
|
||||
tour_type: 'hike',
|
||||
});
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:created']));
|
||||
});
|
||||
|
||||
it('flags a track without elevation with caution', async () => {
|
||||
const res = await importGpx(OWNER, FLAT_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.caution).toBe(true);
|
||||
expect(res.body.tours[0]).toMatchObject({ match_confidence: 0.3, caution: true });
|
||||
});
|
||||
|
||||
it('a second import of the same track is skipped as a duplicate', async () => {
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).status).toBe(201);
|
||||
const again = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(again.status).toBe(201);
|
||||
expect(again.body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('400 without a file, and for a file with no track or route', async () => {
|
||||
const missing = await importGpx(OWNER, null);
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body).toEqual({ error: 'No file uploaded' });
|
||||
for (const xml of ['<not-gpx/>', '<gpx/>', '<gpx><wpt lat="1" lon="2"/></gpx>', 'plain text']) {
|
||||
const res = await importGpx(OWNER, xml);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'No track or route found in GPX file' });
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('other domains', () => {
|
||||
it('409 moving a tour onto a day that already holds it, on create and on move', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const cookie = sessionCookie(OWNER);
|
||||
const onDay4 = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(onDay4.status).toBe(201);
|
||||
const onDay3 = await request(server)
|
||||
.post('/api/trips/5/days/3/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(onDay3.status).toBe(201);
|
||||
|
||||
const twice = await request(server)
|
||||
.post('/api/trips/5/days/4/assignments')
|
||||
.set('Cookie', cookie)
|
||||
.send({ place_id: placeId });
|
||||
expect(twice.status).toBe(409);
|
||||
expect(twice.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
|
||||
const moving = onDay3.body.assignment.id as number;
|
||||
const move = await request(server)
|
||||
.put(`/api/trips/5/assignments/${moving}/move`)
|
||||
.set('Cookie', cookie)
|
||||
.send({ new_day_id: 4, order_index: 1 });
|
||||
expect(move.status).toBe(409);
|
||||
expect(move.body).toEqual({ error: 'Tour is already assigned to this day' });
|
||||
expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(moving)).toEqual({ day_id: 3 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(placeId)).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('copying the trip carries its tours and waypoints onto the new places', async () => {
|
||||
const placeId = await createViaApi(
|
||||
5,
|
||||
tourBody({
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/copy')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ title: 'Alps again' });
|
||||
expect(res.status).toBe(201);
|
||||
const copyId = res.body.trip.id as number;
|
||||
|
||||
const list = await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.tours).toHaveLength(1);
|
||||
const copied = list.body.tours[0];
|
||||
expect(copied.place_id).not.toBe(placeId);
|
||||
const source = (await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER)))
|
||||
.body;
|
||||
const { place_id: _sourceId, ...sourceRest } = source.tour;
|
||||
const { place_id: _copyId, ...copyRest } = copied;
|
||||
expect(copyRest).toEqual(sourceRest);
|
||||
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/${copyId}/tours/${copied.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.waypoints).toEqual(source.waypoints);
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
it('deleting the place removes its tour and waypoints and reports the tour id', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const res = await request(server).delete(`/api/trips/5/places/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, tourPlaceIds: [placeId] });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from './test-uow';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
@@ -94,6 +95,7 @@ export async function accommodationsOver(conn: Database): Promise<Accommodations
|
||||
await createTestPlacesRepo(conn),
|
||||
await createTestTripMembersRepo(conn),
|
||||
await createTestRoadtripViasRepo(conn),
|
||||
await createTestToursRepo(conn),
|
||||
);
|
||||
return new AccommodationsService(
|
||||
permissions,
|
||||
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
sharedTestOrm,
|
||||
} from './test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -432,6 +433,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
await createTestPlacesRepo(db),
|
||||
await createTestTripMembersRepo(db),
|
||||
await createTestRoadtripViasRepo(db),
|
||||
await createTestToursRepo(db),
|
||||
);
|
||||
const accommodationsService = new AccommodationsService(
|
||||
permissionsService,
|
||||
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
createTestRoadtripDayBoundariesRepo,
|
||||
} from './test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
|
||||
import { createTestToursRepo } from './tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -389,6 +390,7 @@ export async function createPluginRpcHostFactory(db: Database.Database): Promise
|
||||
await createTestPlacesRepo(db),
|
||||
await createTestTripMembersRepo(db),
|
||||
await createTestRoadtripViasRepo(db),
|
||||
await createTestToursRepo(db),
|
||||
);
|
||||
const membership = new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db));
|
||||
const notifications = await makeNotificationsService(db, realtime);
|
||||
|
||||
@@ -51,6 +51,9 @@ const KEEP_TABLES = new Set([
|
||||
'photo_provider_fields',
|
||||
'document_providers',
|
||||
'document_provider_fields',
|
||||
// Reference data the migration that creates it writes (`tours.tour_type`
|
||||
// has an FK on it); nothing re-seeds it, so a reset must keep it.
|
||||
'tour_types',
|
||||
// Migration bookkeeping: clearing these would make the next boot replay the
|
||||
// whole history over a schema that already has it.
|
||||
'schema_version',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import type { TourWaypointsRepository } from '../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../src/db/repositories/Tours.repository';
|
||||
import { sharedTestOrm } from './test-uow';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Tours test-only repository factories, bound to a suite's own better-sqlite3
|
||||
* handle through the same memoised `sharedTestOrm` `test-uow.ts` exports, so
|
||||
* a repository built here and a `UnitOfWork` from `createTestUnitOfWork`
|
||||
* resolve the same `EntityManager` (the `todo-repos.ts` precedent).
|
||||
*/
|
||||
export function createTestToursRepo(db: Database.Database): Promise<ToursRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(Tours));
|
||||
}
|
||||
|
||||
export function createTestTourWaypointsRepo(db: Database.Database): Promise<TourWaypointsRepository> {
|
||||
return sharedTestOrm(db).then((t) => t.repo(TourWaypoints));
|
||||
}
|
||||
|
||||
/** A `tours` row for an existing place, with the defaults a GPX import writes. */
|
||||
export function createTour(
|
||||
db: Database.Database,
|
||||
placeId: number,
|
||||
overrides: Partial<{
|
||||
distance: number | null;
|
||||
match_confidence: number | null;
|
||||
max_hiking_difficulty: number;
|
||||
created_at: string;
|
||||
}> = {},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO tours (place_id, tour_type, distance, match_confidence, max_hiking_difficulty, created_at)
|
||||
VALUES (?, 'hike', ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))`,
|
||||
).run(
|
||||
placeId,
|
||||
overrides.distance ?? null,
|
||||
overrides.match_confidence ?? null,
|
||||
overrides.max_hiking_difficulty ?? 2,
|
||||
overrides.created_at ?? null,
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(res.body).toEqual({ success: true, tourPlaceIds: [] });
|
||||
|
||||
// The place is gone.
|
||||
expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeUndefined();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
absDifference,
|
||||
caseWhenEquals,
|
||||
caseWhenNotNull,
|
||||
castInteger,
|
||||
castIntegerKysely,
|
||||
coalesce,
|
||||
@@ -704,6 +705,32 @@ describe('sql-functions (sqlite)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Tours (#2586): the assignment projection's `tour_route_geometry` column.
|
||||
it('SQLF-098: caseWhenNotNull yields the THEN column only where the test column is not null', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Shown', a.id);
|
||||
testDb.prepare('UPDATE users SET display_name = NULL WHERE id = ?').run(b.id);
|
||||
const platform = t.em.getPlatform();
|
||||
|
||||
const rows = await t.em
|
||||
.createQueryBuilder(Users, 'u')
|
||||
.select(['u.id', caseWhenNotNull(platform, 'u.display_name', 'u.username').as('picked')])
|
||||
.where({ id: { $in: [a.id, b.id] } })
|
||||
.orderBy({ id: 'asc' })
|
||||
.execute('all', false);
|
||||
expect(rows).toEqual([
|
||||
{ id: a.id, picked: a.username },
|
||||
{ id: b.id, picked: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('SQLF-099: an unknown platform fails closed for caseWhenNotNull', () => {
|
||||
class FakePlatform extends Platform {}
|
||||
const foreign = new FakePlatform();
|
||||
expect(() => caseWhenNotNull(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/);
|
||||
});
|
||||
|
||||
// Task 1 fix review M1 (`PlaceShadowPicksRepository.countBySource`, PS5):
|
||||
// `countAll()`'s alias cannot be ordered by (`.orderBy({ count: 'desc' })`
|
||||
// throws "not existing property" — verified in SQLF-024's comment above);
|
||||
|
||||
@@ -30,16 +30,17 @@ describe('ALL_ENTITIES', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan 2 Task 3: every one of the 127 entities the migrated schema
|
||||
* Plan 2 Task 3: every one of the 130 entities the migrated schema
|
||||
* describes (120 rewritten + the 5 document-sync tables added this task,
|
||||
* + push_subscriptions and google_api_usage from legacy steps 245 and 254)
|
||||
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
|
||||
* + tour_types, tours and tour_waypoints from the Tours migration)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 127 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(127);
|
||||
it('ENT-003: exactly 130 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(130);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -15,17 +15,18 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
|
||||
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
|
||||
* the mechanism). This file pins the OUTPUT of that rule against the real,
|
||||
* committed entities — every one of the 127, not a sample — and reproduces
|
||||
* committed entities — every one of the 130, not a sample — and reproduces
|
||||
* the reviewer's own insert probe so a future regenerate/hand-edit that
|
||||
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
|
||||
* caller's first `em.create()`.
|
||||
*/
|
||||
/** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */
|
||||
// 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key.
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 430;
|
||||
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 440;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 127 entities', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 130 entities', () => {
|
||||
const failures: string[] = [];
|
||||
for (const schema of ALL_ENTITIES) {
|
||||
const Ctor = schema.class as new () => Record<string, unknown>;
|
||||
|
||||
@@ -28,7 +28,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
|
||||
const BASELINE = 'Migration20200101000000_baseline_schema';
|
||||
/** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */
|
||||
const UNNUMBERED = [BASELINE, 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'];
|
||||
const UNNUMBERED = [
|
||||
BASELINE,
|
||||
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
|
||||
'Migration20200101042000_tours',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
const LEGACY_FINAL_STEP = 258;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -52,9 +53,11 @@ function legacyProjectionRow(id: number): unknown {
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
|
||||
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.id = ?
|
||||
`,
|
||||
@@ -116,6 +119,36 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
|
||||
expect(await assignments.findWithPlaceAndCategory(999999)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASSIGNPLACEREPO-009: the Tour facet columns are set for a Tour and null for a plain place, even one with a route', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' });
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id);
|
||||
createTour(testDb, tourPlace.id);
|
||||
const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 });
|
||||
const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 });
|
||||
|
||||
const one = await assignments.findWithPlaceAndCategory(tour.id);
|
||||
expect(one).toStrictEqual(legacyProjectionRow(tour.id));
|
||||
expect(one).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.1,11.1]]' });
|
||||
expect(await assignments.findWithPlaceAndCategory(track.id)).toMatchObject({
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
});
|
||||
|
||||
const facets = (rows: { tour_place_id: number | null; tour_route_geometry: string | null }[]) =>
|
||||
rows.map((r) => [r.tour_place_id, r.tour_route_geometry]);
|
||||
const expected = [
|
||||
[tourPlace.id, '[[47,11],[47.1,11.1]]'],
|
||||
[null, null],
|
||||
];
|
||||
expect(facets(await assignments.listForDay(day.id))).toEqual(expected);
|
||||
expect(facets(await assignments.listWithPlaceAndCategory([day.id]))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("ASSIGNPLACEREPO-004: place_time/end_time COALESCE — the assignment's own time wins, the place's time is the fallback", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -426,6 +459,16 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
|
||||
expect(await assignments.getDayId(999999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-042 (getPlaceId): the place_id of the assignment, undefined for a missing id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
expect(await assignments.getPlaceId(a.id)).toBe(place.id);
|
||||
expect(await assignments.getPlaceId(999999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
import { TRACK_COLORS } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -835,9 +836,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
|
||||
},
|
||||
): unknown[] {
|
||||
let query = `
|
||||
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon,
|
||||
pr.country_code as country_code, pr.region_name as region_name
|
||||
FROM places p
|
||||
LEFT JOIN tours t ON t.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN place_regions pr ON pr.place_id = p.id
|
||||
WHERE p.trip_id = ?
|
||||
@@ -887,8 +889,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
|
||||
const p3 = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(p3.id, tag.id);
|
||||
createDayAssignment(testDb, day.id, p3.id);
|
||||
// p4: no category, no tag, unassigned, does NOT match the search term.
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
// p4: no category, no tag, unassigned, does NOT match the search term,
|
||||
// and a Tour, so tour_place_id is non-null on exactly one row.
|
||||
const p4 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createTour(testDb, p4.id);
|
||||
// Another trip's place, named to collide with the search term — must
|
||||
// never appear in either statement's output (both scope by trip_id).
|
||||
createPlace(testDb, otherTrip.id, { name: 'Central Perk' });
|
||||
@@ -1860,3 +1864,77 @@ describe('PlacesRepository — share.service.ts SH12 read', () => {
|
||||
expect(await places.listPublicForShare(trip.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.insertTourPlace (TO10) / updateTourRoute (TO11)', () => {
|
||||
it('PLACEREPO-TOUR-001: insertTourPlace writes the route place as a walking place and leaves the rest at its defaults', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = await places.insertTourPlace({
|
||||
trip_id: trip.id,
|
||||
name: 'Ridge walk',
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
route_geometry: '[[47,11],[47.1,11.1]]',
|
||||
});
|
||||
|
||||
expect(await places.findInTrip(id, trip.id)).toMatchObject({
|
||||
trip_id: trip.id,
|
||||
name: 'Ridge walk',
|
||||
lat: 47,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
route_geometry: '[[47,11],[47.1,11.1]]',
|
||||
duration_minutes: 60,
|
||||
reservation_status: 'none',
|
||||
category_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACEREPO-TOUR-002: updateTourRoute rewrites the route of a place of that trip, stamps updated_at, and refuses another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 });
|
||||
testDb.prepare("UPDATE places SET transport_mode = 'driving', updated_at = NULL WHERE id = ?").run(place.id);
|
||||
const route = { name: 'New', lat: 47, lng: 11, route_geometry: '[[47,11],[47.2,11.2]]' };
|
||||
|
||||
expect(await places.updateTourRoute(place.id, other.id, route)).toBe(false);
|
||||
expect(await places.findInTrip(place.id, trip.id)).toMatchObject({
|
||||
name: 'Old',
|
||||
transport_mode: 'driving',
|
||||
updated_at: null,
|
||||
});
|
||||
|
||||
expect(await places.updateTourRoute(place.id, trip.id, route)).toBe(true);
|
||||
const row = await places.findInTrip(place.id, trip.id);
|
||||
expect(row).toMatchObject({ ...route, transport_mode: 'walking' });
|
||||
expect(row?.updated_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlacesRepository.isTour / listForTrip tour_place_id', () => {
|
||||
it('PLACEREPO-TOUR-003: isTour is true only for a place with a tours row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await places.isTour(tour.id)).toBe(true);
|
||||
expect(await places.isTour(plain.id)).toBe(false);
|
||||
expect(await places.isTour(999_999)).toBe(false);
|
||||
});
|
||||
|
||||
it('PLACEREPO-TOUR-004: listForTrip carries the place id as tour_place_id for Tours and null otherwise', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
const rows = await places.listForTrip(String(trip.id), {});
|
||||
const byId = new Map(rows.map((r) => [r.id, r.tour_place_id]));
|
||||
expect(byId.get(tour.id)).toBe(tour.id);
|
||||
expect(byId.get(plain.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
|
||||
* (`insertForPlace`) and TW4 (`deleteForPlace`).
|
||||
*/
|
||||
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
|
||||
import type {
|
||||
TourWaypointRow,
|
||||
TourWaypointsRepository,
|
||||
} from '../../../../src/db/repositories/TourWaypoints.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: TourWaypointsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(TourWaypoints);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
const ROUTE: TourWaypointRow[] = [
|
||||
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1 },
|
||||
];
|
||||
|
||||
function tourInTrip() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
return { trip, place };
|
||||
}
|
||||
|
||||
describe('TourWaypointsRepository', () => {
|
||||
it('TWREPO-001: insertForPlace writes every point and listForPlace reads them back in route order', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, ROUTE);
|
||||
expect(await repo.listForPlace(place.id)).toEqual([...ROUTE].sort((a, b) => a.sequence - b.sequence));
|
||||
});
|
||||
|
||||
it('TWREPO-002: insertForPlace with no points writes nothing', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, []);
|
||||
expect(await repo.listForPlace(place.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TWREPO-003: deleteForPlace clears one tour and leaves another alone', async () => {
|
||||
const first = tourInTrip();
|
||||
const second = tourInTrip();
|
||||
await repo.insertForPlace(first.place.id, ROUTE);
|
||||
await repo.insertForPlace(second.place.id, ROUTE);
|
||||
|
||||
await repo.deleteForPlace(first.place.id);
|
||||
expect(await repo.listForPlace(first.place.id)).toEqual([]);
|
||||
expect(await repo.listForPlace(second.place.id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('TWREPO-004: listForTrip returns every waypoint of the trip with its place, ordered by place and sequence', async () => {
|
||||
const { trip, place } = tourInTrip();
|
||||
const other = tourInTrip();
|
||||
const second = createPlace(testDb, trip.id);
|
||||
createTour(testDb, second.id);
|
||||
await repo.insertForPlace(second.id, ROUTE.slice(0, 2));
|
||||
await repo.insertForPlace(place.id, ROUTE);
|
||||
await repo.insertForPlace(other.place.id, ROUTE);
|
||||
|
||||
const rows = await repo.listForTrip(trip.id);
|
||||
expect(rows.map((r) => [r.place_id, r.sequence])).toEqual([
|
||||
[place.id, 0],
|
||||
[place.id, 1],
|
||||
[place.id, 2],
|
||||
[second.id, 0],
|
||||
[second.id, 2],
|
||||
]);
|
||||
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes
|
||||
* places and assignments ask (TO3/TO4/TO5), the trip copy read and write
|
||||
* (TO6/TO8), and the create/edit writes (TO7/TO9).
|
||||
*/
|
||||
import { Tours } from '../../../../src/db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTour } from '../../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let repo: ToursRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
repo = t.repo(Tours);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
return { trip, other };
|
||||
}
|
||||
|
||||
function addWaypoint(placeId: number, sequence: number): void {
|
||||
testDb
|
||||
.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)")
|
||||
.run(placeId, sequence);
|
||||
}
|
||||
|
||||
const METRICS = {
|
||||
tour_type: 'hike',
|
||||
distance: 8.25,
|
||||
elevation_gain: 420,
|
||||
elevation_loss: 380,
|
||||
duration: 150,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 3,
|
||||
};
|
||||
|
||||
describe('ToursRepository reads', () => {
|
||||
it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const older = createPlace(testDb, trip.id, { name: 'Older' });
|
||||
const newer = createPlace(testDb, trip.id, { name: 'Newer' });
|
||||
const plain = createPlace(testDb, trip.id, { name: 'Not a tour' });
|
||||
const foreign = createPlace(testDb, other.id, { name: 'Foreign' });
|
||||
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 });
|
||||
createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 });
|
||||
createTour(testDb, foreign.id);
|
||||
createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id);
|
||||
addWaypoint(newer.id, 0);
|
||||
|
||||
expect(await repo.listForTrip(trip.id)).toEqual([
|
||||
{
|
||||
place_id: newer.id,
|
||||
name: 'Newer',
|
||||
tour_type: 'hike',
|
||||
distance: null,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 5,
|
||||
planned: 0,
|
||||
has_waypoints: 1,
|
||||
},
|
||||
{
|
||||
place_id: older.id,
|
||||
name: 'Older',
|
||||
tour_type: 'hike',
|
||||
distance: 5,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 0.3,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 1,
|
||||
has_waypoints: 0,
|
||||
},
|
||||
]);
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
|
||||
});
|
||||
|
||||
it('TOURREPO-002: listForTrip breaks a created_at tie by the newer place', async () => {
|
||||
const { trip } = fixture();
|
||||
const a = createPlace(testDb, trip.id);
|
||||
const b = createPlace(testDb, trip.id);
|
||||
createTour(testDb, a.id, { created_at: '2026-01-01 10:00:00' });
|
||||
createTour(testDb, b.id, { created_at: '2026-01-01 10:00:00' });
|
||||
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).toEqual([b.id, a.id]);
|
||||
});
|
||||
|
||||
it('TOURREPO-003: findInTrip returns the row for its own trip and undefined across trips or for a plain place', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Ridge' });
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await repo.findInTrip(trip.id, tour.id)).toMatchObject({
|
||||
place_id: tour.id,
|
||||
name: 'Ridge',
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
});
|
||||
expect(await repo.findInTrip(other.id, tour.id)).toBeUndefined();
|
||||
expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => {
|
||||
const { trip } = fixture();
|
||||
const tour = createPlace(testDb, trip.id);
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
|
||||
expect(await repo.existsForPlace(tour.id)).toBe(true);
|
||||
expect(await repo.existsForPlace(plain.id)).toBe(false);
|
||||
expect(await repo.listTourPlaceIds([plain.id, tour.id])).toEqual([tour.id]);
|
||||
expect(await repo.listTourPlaceIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOURREPO-005: isOnDay is true only for a tour already on that day, and ignores the assignment being moved', async () => {
|
||||
const { trip } = fixture();
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id);
|
||||
const plain = createPlace(testDb, trip.id);
|
||||
createTour(testDb, tour.id);
|
||||
const onDay1 = createDayAssignment(testDb, day1.id, tour.id);
|
||||
createDayAssignment(testDb, day1.id, plain.id);
|
||||
|
||||
expect(await repo.isOnDay(day1.id, tour.id)).toBe(true);
|
||||
expect(await repo.isOnDay(day2.id, tour.id)).toBe(false);
|
||||
expect(await repo.isOnDay(day1.id, plain.id)).toBe(false);
|
||||
expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursRepository writes', () => {
|
||||
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
|
||||
const { trip } = fixture();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Drawn' });
|
||||
await repo.insertTour({ place_id: place.id, ...METRICS });
|
||||
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toEqual({
|
||||
place_id: place.id,
|
||||
name: 'Drawn',
|
||||
...METRICS,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
tour_group_id: null,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
|
||||
expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 });
|
||||
|
||||
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
|
||||
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
|
||||
});
|
||||
|
||||
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
|
||||
const { trip, other } = fixture();
|
||||
const source = createPlace(testDb, trip.id);
|
||||
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
|
||||
testDb
|
||||
.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 9 WHERE place_id = ?")
|
||||
.run(source.id);
|
||||
|
||||
const rows = await repo.listRowsForTrip(trip.id);
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
place_id: source.id,
|
||||
tour_type: 'hike',
|
||||
distance: 3.5,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: 'T3',
|
||||
wanderer_ref: 'w-1',
|
||||
match_confidence: null,
|
||||
tour_group_id: 9,
|
||||
created_at: '2026-03-04 05:06:07',
|
||||
max_hiking_difficulty: 4,
|
||||
},
|
||||
]);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
|
||||
|
||||
const copy = createPlace(testDb, other.id);
|
||||
const { place_id: _sourceId, ...columns } = rows[0];
|
||||
await repo.insertCopy(copy.id, columns);
|
||||
expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* `Migration20200101042000_tours`: the Tours tables on a fresh install, and on
|
||||
* a test instance that already carries them from the pre-ORM Tours branch.
|
||||
*
|
||||
* Migrate to the step immediately before it, optionally lay down the
|
||||
* branch's own tables with rows in them, apply just this migration, assert.
|
||||
*/
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101042000_tours';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const idx = names.indexOf(TARGET);
|
||||
expect(idx).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[idx - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
async function indexNames(orm: MikroORM, table: string): Promise<string[]> {
|
||||
const rows = await rawQuery<{ name: string }>(orm, `SELECT name FROM pragma_index_list('${table}') ORDER BY name`);
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
async function seedTripWithPlace(orm: MikroORM): Promise<void> {
|
||||
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
|
||||
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
|
||||
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')");
|
||||
}
|
||||
|
||||
describe('Tours migration', () => {
|
||||
it('TOURMIG-001: a fresh install gets the three tables, the hike type and no redundant waypoint index', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(
|
||||
await rawQuery(
|
||||
orm,
|
||||
'SELECT key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order FROM tour_types',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'hike',
|
||||
label_key: 'tourTypes.hike',
|
||||
icon: 'Mountain',
|
||||
color: '#16a34a',
|
||||
routing_profile: 'pedestrian',
|
||||
is_sport: 1,
|
||||
enabled: 1,
|
||||
sort_order: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const tourColumns = await rawQuery<{ name: string; notnull: number; dflt_value: string | null; pk: number }>(
|
||||
orm,
|
||||
'SELECT name, "notnull", dflt_value, pk FROM pragma_table_info(\'tours\') ORDER BY cid',
|
||||
);
|
||||
expect(tourColumns.map((c) => c.name)).toEqual([
|
||||
'place_id',
|
||||
'tour_type',
|
||||
'distance',
|
||||
'elevation_gain',
|
||||
'elevation_loss',
|
||||
'duration',
|
||||
'difficulty',
|
||||
'wanderer_ref',
|
||||
'match_confidence',
|
||||
'tour_group_id',
|
||||
'created_at',
|
||||
'max_hiking_difficulty',
|
||||
]);
|
||||
expect(tourColumns.find((c) => c.name === 'max_hiking_difficulty')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '2',
|
||||
});
|
||||
expect(tourColumns.find((c) => c.name === 'place_id')).toMatchObject({ pk: 1 });
|
||||
|
||||
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
|
||||
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
|
||||
|
||||
const fks = await rawQuery<{ table: string; from: string; to: string; on_delete: string }>(
|
||||
orm,
|
||||
'SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tours\') UNION ALL SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tour_waypoints\') ORDER BY 1, 2',
|
||||
);
|
||||
expect(fks).toEqual([
|
||||
{ table: 'places', from: 'place_id', to: 'id', on_delete: 'CASCADE' },
|
||||
{ table: 'tour_types', from: 'tour_type', to: 'key', on_delete: 'NO ACTION' },
|
||||
{ table: 'tours', from: 'place_id', to: 'place_id', on_delete: 'CASCADE' },
|
||||
]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURMIG-002: the CHECKs refuse out-of-range values and a place delete cascades through tours to its waypoints', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
await migrateTo(orm, TARGET);
|
||||
await rawExec(orm, 'PRAGMA foreign_keys = ON');
|
||||
await seedTripWithPlace(orm);
|
||||
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (100, 'hike', 7)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'kayak')")).rejects.toThrow(
|
||||
/FOREIGN KEY/,
|
||||
);
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'hike')");
|
||||
expect(await rawQuery(orm, 'SELECT max_hiking_difficulty FROM tours')).toEqual([{ max_hiking_difficulty: 2 }]);
|
||||
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 91, 0, 'start', 0)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 0, 0, 'detour', 0)"),
|
||||
).rejects.toThrow(/CHECK/);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
|
||||
);
|
||||
await expect(
|
||||
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'via', 1)"),
|
||||
).rejects.toThrow(/UNIQUE/);
|
||||
|
||||
await rawExec(orm, 'DELETE FROM places WHERE id = 100');
|
||||
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tours')).toEqual([{ n: 0 }]);
|
||||
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tour_waypoints')).toEqual([{ n: 0 }]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('TOURMIG-003: on an instance that already has the pre-ORM Tours tables it keeps every row and drops only the redundant index', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
// The pre-ORM branch's final shape, rows and all, with its custom hike row.
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tour_types (
|
||||
key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT,
|
||||
is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`,
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#000000', 'pedestrian')",
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tours (
|
||||
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||
tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL,
|
||||
duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6))`,
|
||||
);
|
||||
await rawExec(orm, 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)');
|
||||
await rawExec(
|
||||
orm,
|
||||
`CREATE TABLE tour_waypoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
|
||||
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), sequence INTEGER NOT NULL CHECK(sequence >= 0),
|
||||
UNIQUE(place_id, sequence))`,
|
||||
);
|
||||
await rawExec(orm, 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
|
||||
await seedTripWithPlace(orm);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tours (place_id, tour_type, distance, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 4)",
|
||||
);
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
|
||||
);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await rawQuery(orm, 'SELECT key, color FROM tour_types')).toEqual([{ key: 'hike', color: '#000000' }]);
|
||||
expect(await rawQuery(orm, 'SELECT place_id, tour_type, distance, max_hiking_difficulty FROM tours')).toEqual([
|
||||
{ place_id: 100, tour_type: 'hike', distance: 12.5, max_hiking_difficulty: 4 },
|
||||
]);
|
||||
expect(await rawQuery(orm, 'SELECT role, sequence FROM tour_waypoints ORDER BY sequence')).toEqual([
|
||||
{ role: 'start', sequence: 0 },
|
||||
{ role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
|
||||
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -46,6 +46,12 @@ describe('global CSP: the other shipped raster presets (#2180)', () => {
|
||||
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
|
||||
});
|
||||
|
||||
it('allows the OpenTopoMap apex and tile shards used by the Tours planner', async () => {
|
||||
const sources = await connectSrcSources();
|
||||
expect(sources).toContain('https://tile.opentopomap.org');
|
||||
expect(sources).toContain('https://*.tile.opentopomap.org');
|
||||
});
|
||||
|
||||
it('keeps the routing host, which is a different host and covers nothing here', async () => {
|
||||
// routing.openstreetmap.de was on the list all along and looks close enough
|
||||
// to hide the gap: a CSP source matches a host, not a suffix of one.
|
||||
@@ -139,8 +145,8 @@ describe('forced-HTTPS redirect', () => {
|
||||
|
||||
it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
process.env.APP_URL = 'https://trip.pakulat.org';
|
||||
expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips');
|
||||
process.env.APP_URL = 'https://trip.example.invalid';
|
||||
expect(await redirectLocation()).toBe('https://trip.example.invalid/trips');
|
||||
});
|
||||
|
||||
it('falls back to the request host when APP_URL is unset', async () => {
|
||||
@@ -159,7 +165,7 @@ describe('forced-HTTPS redirect', () => {
|
||||
|
||||
it('leaves an already-secure request alone, and never redirects the health probe', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
process.env.APP_URL = 'https://trip.pakulat.org';
|
||||
process.env.APP_URL = 'https://trip.example.invalid';
|
||||
const app = express();
|
||||
applyGlobalMiddleware(app);
|
||||
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
||||
@@ -184,7 +190,7 @@ describe('routingCspOrigins', () => {
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
|
||||
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
|
||||
expect(routingCspOrigins(['http://192.0.2.1:5000'])).toEqual(['http://192.0.2.1:5000']);
|
||||
});
|
||||
|
||||
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../../src/nest/assignments/assignments.controller';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ConflictException, HttpException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -73,6 +73,22 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
|
||||
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
|
||||
});
|
||||
|
||||
it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => {
|
||||
const broadcast = vi.fn();
|
||||
const reconcile = vi.fn();
|
||||
const createAssignment = vi.fn().mockRejectedValue(new ConflictException('Tour is already assigned to this day'));
|
||||
const controller = new DayAssignmentsController(
|
||||
svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>),
|
||||
);
|
||||
|
||||
expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /reorder 404 day, else reorders + broadcasts', async () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTour } from '../../helpers/tours-repos';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -128,6 +130,7 @@ beforeAll(async () => {
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestRoadtripViasRepo(testDb),
|
||||
await createTestToursRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,6 +160,12 @@ function fixture() {
|
||||
return { user, trip, day, place };
|
||||
}
|
||||
|
||||
/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */
|
||||
function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') {
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId);
|
||||
createTour(testDb, placeId);
|
||||
}
|
||||
|
||||
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
@@ -307,6 +316,68 @@ describe('createAssignment', () => {
|
||||
]);
|
||||
void trip;
|
||||
});
|
||||
|
||||
it('ASG-SVC-031: rejects a duplicate Tour assignment on the same day but allows another day and ordinary Place duplicates', async () => {
|
||||
const { trip, day, place: tourPlace } = fixture();
|
||||
const secondDay = createDay(testDb, trip.id);
|
||||
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
addTourFacet(tourPlace.id);
|
||||
|
||||
const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id);
|
||||
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException);
|
||||
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow('Tour is already assigned to this day');
|
||||
const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id);
|
||||
expect(firstTourAssignment?.day_id).toBe(day.id);
|
||||
expect(otherDayTourAssignment?.day_id).toBe(secondDay.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({
|
||||
n: 2,
|
||||
});
|
||||
|
||||
await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
expect(
|
||||
testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id),
|
||||
).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => {
|
||||
const { day, place } = fixture();
|
||||
addTourFacet(place.id);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
svc.createAssignment(day.id, place.id),
|
||||
svc.createAssignment(day.id, place.id),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?')
|
||||
.get(day.id, place.id),
|
||||
).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => {
|
||||
const { trip, day, place: tourPlace } = fixture();
|
||||
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
// Geometry on a plain place (an imported track) must not leak into the Tour fields.
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id);
|
||||
addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]');
|
||||
|
||||
const tour = await svc.createAssignment(day.id, tourPlace.id);
|
||||
const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id);
|
||||
expect(tour).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.5,11.5]]' });
|
||||
expect(ordinary).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
|
||||
|
||||
const list = await svc.listDayAssignments(day.id);
|
||||
expect(list.map((a) => [a.tour_place_id, a.tour_route_geometry])).toEqual([
|
||||
[tourPlace.id, '[[47,11],[47.5,11.5]]'],
|
||||
[null, null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listDayAssignments ────────────────────────────────────────────────────────
|
||||
@@ -416,6 +487,33 @@ describe('moveAssignment', () => {
|
||||
expect((await svc.moveAssignment(a.id, day.id, null)).assignment!.order_index).toBe(0);
|
||||
expect((await svc.moveAssignment(a.id, target.id, 0)).assignment!.order_index).toBe(0);
|
||||
});
|
||||
|
||||
it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
addTourFacet(place.id);
|
||||
createDayAssignment(testDb, target.id, place.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
|
||||
|
||||
await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException);
|
||||
expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({
|
||||
day_id: day.id,
|
||||
order_index: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => {
|
||||
const { trip, day, place } = fixture();
|
||||
const target = createDay(testDb, trip.id);
|
||||
addTourFacet(place.id);
|
||||
const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 });
|
||||
|
||||
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
|
||||
createDayAssignment(testDb, target.id, ordinary.id);
|
||||
const second = createDayAssignment(testDb, day.id, ordinary.id);
|
||||
expect((await svc.moveAssignment(second.id, target.id, 1)).assignment).toMatchObject({ day_id: target.id });
|
||||
});
|
||||
});
|
||||
|
||||
// ── participants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,8 @@ function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWit
|
||||
phone: '+33 1 2345 6789',
|
||||
stop_type: null,
|
||||
fill_percent: null,
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +108,16 @@ describe('formatAssignmentWithPlace', () => {
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the Tour facet fields at the assignment level, null for an ordinary place', () => {
|
||||
expect(formatAssignmentWithPlace(makeRow(), [], [])).toMatchObject({
|
||||
tour_place_id: null,
|
||||
tour_route_geometry: null,
|
||||
});
|
||||
const geometry = '[[48.1,11.5],[48.2,11.6]]';
|
||||
const tour = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_route_geometry: geometry }), [], []);
|
||||
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
|
||||
});
|
||||
|
||||
it('passes tags and participants through untouched', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||
expect(result.place.tags).toEqual(sampleTags);
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
sharedTestOrm,
|
||||
createTestTripsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo } from '../../helpers/tours-repos';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
@@ -161,6 +162,7 @@ beforeAll(async () => {
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestRoadtripViasRepo(testDb),
|
||||
await createTestToursRepo(testDb),
|
||||
);
|
||||
accommodations = new AccommodationsService(
|
||||
permissions,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
createTestRoadtripViasRepo,
|
||||
createTestRoadtripDayBoundariesRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTour } from '../../helpers/tours-repos';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -166,6 +167,27 @@ describe('verifyTripAccess', () => {
|
||||
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getAssignmentsForDay', () => {
|
||||
it('DAY-SVC-TOUR-001: projects only facet-backed Tour geometry in both day read paths', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Hike', lat: 48.1, lng: 11.1 });
|
||||
const track = createPlace(testDb, trip.id, { name: 'Legacy track', lat: 48.2, lng: 11.2 });
|
||||
const geometry = JSON.stringify([
|
||||
[48.1, 11.1],
|
||||
[48.3, 11.3],
|
||||
]);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id IN (?, ?)').run(geometry, tour.id, track.id);
|
||||
createTour(testDb, tour.id);
|
||||
createDayAssignment(testDb, day.id, tour.id, { order_index: 0 });
|
||||
createDayAssignment(testDb, day.id, track.id, { order_index: 1 });
|
||||
|
||||
for (const assignments of [await svc.getAssignmentsForDay(day.id), (await svc.list(trip.id)).days[0].assignments]) {
|
||||
expect(assignments?.[0]).toMatchObject({ tour_place_id: tour.id, tour_route_geometry: geometry });
|
||||
expect(assignments?.[1]).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('DAY-SVC-003 — returns empty array when day has no assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { PlacesController } from '../../../src/nest/places/places.controller';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
@@ -7,6 +8,26 @@ import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
@@ -43,6 +64,10 @@ async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number
|
||||
beforeEach(() => vi.spyOn(console, 'error').mockImplementation(() => {}));
|
||||
|
||||
describe('PlacesController (parity with the legacy /api/trips/:tripId/places route)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The trip 404 for this handler is TripAccessGuard's now (see
|
||||
// trip-access.guard.test.ts and the places e2e), so it is no longer reachable
|
||||
// by calling the method directly.
|
||||
@@ -412,13 +437,27 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
new RuntimeEnvService(),
|
||||
storageStub,
|
||||
).bulkDelete(user, '5', { ids: [] }),
|
||||
).toEqual({ deleted: [], count: 0 });
|
||||
).toEqual({ deleted: [], count: 0, tourPlaceIds: [] });
|
||||
expect(removeMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('requires place_edit before a bulk deletion can reach the service', async () => {
|
||||
const removeMany = vi.fn();
|
||||
const s = svc({ canEdit: vi.fn().mockReturnValue(false), removeMany } as Partial<PlacesService>);
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1] }),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(removeMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('deletes, fires hooks + broadcasts per deleted id', async () => {
|
||||
const removeMany = vi
|
||||
.fn()
|
||||
.mockReturnValue({ deleted: [1, 2], cancelled: { reservationIds: [], budgetItemIds: [] } });
|
||||
.mockReturnValue({
|
||||
deleted: [1, 2],
|
||||
deletedTourPlaceIds: [2],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
});
|
||||
const onDeleted = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const scopedIds = vi.fn().mockReturnValue([1, 2]);
|
||||
@@ -430,7 +469,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
{ ids: [1, 2] },
|
||||
'sock',
|
||||
),
|
||||
).toEqual({ deleted: [1, 2], count: 2 });
|
||||
).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [2] });
|
||||
expect(onDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -464,7 +503,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
const removeMany = vi.fn(async () => {
|
||||
order.push('delete');
|
||||
return { deleted: [1], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
return { deleted: [1], deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
});
|
||||
const s = svc({
|
||||
removeMany,
|
||||
@@ -827,14 +866,25 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
expect(onDeleted).toHaveBeenCalledWith(9);
|
||||
expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]);
|
||||
const s = svc({
|
||||
remove: vi.fn().mockReturnValue({ deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } }),
|
||||
remove: vi
|
||||
.fn()
|
||||
.mockReturnValue({
|
||||
deleted: true,
|
||||
deletedTourPlaceIds: [9],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
}),
|
||||
broadcast: vi.fn(),
|
||||
} as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({
|
||||
success: true,
|
||||
tourPlaceIds: [9],
|
||||
});
|
||||
});
|
||||
|
||||
it('requires place_edit for single Place/Tour deletion', () => {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
|
||||
});
|
||||
|
||||
// invocationCallOrder in the case above only proves the hook was CALLED
|
||||
// first — a synchronously-resolving mock can't tell an awaited hook from a
|
||||
// detached (fire-and-forget) one. A hook stubbed with a real macrotask hop,
|
||||
@@ -848,7 +898,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
const remove = vi.fn(async () => {
|
||||
order.push('delete');
|
||||
return { deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
return { deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
|
||||
});
|
||||
const s = svc({ remove, onDeleted, broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9');
|
||||
@@ -991,7 +1041,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/image/from-file (#1242)', () => {
|
||||
it('maps every refusal to its status and broadcasts a success', async () => {
|
||||
const make = (result: unknown, extra: Partial<PlacesService> = {}) =>
|
||||
|
||||
@@ -235,6 +235,19 @@ describe('list', () => {
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Facet-backed tour' }) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id);
|
||||
|
||||
const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map((item) => [item.id, item]));
|
||||
|
||||
expect(byId.get(tour.id)).toMatchObject({ tour_place_id: tour.id });
|
||||
expect(byId.get(place.id)).toMatchObject({ tour_place_id: null });
|
||||
});
|
||||
|
||||
it('PLACE-SVC-003 — does not return places from other trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const t1 = createTrip(testDb, user.id);
|
||||
@@ -711,6 +724,66 @@ describe('remove', () => {
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-TOURS-001 — reports Tour deletion and cascades only its facet, waypoints, assignments, and links', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
||||
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
||||
const keptPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
for (const place of [tour, keptTour]) {
|
||||
testDb
|
||||
.prepare(`INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (?, 'hike', 4)`)
|
||||
.run(place.id);
|
||||
testDb
|
||||
.prepare(`INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 48, 11, 'start', 0)`)
|
||||
.run(place.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)')
|
||||
.run(day.id, place.id);
|
||||
}
|
||||
const tourFileId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'tour.gpx', 'tour.gpx')
|
||||
`,
|
||||
)
|
||||
.run(trip.id, tour.id).lastInsertRowid,
|
||||
);
|
||||
const keptFileId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'kept.gpx', 'kept.gpx')
|
||||
`,
|
||||
)
|
||||
.run(trip.id, keptTour.id).lastInsertRowid,
|
||||
);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(tourFileId, tour.id);
|
||||
|
||||
const result = await svc.remove(String(trip.id), String(tour.id));
|
||||
|
||||
expect(result).toMatchObject({ deleted: true, deletedTourPlaceIds: [tour.id] });
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(tour.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(tour.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(tour.id)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(tour.id)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(tourFileId)).toMatchObject({
|
||||
id: tourFileId,
|
||||
place_id: null,
|
||||
});
|
||||
expect(testDb.prepare('SELECT id FROM file_links WHERE file_id = ?').all(tourFileId)).toEqual([]);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptPlace.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(keptFileId)).toMatchObject({
|
||||
id: keptFileId,
|
||||
place_id: keptTour.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('PLACE-SVC-019e — a place with no booking is untouched by that', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -837,6 +910,24 @@ describe('remove', () => {
|
||||
// ── removeMany ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeMany', () => {
|
||||
it('PLACE-SVC-TOURS-003 — mixed bulk deletion reports only the deleted Tour facets', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
|
||||
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
|
||||
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
|
||||
for (const place of [tour, keptTour]) {
|
||||
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(place.id);
|
||||
}
|
||||
|
||||
const result = await svc.removeMany(String(trip.id), [ordinary.id, tour.id]);
|
||||
|
||||
expect(result.deleted).toEqual([ordinary.id, tour.id]);
|
||||
expect(result.deletedTourPlaceIds).toEqual([tour.id]);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptTour.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ToursController and ToursImportController: route metadata, delegation to
|
||||
* ToursService, and the GPX import's handler-level checks. The import is not
|
||||
* guarded (a guard answers before multer drains the body and the client sees
|
||||
* ECONNRESET), so its handler has to reproduce the guards' answers itself, in
|
||||
* the same order and with the same bodies (parity with AddonGuard and
|
||||
* TripAccessGuard).
|
||||
*/
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
||||
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
||||
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { HttpException, RequestMethod } from '@nestjs/common';
|
||||
import {
|
||||
GUARDS_METADATA,
|
||||
HTTP_CODE_METADATA,
|
||||
INTERCEPTORS_METADATA,
|
||||
METHOD_METADATA,
|
||||
PATH_METADATA,
|
||||
} from '@nestjs/common/constants';
|
||||
import type { TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike' as const,
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.02, 11.04, 630],
|
||||
] as [number, number, number][],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end' as const, sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
const file = { buffer: Buffer.from('<gpx/>'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
||||
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
function importFixture(
|
||||
o: { addon?: boolean; trip?: typeof trip | undefined; canEdit?: boolean; result?: unknown } = {},
|
||||
) {
|
||||
const addons = { isAddonEnabled: vi.fn(async () => o.addon ?? true) };
|
||||
const places = {
|
||||
verifyTripAccess: vi.fn(async () => ('trip' in o ? o.trip : trip)),
|
||||
canEdit: vi.fn(async () => o.canEdit ?? true),
|
||||
};
|
||||
const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 };
|
||||
const tours = { importGpxAsTour: vi.fn(async () => result) };
|
||||
const controller = new ToursImportController(
|
||||
tours as unknown as ToursService,
|
||||
places as unknown as PlacesService,
|
||||
addons as unknown as AddonsService,
|
||||
);
|
||||
return { addons, places, tours, controller, result };
|
||||
}
|
||||
|
||||
describe('ToursController planner routes', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, ToursController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
});
|
||||
|
||||
it('TOURS-CTL-001b: routes, methods and write permissions', () => {
|
||||
const routes = [
|
||||
['list', RequestMethod.GET, '/', undefined],
|
||||
['detail', RequestMethod.GET, ':placeId', undefined],
|
||||
['create', RequestMethod.POST, '/', 'place_edit'],
|
||||
['update', RequestMethod.PUT, ':placeId', 'place_edit'],
|
||||
] as const;
|
||||
for (const [handler, method, path, permission] of routes) {
|
||||
const fn = ToursController.prototype[handler];
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
|
||||
}
|
||||
// POST keeps Nest's default 201, as the PR's route answered.
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, ToursController.prototype.create)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.create('7', request, 'socket-1')).toBe(result);
|
||||
expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.detail('7', '1')).toBe(result);
|
||||
expect(service.getTour).toHaveBeenCalledWith('7', '1');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => {
|
||||
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
|
||||
const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
|
||||
const controller = new ToursController(service);
|
||||
|
||||
expect(await controller.update('7', '1', request, 'socket-2')).toBe(result);
|
||||
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
|
||||
});
|
||||
|
||||
it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => {
|
||||
const tours = [{ place_id: 1 }];
|
||||
const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService;
|
||||
|
||||
expect(await new ToursController(service).list('7')).toEqual({ tours });
|
||||
expect(service.listTours).toHaveBeenCalledWith('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', () => {
|
||||
it('TOURS-IMP-001: the multipart route carries only the JWT guard and checks the rest in the handler', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, ToursImportController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursImportController)).toBeUndefined();
|
||||
const handler = ToursImportController.prototype.importGpx;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => {
|
||||
const { controller, tours, places, result } = importFixture();
|
||||
|
||||
expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result);
|
||||
expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id);
|
||||
expect(places.canEdit).toHaveBeenCalledWith(trip, user);
|
||||
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
});
|
||||
|
||||
it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => {
|
||||
const allSkipped = { tours: [], caution: false, skipped: 2 };
|
||||
const { controller } = importFixture({ result: allSkipped });
|
||||
expect(await controller.importGpx(user, '7', file)).toBe(allSkipped);
|
||||
});
|
||||
|
||||
it('TOURS-IMP-004: addon off is the AddonGuard 404 before any trip lookup', async () => {
|
||||
const { controller, places, tours, addons } = importFixture({ addon: false });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Tours addon is not enabled' },
|
||||
});
|
||||
expect(addons.isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
expect(places.verifyTripAccess).not.toHaveBeenCalled();
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-005: an inaccessible trip is the TripAccessGuard 404 before any permission check', async () => {
|
||||
const { controller, places, tours } = importFixture({ trip: undefined });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '8', file))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Trip not found' },
|
||||
});
|
||||
expect(places.verifyTripAccess).toHaveBeenCalledWith('8', user.id);
|
||||
expect(places.canEdit).not.toHaveBeenCalled();
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-006: without place_edit it is the TripAccessGuard 403, checked before the file', async () => {
|
||||
const { controller, tours } = importFixture({ canEdit: false });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
||||
status: 403,
|
||||
body: { error: 'No permission' },
|
||||
});
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-007: 400 without a file', async () => {
|
||||
const { controller, tours } = importFixture();
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No file uploaded' },
|
||||
});
|
||||
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-IMP-008: 400 when the GPX has no track or route', async () => {
|
||||
const { controller } = importFixture({ result: null });
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No track or route found in GPX file' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* The tours-mode GPX import end to end through the real PlacesService and
|
||||
* ToursService over the migrated per-worker DB: the places, their track
|
||||
* colours and their tours facets commit in one transaction (PlacesService's
|
||||
* importPreparedGpx nests in ToursService's as a savepoint), any failure on
|
||||
* the way rolls all of it back, and the realtime events go out only after the
|
||||
* commit. Failures are injected with SQLite triggers on the real tables.
|
||||
*/
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { gpxParser } from '../../../src/nest/places/places.helpers';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import {
|
||||
createTestJourneysRepo,
|
||||
createTestJourneyContributorsRepo,
|
||||
createTestJourneyTripsRepo,
|
||||
createTestJourneyEntriesRepo,
|
||||
createTestJourneyPhotosRepo,
|
||||
createTestJourneyEntryPhotosRepo,
|
||||
} from '../../helpers/journey-repos';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import {
|
||||
createTestUnitOfWork,
|
||||
createTestAppSettingsRepo,
|
||||
createTestUsersRepo,
|
||||
createTestTagsRepo,
|
||||
createTestPlaceRatingsRepo,
|
||||
createTestAssignmentParticipantsRepo,
|
||||
createTestPlacesRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestDayAssignmentsRepo,
|
||||
createTestCategoriesRepo,
|
||||
createTestTripsRepo,
|
||||
createTestCollectionPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const mixedGpx = Buffer.from(`<gpx>
|
||||
<wpt lat="1" lon="2"><name>Ignored POI</name></wpt>
|
||||
<rte><desc>Route description</desc><rtept lat="48" lon="11"/><rtept lat="48.01" lon="11.01"/></rte>
|
||||
<trk><name>Ridge</name><desc>Track description</desc><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
|
||||
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
|
||||
</gpx>`);
|
||||
|
||||
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
|
||||
|
||||
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
|
||||
async function makePlacesService(): Promise<PlacesService> {
|
||||
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
|
||||
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
||||
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
||||
const storage = makeStorageFixture('').storage;
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
return new PlacesService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
new RealtimeService(),
|
||||
new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota),
|
||||
new QueryHelpersService(
|
||||
await createTestTagsRepo(testDb),
|
||||
await createTestPlaceRatingsRepo(testDb),
|
||||
await createTestAssignmentParticipantsRepo(testDb),
|
||||
),
|
||||
new UnsplashService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
await createTestUsersRepo(testDb),
|
||||
new RuntimeEnvService(),
|
||||
storage,
|
||||
),
|
||||
photoCacheStub,
|
||||
new JourneyDomainService(
|
||||
new RealtimeService(),
|
||||
new TrekPhotoRegistrationService(
|
||||
orm.repo(TrekPhotos),
|
||||
orm.repo(TripPhotos),
|
||||
await createTestJourneyPhotosRepo(testDb),
|
||||
),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestJourneysRepo(testDb),
|
||||
await createTestJourneyContributorsRepo(testDb),
|
||||
await createTestJourneyTripsRepo(testDb),
|
||||
await createTestJourneyEntriesRepo(testDb),
|
||||
await createTestTripsRepo(testDb),
|
||||
await createTestJourneyPhotosRepo(testDb),
|
||||
await createTestJourneyEntryPhotosRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
),
|
||||
storage,
|
||||
await accommodationsOver(testDb),
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
await createTestTagsRepo(testDb),
|
||||
await createTestPlaceRatingsRepo(testDb),
|
||||
await createTestTripMembersRepo(testDb),
|
||||
await createTestDayAssignmentsRepo(testDb),
|
||||
await createTestCategoriesRepo(testDb),
|
||||
await createTestTripsRepo(testDb),
|
||||
await createTestBudgetItemsRepo(testDb),
|
||||
await createTestCollectionPlacesRepo(testDb),
|
||||
);
|
||||
}
|
||||
|
||||
let uow: UnitOfWork;
|
||||
let places: PlacesService;
|
||||
let tours: ToursService;
|
||||
let tripId: string;
|
||||
let broadcast: MockInstance<PlacesService['broadcast']>;
|
||||
|
||||
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
|
||||
beforeAll(async () => {
|
||||
uow = await createTestUnitOfWork(testDb);
|
||||
places = await makePlacesService();
|
||||
tours = new ToursService(
|
||||
uow,
|
||||
places,
|
||||
await createTestToursRepo(testDb),
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
const { user } = createUser(testDb);
|
||||
tripId = String(createTrip(testDb, user.id).id);
|
||||
broadcast = vi.spyOn(places, 'broadcast').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of TRIGGERS) testDb.exec(`DROP TRIGGER IF EXISTS ${name}`);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
function expectEmpty() {
|
||||
for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
const seen: { inTransaction: boolean; tours: number }[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') });
|
||||
});
|
||||
|
||||
const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!;
|
||||
|
||||
// The tours transaction, with importPreparedGpx and its colouring nested inside as savepoints.
|
||||
expect(transactional.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.tours).toHaveLength(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.caution).toBe(true);
|
||||
expect(result.tours[0]).toMatchObject({
|
||||
name: 'walk',
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
has_waypoints: false,
|
||||
caution: true,
|
||||
match_confidence: 0.3,
|
||||
});
|
||||
expect(result.tours[1]).toMatchObject({
|
||||
name: 'Ridge',
|
||||
elevation_gain: 50,
|
||||
elevation_loss: 30,
|
||||
caution: false,
|
||||
match_confidence: 1,
|
||||
});
|
||||
|
||||
const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
description: string;
|
||||
route_geometry: string;
|
||||
route_color: string;
|
||||
}[];
|
||||
expect(rows.map((row) => row.trip_id)).toEqual([Number(tripId), Number(tripId)]);
|
||||
expect(rows.map((row) => row.description)).toEqual(['Route description', 'Track description']);
|
||||
expect(JSON.parse(rows[0].route_geometry)).toEqual([
|
||||
[48, 11],
|
||||
[48.01, 11.01],
|
||||
]);
|
||||
expect(JSON.parse(rows[1].route_geometry)).toEqual([
|
||||
[49, 12, 100],
|
||||
[49.01, 12.01, 150],
|
||||
[49.02, 12.02, 120],
|
||||
]);
|
||||
expect(new Set(rows.map((row) => row.route_color)).size).toBe(2);
|
||||
expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all()).toEqual(
|
||||
rows.map((row) => ({ place_id: row.id, tour_type: 'hike' })),
|
||||
);
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ inTransaction: false, tours: 2 },
|
||||
{ inTransaction: false, tours: 2 },
|
||||
{ inTransaction: false, tours: 2 },
|
||||
]);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
|
||||
expect(broadcast.mock.calls[0]).toEqual([
|
||||
tripId,
|
||||
'tours:changed',
|
||||
{ placeIds: rows.map((row) => row.id) },
|
||||
'socket',
|
||||
]);
|
||||
for (const call of broadcast.mock.calls.slice(1)) {
|
||||
expect(call[3]).toBe('socket');
|
||||
expect((call[2] as { place: { route_color: string } }).place.route_color).toBeTruthy();
|
||||
}
|
||||
expect(broadcast.mock.calls.slice(1).map((call) => (call[2] as { place: { id: number } }).place.id)).toEqual(
|
||||
result.tours.map((tour) => tour.place_id),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 2])('rolls back every carrier and facet when facet %i fails', async (ordinal) => {
|
||||
testDb.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours
|
||||
WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1}
|
||||
BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back earlier carriers when the later Place insert fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure');
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<not-gpx/>',
|
||||
'<gpx/>',
|
||||
'<gpx><wpt lat="1" lon="2"/></gpx>',
|
||||
'<gpx><trk><trkseg><trkpt/></trkseg></trk></gpx>',
|
||||
])('does not write or publish unusable input %s', async (xml) => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull();
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('propagates parser failures before entering the transaction', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => {
|
||||
throw new Error('parser failure');
|
||||
});
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure');
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
});
|
||||
|
||||
it('keeps a committed response and attempts remaining events if publication throws', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementationOnce(() => {
|
||||
throw new Error('transport unavailable');
|
||||
});
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(3);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps committed rows and success response if a place event fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId, event) => {
|
||||
if (event === 'place:created') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps committed rows and success response if the Tours invalidation event fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId, event) => {
|
||||
if (event === 'tours:changed') throw new Error('invalidation unavailable');
|
||||
});
|
||||
|
||||
const result = await tours.importGpxAsTour(tripId, mixedGpx);
|
||||
|
||||
expect(result?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns a successful skipped result for duplicate-only imports without publishing events', async () => {
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all();
|
||||
const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all();
|
||||
broadcast.mockClear();
|
||||
|
||||
expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 });
|
||||
expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
|
||||
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg></trk></gpx>`);
|
||||
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
|
||||
broadcast.mockClear();
|
||||
|
||||
const result = await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket');
|
||||
|
||||
expect(result?.tours).toHaveLength(1);
|
||||
expect(result?.tours[0].name).toBe('walk');
|
||||
expect(result?.skipped).toBe(1);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
const walkId = result!.tours[0].place_id;
|
||||
expect(broadcast.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
['tours:changed', { placeIds: [walkId] }],
|
||||
['place:created', expect.objectContaining({ place: expect.objectContaining({ id: walkId, name: 'walk' }) })],
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves single-track response and trip scoping', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const otherTripId = String(createTrip(testDb, user.id).id);
|
||||
const single = Buffer.from(
|
||||
'<gpx><trk><trkseg><trkpt lat="48" lon="11"/><trkpt lat="48.01" lon="11.01"/></trkseg></trk></gpx>',
|
||||
);
|
||||
|
||||
const result = (await tours.importGpxAsTour(otherTripId, single, 'single.gpx'))!;
|
||||
|
||||
expect(result.tours).toHaveLength(1);
|
||||
expect(result.tours[0].name).toBe('single');
|
||||
expect(result.caution).toBe(true);
|
||||
expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]);
|
||||
expect((await tours.listTours(otherTripId)).map((t) => t.place_id)).toEqual([result.tours[0].place_id]);
|
||||
expect(await tours.listTours(tripId)).toEqual([]);
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Tours reuse the ordinary place and day-assignment endpoints for deletion and
|
||||
* planning, so their permissions are those endpoints' permissions: deleting a
|
||||
* tour needs 'place_edit' only, assigning or unassigning one needs 'day_edit'
|
||||
* only. Run through the real TripAccessGuard with mocked collaborators (no DB).
|
||||
* The last block pins ToursService's trip boundary on update against stubbed
|
||||
* repositories.
|
||||
*/
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { PlacesController } from '../../../src/nest/places/places.controller';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/assignments/assignments.service', () => ({ AssignmentsService: class {} }));
|
||||
vi.mock('../../../src/nest/app-config/runtime-env.service', () => ({ RuntimeEnvService: class {} }));
|
||||
vi.mock('../../../src/nest/storage/storage.service', () => ({ StorageService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const matrix = [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as const;
|
||||
|
||||
function authorization(
|
||||
controller: typeof PlacesController | typeof DayAssignmentsController,
|
||||
handler: 'create' | 'remove',
|
||||
canEdit: boolean,
|
||||
canAssign: boolean,
|
||||
tripId = '7',
|
||||
) {
|
||||
const request = { params: { tripId }, user: { id: 2, role: 'user' } as User };
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
|
||||
const permission = vi.fn(async (action: string) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
getHandler: () => controller.prototype[handler],
|
||||
getClass: () => controller,
|
||||
} as unknown as ExecutionContext;
|
||||
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
|
||||
const guard = new TripAccessGuard(
|
||||
em,
|
||||
{ checkPermission: permission } as unknown as PermissionsService,
|
||||
new Reflector(),
|
||||
);
|
||||
return { request, access, permission, context, guard };
|
||||
}
|
||||
|
||||
async function rejected(action: () => Promise<unknown>, status: number) {
|
||||
await expect(action()).rejects.toMatchObject({ status });
|
||||
}
|
||||
|
||||
function placesFixture() {
|
||||
const places = {
|
||||
get: vi.fn(async () => ({ id: 42, trip_id: 7 })),
|
||||
onDeleted: vi.fn(async () => {}),
|
||||
linkedExpenseIds: vi.fn(async () => []),
|
||||
remove: vi.fn(async () => ({
|
||||
deleted: true,
|
||||
deletedTourPlaceIds: [42],
|
||||
cancelled: { reservationIds: [], budgetItemIds: [] },
|
||||
})),
|
||||
broadcast: vi.fn(),
|
||||
};
|
||||
const controller = new PlacesController(
|
||||
places as unknown as PlacesService,
|
||||
{} as RuntimeEnvService,
|
||||
{} as StorageService,
|
||||
);
|
||||
return { places, controller };
|
||||
}
|
||||
|
||||
function assignmentsFixture() {
|
||||
const assignment = { id: 51, place_id: 42, day_id: 11 };
|
||||
const assignments = {
|
||||
dayExists: vi.fn(async () => true),
|
||||
placeExists: vi.fn(async () => true),
|
||||
assignmentExistsInDay: vi.fn(async () => true),
|
||||
createAssignment: vi.fn(async () => assignment),
|
||||
deleteAssignment: vi.fn(async () => {}),
|
||||
broadcast: vi.fn(),
|
||||
reconcile: vi.fn(async () => {}),
|
||||
};
|
||||
return {
|
||||
assignment,
|
||||
assignments,
|
||||
controller: new DayAssignmentsController(assignments as unknown as AssignmentsService),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Tours deletion and assignment authorization (mock-only)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps existing Places deletion and day assignment endpoints wired to their exact permissions', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PlacesController)).toBe('api/trips/:tripId/places');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController)).toEqual([JwtAuthGuard]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController.prototype.remove)).toEqual([TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, PlacesController.prototype.remove)).toBe(RequestMethod.DELETE);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PlacesController.prototype.remove)).toBe(':id');
|
||||
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController)).toBe(
|
||||
'api/trips/:tripId/days/:dayId/assignments',
|
||||
);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, DayAssignmentsController)).toEqual([JwtAuthGuard, TripAccessGuard]);
|
||||
for (const handler of ['create', 'remove'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, DayAssignmentsController.prototype[handler])).toBe('day_edit');
|
||||
}
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.create)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.remove)).toBe(RequestMethod.DELETE);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController.prototype.remove)).toBe(':id');
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s: tour deletion requires only place_edit', async (canEdit, canAssign) => {
|
||||
const auth = authorization(PlacesController, 'remove', canEdit, canAssign);
|
||||
const { places, controller } = placesFixture();
|
||||
const invoke = async () => {
|
||||
await auth.guard.canActivate(auth.context);
|
||||
return await controller.remove(auth.request.user, '7', '42', 'socket');
|
||||
};
|
||||
if (canEdit) {
|
||||
await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [42] });
|
||||
expect(places.get).toHaveBeenCalledWith('7', '42');
|
||||
expect(places.onDeleted).toHaveBeenCalledWith(42);
|
||||
expect(places.remove).toHaveBeenCalledWith('7', '42');
|
||||
expect(places.broadcast).toHaveBeenCalledWith('7', 'place:deleted', { placeId: 42 }, 'socket');
|
||||
} else {
|
||||
await rejected(invoke, 403);
|
||||
for (const method of Object.values(places)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
});
|
||||
|
||||
describe.each(['create', 'remove'] as const)('assignment %s', (handler) => {
|
||||
it.each(matrix)('edit=%s assign=%s: requires only day_edit', async (canEdit, canAssign) => {
|
||||
const auth = authorization(DayAssignmentsController, handler, canEdit, canAssign);
|
||||
const { assignment, assignments, controller } = assignmentsFixture();
|
||||
const invoke = async () => {
|
||||
await auth.guard.canActivate(auth.context);
|
||||
return handler === 'create'
|
||||
? await controller.create(auth.request.user, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket')
|
||||
: await controller.remove(auth.request.user, '7', '11', '51', 'socket');
|
||||
};
|
||||
if (canAssign) {
|
||||
expect(await invoke()).toEqual(handler === 'create' ? { assignment } : { success: true });
|
||||
if (handler === 'create') {
|
||||
expect(assignments.dayExists).toHaveBeenCalledWith('11', '7');
|
||||
expect(assignments.placeExists).toHaveBeenCalledWith(42, '7');
|
||||
expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour');
|
||||
expect(assignments.deleteAssignment).not.toHaveBeenCalled();
|
||||
expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:created', { assignment }, 'socket');
|
||||
} else {
|
||||
expect(assignments.assignmentExistsInDay).toHaveBeenCalledWith('51', '11', '7');
|
||||
expect(assignments.deleteAssignment).toHaveBeenCalledWith('51');
|
||||
expect(assignments.createAssignment).not.toHaveBeenCalled();
|
||||
expect(assignments.broadcast).toHaveBeenCalledWith(
|
||||
'7',
|
||||
'assignment:deleted',
|
||||
{ assignmentId: 51, dayId: 11 },
|
||||
'socket',
|
||||
);
|
||||
}
|
||||
expect(assignments.reconcile).toHaveBeenCalledWith('7', 'socket');
|
||||
} else {
|
||||
await rejected(invoke, 403);
|
||||
for (const method of Object.values(assignments)) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('day_edit', 'user', 1, 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[PlacesController, 'remove'],
|
||||
[DayAssignmentsController, 'create'],
|
||||
[DayAssignmentsController, 'remove'],
|
||||
] as const)('%s.%s rejects an inaccessible route trip before checking permissions', async (owner, handler) => {
|
||||
const auth = authorization(owner, handler, true, true, '8');
|
||||
await rejected(() => auth.guard.canActivate(auth.context), 404);
|
||||
expect(auth.access).toHaveBeenCalledExactlyOnceWith(8, 2);
|
||||
expect(auth.permission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const input: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Tour 42 belongs to `ownerTripId`; findInTrip answers only for that trip. */
|
||||
function tourRepositoryFixture(ownerTripId: number) {
|
||||
const row = {
|
||||
place_id: 42,
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
distance: 3,
|
||||
elevation_gain: 30,
|
||||
elevation_loss: 0,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 1,
|
||||
};
|
||||
const uow = { transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
|
||||
const toursRepo = {
|
||||
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
|
||||
tripId === ownerTripId && placeId === 42 ? row : undefined,
|
||||
),
|
||||
updateInTrip: vi.fn(async () => true),
|
||||
};
|
||||
const waypointsRepo = {
|
||||
listForPlace: vi.fn(async () => input.waypoints),
|
||||
deleteForPlace: vi.fn(async () => {}),
|
||||
insertForPlace: vi.fn(async () => {}),
|
||||
};
|
||||
const placesRepo = {
|
||||
updateTourRoute: vi.fn(async () => true),
|
||||
findInTrip: vi.fn(async () => undefined),
|
||||
findWithTagsAndRatings: vi.fn(async () => ({ id: 42, trip_id: ownerTripId })),
|
||||
};
|
||||
const places = { broadcast: vi.fn() };
|
||||
const service = new ToursService(
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
|
||||
}
|
||||
|
||||
describe('Real ToursService update trip boundary (stubbed repositories)', () => {
|
||||
it('rejects a placeId belonging to another trip before any write', async () => {
|
||||
const f = tourRepositoryFixture(8);
|
||||
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
|
||||
f.toursRepo.findInTrip.mockClear();
|
||||
f.waypointsRepo.listForPlace.mockClear();
|
||||
|
||||
const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(NotFoundException);
|
||||
expect((error as HttpException).getStatus()).toBe(404);
|
||||
expect((error as Error).message).toBe('Tour not found');
|
||||
expect(f.toursRepo.findInTrip).toHaveBeenCalledExactlyOnceWith(7, 42);
|
||||
for (const method of [
|
||||
f.placesRepo.updateTourRoute,
|
||||
f.toursRepo.updateInTrip,
|
||||
f.waypointsRepo.deleteForPlace,
|
||||
f.waypointsRepo.insertForPlace,
|
||||
f.waypointsRepo.listForPlace,
|
||||
f.placesRepo.findWithTagsAndRatings,
|
||||
f.places.broadcast,
|
||||
]) {
|
||||
expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows a same-trip update through the transaction with trip-scoped writes', async () => {
|
||||
const f = tourRepositoryFixture(7);
|
||||
|
||||
expect((await f.service.updateTour('7', '42', input, 'socket')).tour.place_id).toBe(42);
|
||||
|
||||
expect(f.uow.transactional).toHaveBeenCalledOnce();
|
||||
// Once inside the write, once for the response.
|
||||
expect(f.toursRepo.findInTrip).toHaveBeenCalledTimes(2);
|
||||
expect(f.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
|
||||
name: input.name,
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
route_geometry: JSON.stringify(input.route_geometry),
|
||||
});
|
||||
expect(f.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(
|
||||
7,
|
||||
42,
|
||||
expect.objectContaining({ tour_type: 'hike', duration: null }),
|
||||
);
|
||||
expect(f.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
|
||||
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
|
||||
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The Tours permission contract, run through the real guards with mocked
|
||||
* collaborators (no DB): reads need trip access only, writes need
|
||||
* 'place_edit' and never 'day_edit', an inaccessible trip is a 404 before any
|
||||
* permission check, and the addon and JWT refusals stop a request before the
|
||||
* trip is resolved. The GPX import answers the same matrix from its handler.
|
||||
*/
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import {
|
||||
TripAccessGuard,
|
||||
TRIP_PERMISSION_KEY,
|
||||
TRIP_REQUEST_KEY,
|
||||
} from '../../../src/nest/permissions/trip-access.guard';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
|
||||
import { ToursController } from '../../../src/nest/tours/tours.controller';
|
||||
import type { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { EntityManager } from '@mikro-orm/core';
|
||||
import { HttpException, type ExecutionContext } from '@nestjs/common';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
|
||||
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
|
||||
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
|
||||
const matrix = [
|
||||
[false, false],
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
] as const;
|
||||
const user = { id: 2, role: 'user' } as User;
|
||||
const trip = { id: 7, user_id: 1 };
|
||||
|
||||
/** Trip 7 is reachable for user 2; it belongs to user 1, so every check is a shared-trip check. */
|
||||
function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') {
|
||||
const request = { params: { tripId }, user };
|
||||
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
|
||||
const permission = vi.fn(async (action: string) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const context = {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
getHandler: () => ToursController.prototype[handler],
|
||||
getClass: () => ToursController,
|
||||
} as unknown as ExecutionContext;
|
||||
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
|
||||
const guard = new TripAccessGuard(
|
||||
em,
|
||||
{ checkPermission: permission } as unknown as PermissionsService,
|
||||
new Reflector(),
|
||||
);
|
||||
return { request, access, permission, context, guard };
|
||||
}
|
||||
|
||||
/** The import handler's own checks, with the same access and permission doubles. */
|
||||
function importFixture(canEdit: boolean, canAssign: boolean) {
|
||||
const permission = vi.fn(async (action: string, ..._context: unknown[]) =>
|
||||
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
|
||||
);
|
||||
const access = vi.fn(async (id: string, userId: number) => (id === '7' && userId === 2 ? trip : undefined));
|
||||
const places = {
|
||||
verifyTripAccess: access,
|
||||
canEdit: vi.fn(async (t: typeof trip, u: User) =>
|
||||
permission('place_edit', u.role, t.user_id, u.id, t.user_id !== u.id),
|
||||
),
|
||||
};
|
||||
const importGpxAsTour = vi.fn(async () => ({ tours: [{ place_id: 42 }], caution: false, skipped: 0 }));
|
||||
const controller = new ToursImportController(
|
||||
{ importGpxAsTour } as unknown as ToursService,
|
||||
places as unknown as PlacesService,
|
||||
{ isAddonEnabled: vi.fn(async () => true) } as unknown as AddonsService,
|
||||
);
|
||||
return { controller, access, permission, importGpxAsTour };
|
||||
}
|
||||
|
||||
async function expectStatus(action: () => Promise<unknown>, status: number) {
|
||||
try {
|
||||
await action();
|
||||
expect.fail('Expected rejection');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpException);
|
||||
expect((error as HttpException).getStatus()).toBe(status);
|
||||
}
|
||||
}
|
||||
|
||||
const file = { buffer: Buffer.from('gpx'), originalname: 'ridge.gpx' } as Express.Multer.File;
|
||||
|
||||
describe('Tours permission contract (mock-only, no DB)', () => {
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains addon/JWT/trip guard order and exact write metadata', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
|
||||
for (const handler of ['create', 'update'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBe('place_edit');
|
||||
}
|
||||
for (const handler of ['list', 'detail'] as const) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s permits reads and only place-edit writes', async (canEdit, canAssign) => {
|
||||
for (const handler of ['list', 'detail'] as const) {
|
||||
const test = fixture(handler, canEdit, canAssign);
|
||||
expect(await test.guard.canActivate(test.context)).toBe(true);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(trip);
|
||||
}
|
||||
for (const handler of ['create', 'update'] as const) {
|
||||
const test = fixture(handler, canEdit, canAssign);
|
||||
if (canEdit) expect(await test.guard.canActivate(test.context)).toBe(true);
|
||||
else await expectStatus(() => test.guard.canActivate(test.context), 403);
|
||||
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(matrix)('edit=%s assign=%s: the GPX import handler requires only place_edit', async (canEdit, canAssign) => {
|
||||
const test = importFixture(canEdit, canAssign);
|
||||
if (canEdit) {
|
||||
expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] });
|
||||
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
|
||||
} else {
|
||||
await expectStatus(() => test.controller.importGpx(user, '7', file), 403);
|
||||
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
|
||||
});
|
||||
|
||||
it.each(['list', 'detail', 'create', 'update'] as const)(
|
||||
'%s rejects inaccessible route trips before any write permission check',
|
||||
async (handler) => {
|
||||
const test = fixture(handler, true, true, '8');
|
||||
await expectStatus(() => test.guard.canActivate(test.context), 404);
|
||||
expect(test.access).toHaveBeenCalledWith(8, 2);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('importGpx rejects an inaccessible route trip before any permission check', async () => {
|
||||
const test = importFixture(true, true);
|
||||
await expectStatus(() => test.controller.importGpx(user, '8', file), 404);
|
||||
expect(test.access).toHaveBeenCalledWith('8', 2);
|
||||
expect(test.permission).not.toHaveBeenCalled();
|
||||
expect(test.importGpxAsTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves addon and JWT refusal without reaching trip resolution', async () => {
|
||||
const test = fixture('create', true, true);
|
||||
const addon = new AddonGuard(
|
||||
{ isAddonEnabled: vi.fn(async () => false) } as unknown as AddonsService,
|
||||
new Reflector(),
|
||||
);
|
||||
await expectStatus(() => addon.canActivate(test.context), 404);
|
||||
const jwt = new JwtAuthGuard({ getRepository: vi.fn() } as unknown as EntityManager);
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
await expectStatus(() => jwt.canActivate(test.context), 401);
|
||||
vi.mocked(extractToken).mockReturnValue('token');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
|
||||
await expectStatus(() => jwt.canActivate(test.context), 401);
|
||||
expect(test.access).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* ToursService write contracts against stubbed repositories and a stub
|
||||
* UnitOfWork (the budget.service.test.ts pattern): which repository call runs
|
||||
* with which arguments, and that every write runs inside the transaction while
|
||||
* the read-back and the broadcasts run after it. Rollback against a real
|
||||
* SQLite database is covered by tours.service.test.ts.
|
||||
*/
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { legacyDatabaseAccess } = vi.hoisted(() => ({
|
||||
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
|
||||
throw new Error(`Unexpected legacy database access: ${String(property)}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'test-only-inert-key',
|
||||
JWT_SECRET: 'test-only-inert-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
const row = {
|
||||
place_id: 42,
|
||||
name: request.name,
|
||||
tour_type: 'hike',
|
||||
distance: 3,
|
||||
elevation_gain: 50,
|
||||
elevation_loss: 20,
|
||||
duration: 60,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
tour_group_id: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: 0,
|
||||
has_waypoints: 1,
|
||||
};
|
||||
const place = { id: 42, trip_id: 7 };
|
||||
let inTransaction = false;
|
||||
/** Records whether each write ran inside the transaction. */
|
||||
const inTx = <T>(value: T) =>
|
||||
vi.fn(async (..._args: unknown[]) => {
|
||||
expect(inTransaction).toBe(true);
|
||||
return value;
|
||||
});
|
||||
|
||||
const uow = {
|
||||
transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
inTransaction = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
inTransaction = false;
|
||||
}
|
||||
}),
|
||||
};
|
||||
const toursRepo = {
|
||||
findInTrip: vi.fn(async (): Promise<typeof row | undefined> => row),
|
||||
insertTour: inTx(undefined),
|
||||
updateInTrip: inTx(true),
|
||||
};
|
||||
const waypointsRepo = {
|
||||
listForPlace: vi.fn(async () => request.waypoints),
|
||||
insertForPlace: inTx(undefined),
|
||||
deleteForPlace: inTx(undefined),
|
||||
};
|
||||
const placesRepo = {
|
||||
insertTourPlace: inTx(42),
|
||||
updateTourRoute: inTx(true),
|
||||
findInTrip: vi.fn(async (): Promise<{ route_geometry: string | null } | undefined> => undefined),
|
||||
findWithTagsAndRatings: vi.fn(async () => {
|
||||
expect(inTransaction).toBe(false);
|
||||
return place;
|
||||
}),
|
||||
};
|
||||
const places = {
|
||||
broadcast: vi.fn(() => {
|
||||
expect(inTransaction).toBe(false);
|
||||
}),
|
||||
};
|
||||
const service = new ToursService(
|
||||
uow as unknown as UnitOfWork,
|
||||
places as unknown as PlacesService,
|
||||
toursRepo as unknown as ToursRepository,
|
||||
waypointsRepo as unknown as TourWaypointsRepository,
|
||||
placesRepo as unknown as PlacesRepository,
|
||||
);
|
||||
return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service };
|
||||
}
|
||||
|
||||
describe('ToursService planner contracts (mock-only; SQLite rollback semantics are covered in tours.service.test.ts)', () => {
|
||||
let setup: ReturnType<typeof makeService>;
|
||||
beforeEach(() => {
|
||||
setup = makeService();
|
||||
});
|
||||
|
||||
it('does not access the legacy global database initializer', () => {
|
||||
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one transaction', async () => {
|
||||
const result = await setup.service.createTour('7', request, 'socket-1');
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.placesRepo.insertTourPlace).toHaveBeenCalledExactlyOnceWith({
|
||||
trip_id: 7,
|
||||
name: request.name,
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
route_geometry: JSON.stringify(request.route_geometry),
|
||||
});
|
||||
expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({
|
||||
place_id: 42,
|
||||
tour_type: 'hike',
|
||||
distance: expect.any(Number),
|
||||
elevation_gain: 50,
|
||||
elevation_loss: 20,
|
||||
duration: 60,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
|
||||
expect(result.waypoints).toEqual(request.waypoints);
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
|
||||
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1');
|
||||
});
|
||||
|
||||
it('TOURS-SVC-002: propagates a waypoint failure without publishing or reading an uncommitted create', async () => {
|
||||
const failure = new Error('Injected waypoint failure');
|
||||
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(setup.service.createTour('7', request)).rejects.toBe(failure);
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.toursRepo.findInTrip).not.toHaveBeenCalled();
|
||||
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003: validates and rounds a fractional routed duration before the create write', async () => {
|
||||
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
|
||||
const result = await setup.service.createTour('7', validated);
|
||||
expect(setup.toursRepo.insertTour).toHaveBeenCalledWith(expect.objectContaining({ place_id: 42, duration: 60 }));
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
|
||||
});
|
||||
|
||||
it('TOURS-SVC-004: scopes detail reads to the trip and derives legacy GPX controls without writes', async () => {
|
||||
expect((await setup.service.getTour('7', '42')).waypoints).toEqual(request.waypoints);
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
|
||||
expect(setup.waypointsRepo.listForPlace).toHaveBeenCalledWith(42);
|
||||
|
||||
setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
|
||||
setup.waypointsRepo.listForPlace.mockClear();
|
||||
await expect(setup.service.getTour('8', '42')).rejects.toThrow('Tour not found');
|
||||
expect(setup.waypointsRepo.listForPlace).not.toHaveBeenCalled();
|
||||
|
||||
setup.waypointsRepo.listForPlace.mockResolvedValueOnce([]);
|
||||
setup.placesRepo.findInTrip.mockResolvedValueOnce({ route_geometry: JSON.stringify(request.route_geometry) });
|
||||
const legacyGpx = await setup.service.getTour('7', '42');
|
||||
expect(setup.placesRepo.findInTrip).toHaveBeenCalledWith(42, 7);
|
||||
expect(legacyGpx.waypoints).toEqual([
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
]);
|
||||
for (const method of [
|
||||
setup.uow.transactional,
|
||||
setup.placesRepo.insertTourPlace,
|
||||
setup.placesRepo.updateTourRoute,
|
||||
setup.toursRepo.insertTour,
|
||||
setup.toursRepo.updateInTrip,
|
||||
setup.waypointsRepo.insertForPlace,
|
||||
setup.waypointsRepo.deleteForPlace,
|
||||
setup.places.broadcast,
|
||||
]) {
|
||||
expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-SVC-005: updates route data and replaces controls through one transaction', async () => {
|
||||
const update: TourCreateRequest = {
|
||||
...request,
|
||||
name: 'Updated ridge walk',
|
||||
route_geometry: [
|
||||
[49, 12, 700],
|
||||
[49.02, 12.04, 760],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
|
||||
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
|
||||
],
|
||||
duration_seconds: 2700.5,
|
||||
};
|
||||
|
||||
await setup.service.updateTour('7', '42', update, 'socket-2');
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
|
||||
name: update.name,
|
||||
lat: 49,
|
||||
lng: 12,
|
||||
route_geometry: JSON.stringify(update.route_geometry),
|
||||
});
|
||||
expect(setup.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, {
|
||||
tour_type: 'hike',
|
||||
distance: expect.any(Number),
|
||||
elevation_gain: 60,
|
||||
elevation_loss: 0,
|
||||
duration: 45,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
expect(setup.waypointsRepo.deleteForPlace.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(setup.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: setup.place }, 'socket-2');
|
||||
});
|
||||
|
||||
it('TOURS-SVC-006: propagates a replacement failure without publishing or reloading', async () => {
|
||||
const failure = new Error('Injected replacement failure');
|
||||
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(setup.service.updateTour('7', '42', request)).rejects.toBe(failure);
|
||||
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.toursRepo.findInTrip).toHaveBeenCalledTimes(1);
|
||||
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the tour lookup', 'findInTrip'],
|
||||
['the trip-scoped place update', 'updateTourRoute'],
|
||||
['the trip-scoped tour update', 'updateInTrip'],
|
||||
] as const)('TOURS-SVC-010: a miss in %s is a 404 that stops the write', async (_label, step) => {
|
||||
if (step === 'findInTrip') setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
|
||||
if (step === 'updateTourRoute') setup.placesRepo.updateTourRoute.mockResolvedValueOnce(false);
|
||||
if (step === 'updateInTrip') setup.toursRepo.updateInTrip.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(setup.service.updateTour('7', '42', request)).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
// The existence check runs inside the write's own transaction (F44).
|
||||
expect(setup.uow.transactional).toHaveBeenCalledOnce();
|
||||
expect(setup.waypointsRepo.deleteForPlace).not.toHaveBeenCalled();
|
||||
expect(setup.waypointsRepo.insertForPlace).not.toHaveBeenCalled();
|
||||
expect(setup.places.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* ToursService over the real migrated per-worker DB: the tours facet, its
|
||||
* ordered control points and the owning place are written through the Tours,
|
||||
* TourWaypoints and Places repositories inside one UnitOfWork transaction, and
|
||||
* realtime events go out only after that transaction committed.
|
||||
*
|
||||
* PlacesService is a broadcast stub here; the GPX import path, which needs the
|
||||
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
|
||||
*/
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { createTestToursRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const request: TourCreateRequest = {
|
||||
name: 'Ridge walk',
|
||||
tour_type: 'hike',
|
||||
route_geometry: [
|
||||
[48, 11, 600],
|
||||
[48.01, 11.02, 650],
|
||||
[48.02, 11.04, 630],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 2,
|
||||
duration_seconds: 3600,
|
||||
};
|
||||
|
||||
/** Two waypoints on the same sequence: the UNIQUE(place_id, sequence) index rejects the second one. */
|
||||
const duplicateSequence = {
|
||||
...request,
|
||||
waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }],
|
||||
} as TourCreateRequest;
|
||||
|
||||
const broadcast = vi.fn();
|
||||
let service: ToursService;
|
||||
let tripId: string;
|
||||
let otherTripId: string;
|
||||
|
||||
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
|
||||
beforeAll(async () => {
|
||||
service = new ToursService(
|
||||
await createTestUnitOfWork(testDb),
|
||||
{ broadcast } as unknown as PlacesService,
|
||||
await createTestToursRepo(testDb),
|
||||
await createTestTourWaypointsRepo(testDb),
|
||||
await createTestPlacesRepo(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
(await sharedTestOrm(testDb)).clear();
|
||||
broadcast.mockReset();
|
||||
const { user } = createUser(testDb);
|
||||
tripId = String(createTrip(testDb, user.id).id);
|
||||
otherTripId = String(createTrip(testDb, user.id).id);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('ToursService planner creation', () => {
|
||||
it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', async () => {
|
||||
const writeStates: boolean[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
writeStates.push(testDb.inTransaction);
|
||||
});
|
||||
|
||||
const result = await service.createTour(tripId, request, 'socket-1');
|
||||
|
||||
const placeId = result.tour.place_id;
|
||||
const place = testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as Record<string, unknown>;
|
||||
const tour = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record<string, unknown>;
|
||||
const points = testDb
|
||||
.prepare('SELECT role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
|
||||
.all(placeId);
|
||||
|
||||
expect(place).toMatchObject({
|
||||
trip_id: Number(tripId),
|
||||
name: 'Ridge walk',
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
transport_mode: 'walking',
|
||||
});
|
||||
expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry);
|
||||
expect(tour).toMatchObject({ tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2 });
|
||||
expect(tour.distance).toBeGreaterThan(0);
|
||||
expect(tour.elevation_gain).toBe(50);
|
||||
expect(tour.elevation_loss).toBe(20);
|
||||
expect(tour.duration).toBe(60);
|
||||
expect(points).toEqual([
|
||||
{ role: 'start', sequence: 0 },
|
||||
{ role: 'end', sequence: 1 },
|
||||
]);
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true, caution: false });
|
||||
expect(result.waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1');
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
tripId,
|
||||
'place:created',
|
||||
{ place: expect.objectContaining({ id: placeId }) },
|
||||
'socket-1',
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
// Both events go out after the commit, never from inside the write.
|
||||
expect(writeStates).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', async () => {
|
||||
await expect(service.createTour(tripId, duplicateSequence)).rejects.toThrow();
|
||||
|
||||
expect(count('places')).toBe(0);
|
||||
expect(count('tours')).toBe(0);
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', async () => {
|
||||
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
|
||||
|
||||
const result = await service.createTour(tripId, validated);
|
||||
|
||||
expect(count('places')).toBe(1);
|
||||
expect(count('tours')).toBe(1);
|
||||
expect(count('tour_waypoints')).toBe(2);
|
||||
expect(testDb.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 });
|
||||
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
|
||||
});
|
||||
|
||||
it('TOURS-SVC-003b: stores no duration when the client sends none', async () => {
|
||||
const { duration_seconds: _omitted, ...withoutDuration } = request;
|
||||
const result = await service.createTour(tripId, withoutDuration);
|
||||
expect(result.tour.duration).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a committed create successful when Tours invalidation publication fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId: string, event: string) => {
|
||||
if (event === 'tours:changed') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
const result = await service.createTour(tripId, request);
|
||||
|
||||
expect(result.tour.name).toBe('Ridge walk');
|
||||
expect(count('places')).toBe(1);
|
||||
expect(count('tours')).toBe(1);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps a committed create successful when the place notification fails', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementation((_tripId: string, event: string) => {
|
||||
if (event === 'place:created') throw new Error('transport unavailable');
|
||||
});
|
||||
|
||||
await expect(service.createTour(tripId, request)).resolves.toMatchObject({ tour: { name: 'Ridge walk' } });
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursService reads', () => {
|
||||
it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
const placeId = String(created.tour.place_id);
|
||||
|
||||
expect(await service.getTour(tripId, placeId)).toEqual(created);
|
||||
await expect(service.getTour(otherTripId, placeId)).rejects.toThrow('Tour not found');
|
||||
|
||||
testDb.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id);
|
||||
const legacyGpx = await service.getTour(tripId, placeId);
|
||||
expect(legacyGpx.tour.has_waypoints).toBe(false);
|
||||
expect(legacyGpx.waypoints).toEqual([
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
|
||||
]);
|
||||
// The fallback is read-only: nothing was backfilled.
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-004b: a plain place, a malformed id and a geometry-less tour', async () => {
|
||||
const plain = createPlace(testDb, Number(tripId), { name: 'Not a tour' });
|
||||
await expect(service.getTour(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.getTour(tripId, 'abc')).rejects.toThrow('Tour not found');
|
||||
await expect(service.getTour('abc', String(plain.id))).rejects.toThrow('Tour not found');
|
||||
|
||||
const bare = createPlace(testDb, Number(tripId), { name: 'Bare import' });
|
||||
createTour(testDb, bare.id);
|
||||
const detail = await service.getTour(tripId, String(bare.id));
|
||||
expect(detail.tour).toMatchObject({ place_id: bare.id, has_waypoints: false });
|
||||
expect(detail.waypoints).toEqual([]);
|
||||
});
|
||||
|
||||
it("TOURS-SVC-007: lists only this trip's tours, newest first, with the caution flag", async () => {
|
||||
const older = createPlace(testDb, Number(tripId), { name: 'Older' });
|
||||
const newer = createPlace(testDb, Number(tripId), { name: 'Newer' });
|
||||
const foreign = createPlace(testDb, Number(otherTripId), { name: 'Foreign' });
|
||||
createPlace(testDb, Number(tripId), { name: 'Plain place' });
|
||||
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', match_confidence: 0.3 });
|
||||
createTour(testDb, newer.id, { created_at: '2026-02-01 10:00:00', match_confidence: 1 });
|
||||
createTour(testDb, foreign.id);
|
||||
|
||||
const list = await service.listTours(tripId);
|
||||
|
||||
expect(list.map((t) => t.name)).toEqual(['Newer', 'Older']);
|
||||
expect(list[0]).toMatchObject({ caution: false, planned: false, has_waypoints: false, tour_type: 'hike' });
|
||||
expect(list[1]).toMatchObject({ caution: true, match_confidence: 0.3 });
|
||||
expect(await service.listTours('not-a-number')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursService updates', () => {
|
||||
const update: TourCreateRequest = {
|
||||
...request,
|
||||
name: 'Updated ridge walk',
|
||||
route_geometry: [
|
||||
[49, 12, 700],
|
||||
[49.02, 12.04, 760],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
|
||||
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
|
||||
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
|
||||
],
|
||||
duration_seconds: 2700.5,
|
||||
};
|
||||
|
||||
it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
broadcast.mockReset();
|
||||
const writeStates: boolean[] = [];
|
||||
broadcast.mockImplementation(() => {
|
||||
writeStates.push(testDb.inTransaction);
|
||||
});
|
||||
|
||||
const result = await service.updateTour(tripId, String(created.tour.place_id), update, 'socket-2');
|
||||
|
||||
expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' });
|
||||
expect(result.waypoints).toEqual(update.waypoints);
|
||||
expect(testDb.prepare('SELECT name, lat, lng, transport_mode FROM places').get()).toEqual({
|
||||
name: 'Updated ridge walk',
|
||||
lat: 49,
|
||||
lng: 12,
|
||||
transport_mode: 'walking',
|
||||
});
|
||||
expect(testDb.prepare('SELECT duration, elevation_gain, elevation_loss FROM tours').get()).toEqual({
|
||||
duration: 45,
|
||||
elevation_gain: 60,
|
||||
elevation_loss: 0,
|
||||
});
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
tripId,
|
||||
'tours:changed',
|
||||
{ placeIds: [created.tour.place_id] },
|
||||
'socket-2',
|
||||
);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
tripId,
|
||||
'place:updated',
|
||||
expect.objectContaining({ place: expect.objectContaining({ id: created.tour.place_id }) }),
|
||||
'socket-2',
|
||||
);
|
||||
expect(writeStates).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', async () => {
|
||||
const created = await service.createTour(tripId, request);
|
||||
broadcast.mockReset();
|
||||
|
||||
await expect(
|
||||
service.updateTour(tripId, String(created.tour.place_id), { ...duplicateSequence, name: 'Must roll back' }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(testDb.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' });
|
||||
expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-008: a tour of another trip is a 404 and stays untouched', async () => {
|
||||
const created = await service.createTour(otherTripId, request);
|
||||
broadcast.mockReset();
|
||||
|
||||
await expect(service.updateTour(tripId, String(created.tour.place_id), update, 'socket')).rejects.toThrow(
|
||||
'Tour not found',
|
||||
);
|
||||
|
||||
expect(testDb.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(created.tour.place_id)).toEqual({
|
||||
trip_id: Number(otherTripId),
|
||||
name: 'Ridge walk',
|
||||
});
|
||||
expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOURS-SVC-009: a plain place or a malformed id is a 404 without a write', async () => {
|
||||
const plain = createPlace(testDb, Number(tripId), { name: 'Plain' });
|
||||
|
||||
await expect(service.updateTour(tripId, String(plain.id), update)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.updateTour(tripId, 'abc', update)).rejects.toThrow('Tour not found');
|
||||
await expect(service.updateTour('abc', String(plain.id), update)).rejects.toThrow('Tour not found');
|
||||
|
||||
expect(testDb.prepare('SELECT name FROM places WHERE id = ?').get(plain.id)).toEqual({ name: 'Plain' });
|
||||
expect(count('tours')).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
|
||||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
|
||||
import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||||
import {
|
||||
createTestVacayPlansRepo,
|
||||
createTestVacayPlanMembersRepo,
|
||||
@@ -2609,6 +2610,97 @@ describe('copy — whole-trip parity (Task 8)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy: Tours (#2586)', () => {
|
||||
/** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */
|
||||
function seedTour(tripId: number, name: string) {
|
||||
const place = createPlace(testDb, tripId, { name });
|
||||
testDb
|
||||
.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?")
|
||||
.run(place.id);
|
||||
createTour(testDb, place.id, {
|
||||
distance: 12.5,
|
||||
match_confidence: 0.9,
|
||||
max_hiking_difficulty: 4,
|
||||
created_at: '2025-01-02 03:04:05',
|
||||
});
|
||||
testDb
|
||||
.prepare(
|
||||
"UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 7 WHERE place_id = ?",
|
||||
)
|
||||
.run(place.id);
|
||||
const insert = testDb.prepare(
|
||||
'INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)',
|
||||
);
|
||||
insert.run(place.id, 47, 11, 'start', 0);
|
||||
insert.run(place.id, 47.1, 11.1, 'via', 1);
|
||||
insert.run(place.id, 47.2, 11.2, 'end', 2);
|
||||
return place;
|
||||
}
|
||||
|
||||
const tourColumns = (placeId: number) => {
|
||||
const { place_id: _placeId, ...rest } = testDb
|
||||
.prepare('SELECT * FROM tours WHERE place_id = ?')
|
||||
.get(placeId) as Record<string, unknown>;
|
||||
return rest;
|
||||
};
|
||||
const waypoints = (placeId: number) =>
|
||||
testDb
|
||||
.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
|
||||
.all(placeId);
|
||||
|
||||
it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Alps' });
|
||||
const tour = seedTour(trip.id, 'Ridge walk');
|
||||
createPlace(testDb, trip.id, { name: 'Plain stop' });
|
||||
|
||||
const newTripId = await svc.copy(trip.id, user.id, 'Alps again');
|
||||
|
||||
const newTour = testDb
|
||||
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'")
|
||||
.get(newTripId) as { id: number };
|
||||
const newPlain = testDb
|
||||
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'")
|
||||
.get(newTripId) as { id: number };
|
||||
expect(newTour.id).not.toBe(tour.id);
|
||||
expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id));
|
||||
expect(tourColumns(newTour.id)).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
created_at: '2025-01-02 03:04:05',
|
||||
max_hiking_difficulty: 4,
|
||||
tour_group_id: 7,
|
||||
});
|
||||
expect(waypoints(newTour.id)).toEqual(waypoints(tour.id));
|
||||
expect(waypoints(newTour.id)).toHaveLength(3);
|
||||
// The plain place stays plain, and the source keeps its own rows.
|
||||
expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?')
|
||||
.get(trip.id),
|
||||
).toEqual({ n: 1 });
|
||||
expect(waypoints(tour.id)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Doomed alps' });
|
||||
seedTour(trip.id, 'Doomed ridge');
|
||||
const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n;
|
||||
|
||||
const waypointsRepo = await createTestTourWaypointsRepo(testDb);
|
||||
const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore);
|
||||
expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
|
||||
|
||||
describe('quirk fixes', () => {
|
||||
|
||||
@@ -4,13 +4,52 @@ import { FeaturesController } from '../../../src/nest/health/features.controller
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/config', async () => {
|
||||
const { readEnv } = await import('../../../src/app-config');
|
||||
const env = readEnv();
|
||||
return {
|
||||
ENCRYPTION_KEY: 'wiring-test-inert-encryption-key',
|
||||
JWT_SECRET: 'wiring-test-inert-jwt-secret',
|
||||
updateJwtSecret: vi.fn(),
|
||||
DEFAULT_LANGUAGE: env.app.defaultLanguage,
|
||||
SESSION_DURATION: env.session.duration,
|
||||
SESSION_DURATION_MS: env.session.durationMs,
|
||||
SESSION_DURATION_SECONDS: env.session.durationSeconds,
|
||||
SESSION_DURATION_REMEMBER: env.session.durationRemember,
|
||||
SESSION_DURATION_REMEMBER_MS: env.session.durationRememberMs,
|
||||
SESSION_DURATION_REMEMBER_SECONDS: env.session.durationRememberSeconds,
|
||||
};
|
||||
});
|
||||
|
||||
function ctx(user: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
|
||||
}
|
||||
|
||||
describe('AppModule wiring', () => {
|
||||
it('registers the upstream feature modules and Tours exactly once', () => {
|
||||
// Only plain class imports: forRoot()/forFeature() entries are dynamic
|
||||
// (MikroOrmModule legitimately appears twice) and some resolve async.
|
||||
const imports = Reflect.getMetadata('imports', AppModule) as unknown[];
|
||||
const names = imports
|
||||
.filter((entry): entry is { name: string } => typeof entry === 'function')
|
||||
.map((entry) => entry.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'GoogleQuotaModule',
|
||||
'ReceiptScanModule',
|
||||
'SchoolHolidaysModule',
|
||||
'DocSyncModule',
|
||||
'DawarichModule',
|
||||
'NotificationsModule',
|
||||
'ToursModule',
|
||||
]),
|
||||
);
|
||||
expect(names.filter((name) => name === 'ToursModule')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('compiles with the global filter + DB provider and resolves the controller', async () => {
|
||||
// Plan 4 Task 4: `DatabaseService` is gone — nothing left in the graph
|
||||
// needs overriding for this to compile.
|
||||
|
||||
Reference in New Issue
Block a user