diff --git a/server/tests/e2e/collection-tours.e2e.test.ts b/server/tests/e2e/collection-tours.e2e.test.ts index 42381cc0a..584673579 100644 --- a/server/tests/e2e/collection-tours.e2e.test.ts +++ b/server/tests/e2e/collection-tours.e2e.test.ts @@ -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') diff --git a/server/tests/e2e/tours-run.e2e.test.ts b/server/tests/e2e/tours-run.e2e.test.ts index 646967241..3f665b982 100644 --- a/server/tests/e2e/tours-run.e2e.test.ts +++ b/server/tests/e2e/tours-run.e2e.test.ts @@ -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); diff --git a/server/tests/e2e/tours-ski.e2e.test.ts b/server/tests/e2e/tours-ski.e2e.test.ts index 8572b9a0f..3ca97768b 100644 --- a/server/tests/e2e/tours-ski.e2e.test.ts +++ b/server/tests/e2e/tours-ski.e2e.test.ts @@ -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]); diff --git a/server/tests/helpers/one-by-one.ts b/server/tests/helpers/one-by-one.ts new file mode 100644 index 000000000..c174fe16b --- /dev/null +++ b/server/tests/helpers/one-by-one.ts @@ -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(items: readonly T[], send: (item: T) => PromiseLike): Promise { + return await items.reduce>( + async (sent, item) => [...(await sent), await send(item)], + Promise.resolve([]), + ); +} diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 40b09551c..f18f50440 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -386,6 +386,7 @@ describe('ToursRepository writes', () => { ski_technique: null, track_started_at: null, track_ended_at: null, + stage_ends: 0, }); }); diff --git a/server/tests/unit/db/tour-source-hint-columns.test.ts b/server/tests/unit/db/tour-source-hint-columns.test.ts new file mode 100644 index 000000000..6eaf79f6c --- /dev/null +++ b/server/tests/unit/db/tour-source-hint-columns.test.ts @@ -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()); + }); +}); diff --git a/server/tests/unit/nest/collection-tours.service.test.ts b/server/tests/unit/nest/collection-tours.service.test.ts index ab4959f98..b2f8c98bb 100644 --- a/server/tests/unit/nest/collection-tours.service.test.ts +++ b/server/tests/unit/nest/collection-tours.service.test.ts @@ -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, diff --git a/server/tests/unit/nest/tour-change.mcp.test.ts b/server/tests/unit/nest/tour-change.mcp.test.ts index 2d3735e1e..35c73a0ed 100644 --- a/server/tests/unit/nest/tour-change.mcp.test.ts +++ b/server/tests/unit/nest/tour-change.mcp.test.ts @@ -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(); } }); diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts index fd58b051e..914e82067 100644 --- a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -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 {