From 76c7323e23033c2dd117df8ece9c6d5dd2d9cb88 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 12:44:07 +0200 Subject: [PATCH] fix(ci): parse errors, two leaky tests and the lint ratchets after the parity round --- client/scripts/eslint-baseline.json | 8 ++--- .../Assistant/AssistantWindow.test.tsx | 5 ++- .../Settings/ai/useAiConnections.ts | 2 +- client/src/pages/journey/useJourney.ts | 31 ++++++++++++------- .../pages/tripPlanner/useTripPlanner.test.tsx | 4 +-- .../src/pages/tripPlanner/useTripPlanner.ts | 27 +++++++++++----- client/src/store/inAppNotificationStore.ts | 17 +++++----- .../broadcastParity.test.ts | 2 +- server/scripts/eslint-baseline.json | 6 ++-- .../src/nest/assistant/cards/card-context.ts | 15 +++++---- .../nest/collections/collections.service.ts | 20 ++++++------ server/tests/unit/mcp/tools-files.test.ts | 4 +-- .../unit/mcp/tools-notifications.test.ts | 2 +- server/tests/unit/mcp/tools-places.test.ts | 8 ++--- .../tests/unit/mcp/tools-transports.test.ts | 9 +++--- .../tests/unit/mcp/tools-trip-members.test.ts | 4 +-- .../nest/assistant/cards/card-context.test.ts | 3 +- server/tests/unit/nest/collab.service.test.ts | 2 +- .../nest/collections.copy-to-trip.test.ts | 25 +++++++++++---- .../unit/nest/hike-routing.service.test.ts | 11 +++++-- .../tests/unit/nest/tags.controller.test.ts | 8 +++-- shared/src/book/book-page-ops.spec.ts | 2 +- 22 files changed, 128 insertions(+), 87 deletions(-) diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index d6ecc9098..cb3729347 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -3,15 +3,15 @@ "@typescript-eslint/no-non-null-assertion": 404, "@typescript-eslint/no-misused-promises": 870, "no-console": 88, - "complexity": 230, + "complexity": 229, "@typescript-eslint/no-explicit-any": 147, - "trek/no-swallowed-catch": 132, + "trek/no-swallowed-catch": 131, "max-params": 35, "trek/store-without-selector": 69, "react-hooks/refs": 557, "react-hooks/set-state-in-effect": 196, "@typescript-eslint/no-floating-promises": 166, - "react-hooks/exhaustive-deps": 119, + "react-hooks/exhaustive-deps": 118, "@typescript-eslint/no-unused-vars": 127, "react-hooks/immutability": 28, "max-lines-per-function": 133, @@ -35,7 +35,7 @@ }, "tests": { "@typescript-eslint/no-explicit-any": 491, - "@typescript-eslint/no-non-null-assertion": 1945, + "@typescript-eslint/no-non-null-assertion": 1935, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-floating-promises": 33, diff --git a/client/src/components/Assistant/AssistantWindow.test.tsx b/client/src/components/Assistant/AssistantWindow.test.tsx index 7db215fb7..33241e0e2 100644 --- a/client/src/components/Assistant/AssistantWindow.test.tsx +++ b/client/src/components/Assistant/AssistantWindow.test.tsx @@ -11,7 +11,7 @@ import type { AiStatusResponse } from '@trek/shared'; import { useLocation } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; import { tripRepo } from '../../repo/tripRepo'; import { chatOf, useAssistantStore } from '../../store/assistantStore'; import type { Trip } from '../../types'; @@ -104,6 +104,9 @@ beforeEach(() => { }); afterEach(() => { + // Unmounted before the store gets its real actions back: a window still on screen would ask the real + // loadStatus, whose failed request lands in a later case and hides the assistant there (status null). + cleanup(); useAssistantStore.setState(INITIAL, true); vi.restoreAllMocks(); vi.clearAllMocks(); diff --git a/client/src/components/Settings/ai/useAiConnections.ts b/client/src/components/Settings/ai/useAiConnections.ts index d4ce65dcb..e9af4f0ab 100644 --- a/client/src/components/Settings/ai/useAiConnections.ts +++ b/client/src/components/Settings/ai/useAiConnections.ts @@ -200,6 +200,6 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) { canOpen: status?.available === true, openAssistant, isAdmin, - openAdmin: () => navigate('/admin?tab=addons'), + openAdmin: () => void navigate('/admin?tab=addons'), }; } diff --git a/client/src/pages/journey/useJourney.ts b/client/src/pages/journey/useJourney.ts index 8a631d43f..35b66ea1c 100644 --- a/client/src/pages/journey/useJourney.ts +++ b/client/src/pages/journey/useJourney.ts @@ -31,6 +31,25 @@ function saveDismissedSuggestions(userId: number | undefined, dismissed: Set Promise): void { + useServerPing(JOURNEY_LIST_EVENTS, true, async () => { + try { + await loadJourneys() + } catch { + // A failed reread keeps the list on screen; the next event or visit reads it again. + } + }) +} + /** * Journey list page logic — owns the journey store load, the create-journey * modal (title, available trips, selection), the search box, the trip @@ -63,17 +82,7 @@ export function useJourney() { useEffect(() => { loadJourneys() }, []) - // A journey renamed, given other trips or deleted in another tab or through - // MCP, or this user taken off one as a contributor: the list reads itself - // again, once per burst. The spinner only shows over an empty list, so a - // reread of a full one does not flash. - useServerPing(['journey:updated', 'journey:deleted', 'journey:contributor:changed'], true, async () => { - try { - await loadJourneys() - } catch { - // A failed reread keeps the list on screen; the next event or visit reads it again. - } - }) + useJourneyListReread(loadJourneys) useEffect(() => { let cancelled = false diff --git a/client/src/pages/tripPlanner/useTripPlanner.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.test.tsx index 1eeac1c1a..0d8c571c6 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.test.tsx @@ -279,7 +279,7 @@ describe('useTripPlanner — bootstrap', () => { await waitFor(() => expect(tripsApi.getMembers).toHaveBeenCalledTimes(1)) env.forcedOffline = true - act(() => { result.current.refreshMembers() }) + await act(async () => { await result.current.refreshMembers() }) expect(tripsApi.getMembers).toHaveBeenCalledTimes(1) }) @@ -2845,7 +2845,7 @@ describe('useTripPlanner — misc state', () => { owner: { user_id: 1, username: 'owner' }, members: [{ user_id: 2 }, { user_id: 3 }], }) - act(() => { result.current.refreshMembers() }) + await act(async () => { await result.current.refreshMembers() }) await waitFor(() => expect(result.current.tripMembers).toHaveLength(3)) }) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index fcebced64..04c42bc46 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -67,6 +67,21 @@ import { usePlannerTripView } from './usePlannerTripView' import { useIsPhone } from '../../mobile/useIsPhone' import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' +/** + * The trip's roster from the server: the owner and the members. Null offline + * or when it could not be read; the roster on screen then stays, and the next + * member event or reload reads it again. + */ +async function fetchRoster(tripId: number): Promise { + if (!tripId || isEffectivelyOffline()) return null + try { + const d = await tripsApi.getMembers(tripId) + return [d.owner, ...(d.members || [])].filter(Boolean) + } catch { + return null + } +} + /** * Trip planner page logic, the big one. Owns the trip store wiring, addon * gating, accommodations/members loading, the tab + resizable-panel + selection @@ -178,13 +193,8 @@ export function useTripPlanner() { // Re-fetch the trip roster so consumers (Costs participants, Collab, …) pick up a // just-added guest or member without a full page reload. const refreshMembers = useCallback(async () => { - if (!tripId || isEffectivelyOffline()) return - try { - const d = await tripsApi.getMembers(tripId) - setTripMembers([d.owner, ...(d.members || [])].filter(Boolean)) - } catch { - // The roster on screen stays; the next member event or reload reads it again. - } + const roster = await fetchRoster(tripId) + if (roster) setTripMembers(roster) }, [tripId]) // A member added, removed or renamed elsewhere, and the trip going away under us. useTripRosterSync(tripId, refreshMembers) @@ -420,7 +430,8 @@ export function useTripPlanner() { .then(rows => setTripMembers(rows)) .catch(() => {}) } else { - void refreshMembers() + // The roster lands from the read's callback, never synchronously inside the effect. + void fetchRoster(tripId).then(roster => { if (roster) setTripMembers(roster) }) } } }, [tripId]) diff --git a/client/src/store/inAppNotificationStore.ts b/client/src/store/inAppNotificationStore.ts index 1ef428c6f..c4b16e7cd 100644 --- a/client/src/store/inAppNotificationStore.ts +++ b/client/src/store/inAppNotificationStore.ts @@ -65,6 +65,11 @@ interface NotificationState { const PAGE_SIZE = 20 +/** What marking everything read leaves, here or in another tab: every notification read, none to count. */ +function allRead(state: NotificationState): Partial { + return { notifications: state.notifications.map(n => n.is_read ? n : { ...n, is_read: true }), unreadCount: 0 } +} + export const useInAppNotificationStore = create((set, get) => ({ notifications: [], unreadCount: 0, @@ -130,10 +135,7 @@ export const useInAppNotificationStore = create((set, get) => markAllRead: async () => { try { await inAppNotificationsApi.markAllRead() - set(state => ({ - notifications: state.notifications.map(n => ({ ...n, is_read: true })), - unreadCount: 0, - })) + set(allRead) } catch { // best-effort } @@ -206,10 +208,5 @@ export const useInAppNotificationStore = create((set, get) => }, // Everything marked read at once elsewhere: the same end state markAllRead leaves. - handleAllRead: () => { - set(state => ({ - notifications: state.notifications.map(n => n.is_read ? n : { ...n, is_read: true }), - unreadCount: 0, - })) - }, + handleAllRead: () => set(allRead), })) diff --git a/client/tests/unit/remoteEventHandler/broadcastParity.test.ts b/client/tests/unit/remoteEventHandler/broadcastParity.test.ts index e5dbd1698..9da5d0708 100644 --- a/client/tests/unit/remoteEventHandler/broadcastParity.test.ts +++ b/client/tests/unit/remoteEventHandler/broadcastParity.test.ts @@ -148,7 +148,7 @@ describe('remoteEventHandler > broadcast parity', () => { expect(putBudget.mock.calls[0][0]).toEqual([state.budgetItems[0]]); }); - it('FE-BCAST-026: only this user's removal drops the open trip from the offline cache', async () => { + it("FE-BCAST-026: only this user's removal drops the open trip from the offline cache", async () => { const drop = vi.spyOn(offlineDb.trips, 'delete'); useTripStore.setState({ trip: buildTrip({ id: 5 }) }); send({ type: 'member:removed', tripId: 5, userId: 99 }); diff --git a/server/scripts/eslint-baseline.json b/server/scripts/eslint-baseline.json index 1de69a141..9c573da72 100644 --- a/server/scripts/eslint-baseline.json +++ b/server/scripts/eslint-baseline.json @@ -1,12 +1,12 @@ { "src": { - "@typescript-eslint/no-explicit-any": 89, - "@typescript-eslint/no-non-null-assertion": 264, + "@typescript-eslint/no-explicit-any": 86, + "@typescript-eslint/no-non-null-assertion": 263, "@typescript-eslint/no-require-imports": 18, "@typescript-eslint/no-unsafe-function-type": 1, "@typescript-eslint/no-unused-vars": 36, "no-await-in-loop": 563, - "no-console": 236, + "no-console": 235, "no-empty": 6 }, "suppressed": { diff --git a/server/src/nest/assistant/cards/card-context.ts b/server/src/nest/assistant/cards/card-context.ts index c35df2dab..baea816d6 100644 --- a/server/src/nest/assistant/cards/card-context.ts +++ b/server/src/nest/assistant/cards/card-context.ts @@ -39,14 +39,13 @@ export function memoisedReader(session: ReadSession): (tool: string, args?: Tool const memo = new Map>(); return (tool, args = {}) => { const key = readKey(tool, args); - let read = memo.get(key); - if (!read) { - read = session.read(tool, args).then( - (run) => (run.ok ? run.value : null), - () => null, - ); - memo.set(key, read); - } + const kept = memo.get(key); + if (kept !== undefined) return kept; + const read = session.read(tool, args).then( + (run) => (run.ok ? run.value : null), + () => null, + ); + memo.set(key, read); return read; }; } diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 06e2954ec..bdc4328bb 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -68,7 +68,7 @@ import type { CollectionUpdateRequest, CollectionSavePlaceRequest, CollectionSaveResult, - CollectionCopyToTripRequest, + CollectionCopyToTripRequest, CollectionCopyToTripResponse, CollectionPlaceUpdateRequest, CollectionStatus, CollectionLabel, @@ -1128,7 +1128,7 @@ export class CollectionsService { restTripEvents((this.realtime.broadcast as TripBroadcast).bind(this.realtime), tripId, socketId); /** Copy saved places into a trip; after the commit each copy goes out as `place:created`, as in the planner. */ - async copyToTrip(userId: number, body: CollectionCopyToTripRequest, events: TripEventSink): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { + async copyToTrip(userId: number, body: CollectionCopyToTripRequest, events: TripEventSink): Promise { // R6's opposite order from `savePlace`: trip access (AP6) first, then `place_edit`; each keeps its legacy order. const trip = await this.trips.findAccessible(body.trip_id, userId); if (!trip) httpError(404, 'Trip not found'); @@ -1158,7 +1158,7 @@ export class CollectionsService { else if (r.lat != null && r.lng != null) dedup.coords.push({ lat: r.lat, lng: r.lng }); } - const copiedIds: number[] = []; + const created: CollectionCopyToTripResponse['created'] = []; // each new trip place by the saved place it came from const skipped: { id: number; name: string }[] = []; // The whole copy is one logical write — atomic since the post-fold quirk pass. await this.uow.transactional(async () => { @@ -1183,15 +1183,15 @@ export class CollectionsService { const votes = await this.collectionPlaceRatings.listForPlace(s.id); for (const v of votes) if (tripMemberIds.has(v.user_id)) await this.tripPlaceRatings.insertIgnore(newPlaceId, v.user_id, v.rating); trackInsertedInDedupSet(s, dedup); - copiedIds.push(newPlaceId); + created.push({ id: newPlaceId, source_id: s.id }); } }); - for (const placeId of copiedIds) { - const place = await this.tripPlaces.findWithTagsAndRatings(placeId); // as the place routes send it - if (place) events.emit('place:created', { place }); - await this.places.onCreated(String(body.trip_id), placeId); // a no-op on no day, kept for parity + const rows = await Promise.all(created.map(({ id }) => this.tripPlaces.findWithTagsAndRatings(id))); // reads only + for (const [i, place] of rows.entries()) { + if (place) events.emit('place:created', { place }); // as the place routes send it + await this.places.onCreated(String(body.trip_id), created[i].id); // a no-op on no day, kept for parity } - return { copied: copiedIds.length, skipped }; + return { copied: created.length, skipped, created }; } // ------------------------------------------------------------------------- @@ -1219,7 +1219,7 @@ export class CollectionsService { const role = await this.roleOf(userId, r.collection_id); lists.push({ collection_id: r.collection_id, - name: r.name, + name: r.name, color: r.color, place_id: r.place_id, status: (r.status as CollectionStatus) ?? 'idea', can_edit: role !== null && role !== 'viewer', diff --git a/server/tests/unit/mcp/tools-files.test.ts b/server/tests/unit/mcp/tools-files.test.ts index 51123dcb4..24bd25a68 100644 --- a/server/tests/unit/mcp/tools-files.test.ts +++ b/server/tests/unit/mcp/tools-files.test.ts @@ -998,7 +998,7 @@ describe('Link changes reach every tab', () => { /** The one file:updated a link change sent, and the file it carried. */ function sentFile() { expect(broadcastMock.mock.calls.map((call) => call[1])).toEqual(['file:updated']); - return broadcastMock.mock.calls[0]![2].file as { + return broadcastMock.mock.calls[0][2].file as { id: number; linked_place_ids: number[]; linked_reservation_ids: number[]; @@ -1033,7 +1033,7 @@ describe('Link changes reach every tab', () => { const file = await insertFile(trip.id); await linkFile(orm, file.id, { reservation: reservation.id }); await linkFile(orm, file.id, { place: place.id }); - const doomed = (await linkRows(file.id))[0]!.id; + const doomed = (await linkRows(file.id))[0].id; await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'unlink_trip_file', diff --git a/server/tests/unit/mcp/tools-notifications.test.ts b/server/tests/unit/mcp/tools-notifications.test.ts index 2e393f8d7..994042230 100644 --- a/server/tests/unit/mcp/tools-notifications.test.ts +++ b/server/tests/unit/mcp/tools-notifications.test.ts @@ -333,6 +333,6 @@ describe('Read state reaches every tab of the user', () => { expect(unread.isError).toBe(true); }); expect(sent).not.toHaveBeenCalled(); - expect((await findRow(orm, Notifications, { id: notif.id }))!.is_read).toBe(0); + expect((await findRow(orm, Notifications, { id: notif.id }))?.is_read).toBe(0); }); }); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index a8984a5bd..adefd161e 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -1521,7 +1521,7 @@ describe('journey hooks on the MCP create and update paths', () => { }); expect(result.isError).toBeFalsy(); }); - expect((await skeletonFor(journey.id, place.id))!.title).toBe("Musée d'Orsay"); + expect((await skeletonFor(journey.id, place.id))?.title).toBe("Musée d'Orsay"); }); it('MCP-PLACES-JRN-002: bulk_update_places carries a new time into the journey entry of every place', async () => { @@ -1536,8 +1536,8 @@ describe('journey hooks on the MCP create and update paths', () => { }); expect((parseToolResult(result) as { count: number }).count).toBe(2); }); - expect((await skeletonFor(journey.id, place.id))!.entry_time).toBe('10:30'); - expect((await skeletonFor(journey.id, second.id))!.entry_time).toBe('10:30'); + expect((await skeletonFor(journey.id, place.id))?.entry_time).toBe('10:30'); + expect((await skeletonFor(journey.id, second.id))?.entry_time).toBe('10:30'); }); it('MCP-PLACES-JRN-003: set_place_image_from_file runs the journey hook after the new picture', async () => { @@ -1559,7 +1559,7 @@ describe('journey hooks on the MCP create and update paths', () => { expect(result.isError).toBeFalsy(); }); expect(await imageOf(place.id)).toMatch(/^\/uploads\/places\/[0-9a-f-]+\.jpg$/); - expect((await skeletonFor(journey.id, place.id))!.title).toBe('Lighthouse'); + expect((await skeletonFor(journey.id, place.id))?.title).toBe('Lighthouse'); }); it('MCP-PLACES-JRN-004: create_place hands the new place to the journey hook, as POST /places does', async () => { diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index 03dceaa52..6254979a4 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -17,6 +17,7 @@ import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpe import { makeDay } from '../../helpers/factories/trips'; import { FakeRealtimeService } from '../../helpers/fake-realtime'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { oneByOne } from '../../helpers/one-by-one'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; @@ -652,12 +653,12 @@ describe('Transport tools: the booking notice and the AirTrail push', () => { linked = await create('AY1502'); const local = await create('LX638'); await updateRows(orm, Reservations, { id: linked }, { external_source: 'airtrail', external_id: '501' }); - for (const reservationId of [linked, local]) { - await h.client.callTool({ + await oneByOne([linked, local], (reservationId) => + h.client.callTool({ name: 'update_transport', arguments: { tripId: trip.id, reservationId, status: 'confirmed' }, - }); - } + }), + ); }); expect(push.mock.calls).toEqual([[linked, trip.id]]); } finally { diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index d563df96d..8abd91bea 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -1040,7 +1040,7 @@ describe('Roster writes, as REST makes them', () => { broadcastMock.mockClear(); await h.client.callTool({ name: 'delete_trip_guest', arguments: { tripId: trip.id, guestId } }); expect(broadcastMock.mock.calls.map((call) => call[1])).toEqual(['member:removed', 'budget:updated']); - const [, , payload] = broadcastMock.mock.calls[1]!; + const [, , payload] = broadcastMock.mock.calls[1]; const item = payload.item as { id: number; members: { user_id: number }[] }; expect(item.id).toBe(shared.id); expect(item.members.map((m) => m.user_id)).toEqual([owner.id]); @@ -1057,7 +1057,7 @@ describe('Roster writes, as REST makes them', () => { broadcastMock.mockClear(); await h.client.callTool({ name: 'delete_trip_guest', arguments: { tripId: trip.id, guestId } }); expect(broadcastMock.mock.calls.map((call) => call[1])).toEqual(['member:removed', 'budget:updated']); - const item = broadcastMock.mock.calls[1]![2].item as { id: number; paid_by_user_id: number | null }; + const item = broadcastMock.mock.calls[1][2].item as { id: number; paid_by_user_id: number | null }; expect(item.id).toBe(legacy.id); expect(item.paid_by_user_id).toBeNull(); }); diff --git a/server/tests/unit/nest/assistant/cards/card-context.test.ts b/server/tests/unit/nest/assistant/cards/card-context.test.ts index d707b855b..2932b6c4c 100644 --- a/server/tests/unit/nest/assistant/cards/card-context.test.ts +++ b/server/tests/unit/nest/assistant/cards/card-context.test.ts @@ -15,6 +15,7 @@ import { memoisedReader, } from '../../../../../src/nest/assistant/cards/card-context'; import type { ToolCards } from '../../../../../src/nest/assistant/cards/card-types'; +import { oneByOne } from '../../../../helpers/one-by-one'; import type { AssistantMessagePart, TrekCard } from '@trek/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -76,7 +77,7 @@ describe('loadFor', () => { const cards: Record = { plan_days: { load: async (_args, read) => { - for (const year of [2021, 2022, 2023, 2024, 2025]) seen.push(await read('get_vacay_plan', { year })); + seen.push(...(await oneByOne([2021, 2022, 2023, 2024, 2025], (year) => read('get_vacay_plan', { year })))); return seen.length; }, }, diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index 8652c3811..0a8e36da0 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -450,7 +450,7 @@ describe('postNote / postMessage', () => { const { service, realtime, send } = await postingSvc(); const long = 'x'.repeat(100); const result = await service.postMessage(trip.id, { text: ` ${long} ` }, mcpWriter(service, trip.id, user1.id)); - expect(result.message!.text).toBe(long); + expect(result.message?.text).toBe(long); expect(realtime.broadcastMock).toHaveBeenCalledWith( trip.id, 'collab:message:created', diff --git a/server/tests/unit/nest/collections.copy-to-trip.test.ts b/server/tests/unit/nest/collections.copy-to-trip.test.ts index 41251a2f1..16819dd54 100644 --- a/server/tests/unit/nest/collections.copy-to-trip.test.ts +++ b/server/tests/unit/nest/collections.copy-to-trip.test.ts @@ -50,6 +50,13 @@ const placeCreated = () => .filter(([, event]) => event === 'place:created') .map(([room, , payload, sid]) => [room, payload, sid]); +/** The place a save to a collection kept; a save that answered with a warning instead fails the case. */ +async function saved(userId: number, body: Parameters[1]) { + const { place } = await svc.savePlace(userId, body); + if (!place) throw new Error(`expected ${body.name} to be saved`); + return place; +} + describe('CollectionsService.copyToTrip: the trip hears its new places', () => { it('COLLECTIONS-SVC-215: each copied place goes out as place:created with its tags, then runs the journey hook; a skipped one sends nothing', async () => { const { user } = await makeUser(orm); @@ -57,9 +64,9 @@ describe('CollectionsService.copyToTrip: the trip hears its new places', () => { const trip = await makeTrip(orm, user.id); const tag = await makeTag(orm, user.id, { name: 'rooftop' }); const col = await svc.createCollection(user.id, { name: 'Evenings' }); - const bar = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Sky Bar', tag_ids: [tag.id] })).place!; + const bar = await saved(user.id, { collection_id: col.id, name: 'Sky Bar', tag_ids: [tag.id] }); await makePlace(orm, trip.id, { name: 'Pantheon' }); - const dup = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Pantheon' })).place!; + const dup = await saved(user.id, { collection_id: col.id, name: 'Pantheon' }); t.realtime.broadcastMock.mockClear(); const res = await svc.copyToTrip( @@ -68,8 +75,14 @@ describe('CollectionsService.copyToTrip: the trip hears its new places', () => { svc.tripEvents(trip.id, 'sock-copy'), ); - expect(res).toEqual({ copied: 1, skipped: [{ id: dup.id, name: 'Pantheon' }] }); - const copy = (await findRow(orm, Places, { trip: trip.id, name: 'Sky Bar' }))!; + const copy = await findRow(orm, Places, { trip: trip.id, name: 'Sky Bar' }); + if (!copy) throw new Error('expected the copy in the trip'); + // Each new trip place is named with the saved place it came from, for the assistant's "Show on map". + expect(res).toEqual({ + copied: 1, + skipped: [{ id: dup.id, name: 'Pantheon' }], + created: [{ id: copy.id, source_id: bar.id }], + }); const sent = placeCreated(); expect(sent).toHaveLength(1); // The trip room, the REST socket left out, the place as the place routes send it. @@ -87,7 +100,7 @@ describe('CollectionsService.copyToTrip: the trip hears its new places', () => { const { user: stranger } = await makeUser(orm); await makeCategory(orm); const col = await svc.createCollection(owner.id, { name: 'Mine' }); - const gem = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Hidden gem' })).place!; + const gem = await saved(owner.id, { collection_id: col.id, name: 'Hidden gem' }); const theirs = await makeTrip(orm, stranger.id); t.realtime.broadcastMock.mockClear(); @@ -103,7 +116,7 @@ describe('CollectionsService.copyToTrip: the trip hears its new places', () => { await makeCategory(orm); const trip = await makeTrip(orm, user.id); const col = await svc.createCollection(user.id, { name: 'Plugin' }); - const spot = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Viewpoint' })).place!; + const spot = await saved(user.id, { collection_id: col.id, name: 'Viewpoint' }); t.realtime.broadcastMock.mockClear(); await svc.copyToTrip(user.id, { trip_id: trip.id, place_ids: [spot.id] }, svc.tripEvents(trip.id, undefined)); diff --git a/server/tests/unit/nest/hike-routing.service.test.ts b/server/tests/unit/nest/hike-routing.service.test.ts index 855edb2b3..c93d977ed 100644 --- a/server/tests/unit/nest/hike-routing.service.test.ts +++ b/server/tests/unit/nest/hike-routing.service.test.ts @@ -557,8 +557,13 @@ describe('HikeRoutingService: the hike created from a proposal', () => { const savedLine = async (placeId: number) => JSON.parse(String((await findRow(orm, Places, { id: placeId }))?.route_geometry)) as number[][]; - // The router would now plan another way, and its own caches (five minutes) have let the first go. - valhalla({ route: () => json(valhallaRoute([{ line: stepsOf(0, 20, 11.01), km: 0.222, seconds: 330 }])) }); + // The router would now plan another way, about 76 m east (within the snap limit, one leg per pair of points as + // Valhalla answers), and its own caches (five minutes) have let the first go. + const otherWay = valhallaRoute([ + { line: stepsOf(0, 10, 11.001), km: 0.111, seconds: 150 }, + { line: stepsOf(10, 20, 11.001), km: 0.111, seconds: 180 }, + ]); + valhalla({ route: () => json(otherWay) }); vi.advanceTimersByTime(6 * 60_000); const created = await settled(hiking.create(user.id, tripRow.id, request)); expect(asked('/route')).toHaveLength(0); @@ -571,6 +576,6 @@ describe('HikeRoutingService: the hike created from a proposal', () => { vi.advanceTimersByTime(10 * 60_000); const later = await settled(hiking.create(user.id, tripRow.id, { ...request, name: 'Later' })); expect(asked('/route')).toHaveLength(1); - expect((await savedLine(later.tour.place_id)).every(([, lng]) => lng === 11.01)).toBe(true); + expect((await savedLine(later.tour.place_id)).every(([, lng]) => lng === 11.001)).toBe(true); }); }); diff --git a/server/tests/unit/nest/tags.controller.test.ts b/server/tests/unit/nest/tags.controller.test.ts index 05885275b..77b4a994d 100644 --- a/server/tests/unit/nest/tags.controller.test.ts +++ b/server/tests/unit/nest/tags.controller.test.ts @@ -16,15 +16,17 @@ const user = { id: 5 } as User; * sends is held by tools-tags-maps-weather.test.ts and the broadcast parity suite. */ function writesFor(svc: Partial) { + // As the controller gets it: a case hands in the methods its route reaches. + const tags = svc as TagsService; return { create: vi.fn((userId: number, name: string, color: string | undefined, _socketId: string | undefined) => - svc.create!(userId, name, color), + tags.create(userId, name, color), ), update: vi.fn( (_userId: number, id: string, changes: { name?: string; color?: string }, _socketId: string | undefined) => - svc.update!(id, changes.name, changes.color), + tags.update(id, changes.name, changes.color), ), - remove: vi.fn((_userId: number, id: string, _socketId: string | undefined) => svc.remove!(id)), + remove: vi.fn((_userId: number, id: string, _socketId: string | undefined) => tags.remove(id)), }; } let writes: ReturnType; diff --git a/shared/src/book/book-page-ops.spec.ts b/shared/src/book/book-page-ops.spec.ts index 955767589..8cbc3d752 100644 --- a/shared/src/book/book-page-ops.spec.ts +++ b/shared/src/book/book-page-ops.spec.ts @@ -68,7 +68,7 @@ describe('the blank book and the fixed pages', () => { expect(doc.title.length).toBeLessThan(500); }); - it('BOOKPG-022: lays a new book down in the format it is asked for, at that format's size', () => { + it("BOOKPG-022: lays a new book down in the format it is asked for, at that format's size", () => { const doc = blankBook({ title: 'Iceland', preset: 'a4-landscape', newId }); expect(doc.page).toMatchObject({ preset: 'a4-landscape', pageWidth: 297, pageHeight: 210, bleed: 3, safe: 5 }); expect(doc.page.pageNumbers.startAt).toBe(1);