test(tours): bring the server tests in line with the tours board

Expectations follow createAssignment's options, the stage_ends column, the editor's trip access and the broadcasts of the take-over. Refusals in the run and ski e2e tests go one by one so supertest does not reset a connection under another request. A parity test keeps the source hint enums in step with the contract.
This commit is contained in:
Maurice
2026-10-20 17:51:27 +02:00
parent 0f600d04f5
commit 4d81180fc1
9 changed files with 55 additions and 9 deletions
@@ -211,6 +211,8 @@ describe('Collection tours e2e (real guards + temp SQLite)', () => {
.put(`/api/trips/5/tours/${source}`)
.set(as(OWNER))
.send(tourBody({ name: 'Changed later' }));
// Making the source and changing it told trip 5; from here on only the trip it is taken into hears of it.
realtime.broadcastMock.mockClear();
const full = await request(server)
.post('/api/trips/6/tours/from-collection')
+4 -2
View File
@@ -30,6 +30,7 @@ import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { oneByOne } from '../helpers/one-by-one';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
@@ -205,7 +206,7 @@ describe('Runs e2e (real guards + temp SQLite)', () => {
});
it('TOURS-RUN-E2E-004: refuses a run without a profile, a pace out of bounds or not positive, and run fields on a hike', async () => {
const refused = await Promise.all(
const refused = await oneByOne(
[
runBody({ run_profile: null }),
runBody({ run_profile: 'road' }),
@@ -215,7 +216,8 @@ describe('Runs e2e (real guards + temp SQLite)', () => {
runBody({ run_pace_seconds_per_km: 330.5 }),
runBody({ max_hiking_difficulty: 2 }),
runBody({ tour_type: 'hike', run_profile: undefined }),
].map((body) => send('post', '', body)),
],
(body) => send('post', '', body),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400, 400, 400, 400]);
expect(await countRows(orm, Tours)).toBe(0);
+7 -4
View File
@@ -30,6 +30,7 @@ import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { oneByOne } from '../helpers/one-by-one';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
@@ -208,14 +209,15 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
);
it('TOURS-SKI-E2E-003: refuses a ski import without its discipline or with a technique it has not, and ski fields on a hike', async () => {
const refused = await Promise.all(
const refused = await oneByOne(
[
{ tour_type: 'ski' },
{ tour_type: 'ski', ski_discipline: 'cross_country' },
{ tour_type: 'ski', ski_discipline: 'alpine', ski_technique: 'classic' },
{ tour_type: 'ski', ski_discipline: 'freeride' },
{ tour_type: 'hike', ski_discipline: 'alpine' },
].map((fields) => importGpx(fields)),
],
(fields) => importGpx(fields),
);
expect(refused.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
expect(await countRows(orm, Places)).toBe(0);
@@ -272,13 +274,14 @@ describe('Ski tracks e2e (real guards + temp SQLite)', () => {
it('TOURS-SKI-E2E-007: refuses a classification that is no ski classification, or of a tour that is no ski track', async () => {
const placeId = await importSki({ ski_discipline: 'alpine' });
const invalid = await Promise.all(
const invalid = await oneByOne(
[
{},
{ ski_discipline: 'cross_country' },
{ ski_discipline: 'touring', ski_technique: 'classic' },
{ ski_discipline: 'monoski' },
].map((body) => classify(placeId, body)),
],
(body) => classify(placeId, body),
);
expect(invalid.map((res) => res.status)).toEqual([400, 400, 400, 400]);
+16
View File
@@ -0,0 +1,16 @@
/**
* Sends requests one after another and answers their results in order.
*
* Supertest given an app's HTTP server that is not listening opens it on a
* port of its own for every request and closes it again once that request is
* answered. Several requests sent together against the same server race: a
* refusal answered at once closes it under another one still in flight, which
* then fails with ECONNRESET instead of its status. Sent one by one, each
* request has the server to itself.
*/
export async function oneByOne<T, R>(items: readonly T[], send: (item: T) => PromiseLike<R>): Promise<R[]> {
return await items.reduce<Promise<R[]>>(
async (sent, item) => [...(await sent), await send(item)],
Promise.resolve([]),
);
}
@@ -386,6 +386,7 @@ describe('ToursRepository writes', () => {
ski_technique: null,
track_started_at: null,
track_ended_at: null,
stage_ends: 0,
});
});
@@ -0,0 +1,20 @@
import { TourSourceHintsReason, TourSourceHintsState } from '../../../src/db/entities/TourSourceHints.entity';
import { tourSourceHintReasonSchema, tourSourceHintStateSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
/**
* WAN-02: a hint's state and reason are CHECK lists (migration 046120) the
* entity carries as enums, and the contract holds its own. A value one side
* knows and the other refuses would be a hint the watch can find and the
* database cannot store, or one stored that the answer cannot describe.
*/
describe('wanderer source hint columns match the contract', () => {
it('TOURHINTCOL-001: knows the same states as the hint contract', () => {
expect(Object.values(TourSourceHintsState).sort()).toEqual([...tourSourceHintStateSchema.options].sort());
});
it('TOURHINTCOL-002: knows the same reasons as the hint contract', () => {
expect(Object.values(TourSourceHintsReason).sort()).toEqual([...tourSourceHintReasonSchema.options].sort());
});
});
@@ -159,6 +159,8 @@ describe('CollectionToursService', () => {
it('LIB3-SVC-002: keeps it under another name, an editor may keep one too', async () => {
const f = await fixture();
// An editor of the list keeps only a tour of a trip they can open themselves.
await addTripMember(orm, f.alps.id, f.editor.id);
const { tour } = await service.save(f.editor.id, f.list.id, {
source_trip_id: f.alps.id,
source_place_id: f.ridge.id,
@@ -231,11 +231,11 @@ describe('TourChangeMcp (MCP-T02)', () => {
});
expect(read(await prepare(f.trip.id, f.bakery.id, changes, f.user.id))).toEqual({ error: 'Tour not found.' });
const failure = new Error('database gone');
const prepare = vi.spyOn(t.get(TourChangePreviewService), 'prepare').mockRejectedValueOnce(failure);
const servicePrepare = vi.spyOn(t.get(TourChangePreviewService), 'prepare').mockRejectedValueOnce(failure);
try {
await expect(prepare(f.trip.id, f.walk.id, changes, f.user.id)).rejects.toBe(failure);
} finally {
prepare.mockRestore();
servicePrepare.mockRestore();
}
});
@@ -193,7 +193,7 @@ describe('Tours deletion and assignment authorization (mock-only)', () => {
if (handler === 'create') {
expect(assignments.dayExists).toHaveBeenCalledWith('11', '7');
expect(assignments.placeExists).toHaveBeenCalledWith(42, '7');
expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour');
expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour', {});
expect(assignments.deleteAssignment).not.toHaveBeenCalled();
expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:created', { assignment }, 'socket');
} else {