From c859cf3624be44787f732f6e3f56affe2b526bbd Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 14:24:59 +0200 Subject: [PATCH] test(server): the new demo-gate, timestamp, JSON-column and maps tests add no ESLint warnings Non-null assertions become explicit guards, the two demo-gate loops collect their answers with Promise.all, and the MapsService imports the maps tests no longer use are gone. The src counts the refactors lowered are taken off the warnings baseline. --- server/scripts/eslint-baseline.json | 8 +-- server/tests/unit/mcp/demo-tool-gate.test.ts | 52 ++++++++++++------- .../unit/nest/accommodations.service.test.ts | 3 +- server/tests/unit/nest/auth.service.test.ts | 4 +- server/tests/unit/nest/collab.service.test.ts | 5 +- .../unit/nest/journey-domain.service.test.ts | 4 +- server/tests/unit/nest/maps.amap.test.ts | 1 - server/tests/unit/nest/maps.area.test.ts | 1 - .../tests/unit/nest/maps.autocomplete.test.ts | 1 - server/tests/unit/nest/maps.geocode.test.ts | 1 - .../tests/unit/nest/maps.gers-details.test.ts | 2 +- server/tests/unit/nest/maps.pois.test.ts | 4 +- .../tests/unit/nest/maps.search-merge.test.ts | 1 - server/tests/unit/nest/oauth.service.test.ts | 15 +++--- server/tests/unit/nest/trips.service.test.ts | 1 - 15 files changed, 57 insertions(+), 46 deletions(-) diff --git a/server/scripts/eslint-baseline.json b/server/scripts/eslint-baseline.json index e5208fa84..1fa1011a6 100644 --- a/server/scripts/eslint-baseline.json +++ b/server/scripts/eslint-baseline.json @@ -1,12 +1,12 @@ { "src": { - "@typescript-eslint/no-explicit-any": 93, + "@typescript-eslint/no-explicit-any": 90, "@typescript-eslint/no-non-null-assertion": 297, "@typescript-eslint/no-require-imports": 18, "@typescript-eslint/no-unsafe-function-type": 1, - "@typescript-eslint/no-unused-vars": 43, - "no-await-in-loop": 566, - "no-console": 237, + "@typescript-eslint/no-unused-vars": 36, + "no-await-in-loop": 563, + "no-console": 236, "no-empty": 6 }, "suppressed": { diff --git a/server/tests/unit/mcp/demo-tool-gate.test.ts b/server/tests/unit/mcp/demo-tool-gate.test.ts index a3592a798..8458466b0 100644 --- a/server/tests/unit/mcp/demo-tool-gate.test.ts +++ b/server/tests/unit/mcp/demo-tool-gate.test.ts @@ -10,9 +10,16 @@ * so a tool declared `mode: 'write'` but annotated read-only (or the reverse) * fails here instead of slipping past the gate. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { ADDON_IDS } from '../../../src/addons'; import { db as testDb } from '../../../src/db/database'; +import { isDemoGatedTool, trekDemoToolGate } from '../../../src/mcp/nest-mcp-policy'; +import type { McpContext, McpRegistry, McpRegistryListing } from '../../../src/nest-mcp'; +import { createUser } from '../../helpers/factories'; +import { createMcpTestRegistry } from '../../helpers/mcp-test-controllers'; +import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; + +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); @@ -25,14 +32,10 @@ vi.mock('../../../src/config', () => ({ })); vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); -import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; -import { createUser } from '../../helpers/factories'; -import { createMcpTestRegistry } from '../../helpers/mcp-test-controllers'; -import { ADDON_IDS } from '../../../src/addons'; -import { isDemoGatedTool, trekDemoToolGate } from '../../../src/mcp/nest-mcp-policy'; -import type { McpContext, McpRegistry, McpRegistryListing } from '../../../src/nest-mcp'; - -const DEMO_REFUSAL = { content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }], isError: true }; +const DEMO_REFUSAL = { + content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }], + isError: true, +}; interface CapturedTool { annotations?: Record; @@ -43,7 +46,11 @@ interface CapturedTool { async function captureTools(registry: McpRegistry, ctx: McpContext): Promise> { const tools = new Map(); const server = { - registerTool: (name: string, config: { annotations?: Record }, cb: (...a: unknown[]) => unknown) => { + registerTool: ( + name: string, + config: { annotations?: Record }, + cb: (...a: unknown[]) => unknown, + ) => { // Arguments as a client would send nothing: the gate answers before // the handler ever reads them. tools.set(name, { annotations: config.annotations, call: async (args = {}) => cb(args, {}) }); @@ -84,9 +91,8 @@ describe('demo tool gate over the whole registry', () => { const writes = [...tools].filter(([, t]) => t.annotations?.readOnlyHint !== true); expect(writes.length).toBeGreaterThan(150); - for (const [name, tool] of writes) { - expect({ name, result: await tool.call() }).toEqual({ name, result: DEMO_REFUSAL }); - } + const answers = await Promise.all(writes.map(async ([name, tool]) => ({ name, result: await tool.call() }))); + expect(answers).toEqual(writes.map(([name]) => ({ name, result: DEMO_REFUSAL }))); }); it('DEMOGATE-002: no read-only tool is held back for the demo account', async () => { @@ -98,9 +104,10 @@ describe('demo tool gate over the whole registry', () => { const reads = [...tools].filter(([, t]) => t.annotations?.readOnlyHint === true); expect(reads.length).toBeGreaterThan(80); - for (const [name, tool] of reads) { - expect({ name, refusal: await gate({ name, annotations: tool.annotations }, ctx) }).toEqual({ name, refusal: undefined }); - } + const refusals = await Promise.all( + reads.map(async ([name, tool]) => ({ name, refusal: await gate({ name, annotations: tool.annotations }, ctx) })), + ); + expect(refusals).toEqual(reads.map(([name]) => ({ name, refusal: undefined }))); }); it('DEMOGATE-003: an ordinary account reaches the write handlers in demo mode', async () => { @@ -108,7 +115,10 @@ describe('demo tool gate over the whole registry', () => { const { user } = createUser(testDb, { email: 'alice@example.com' }); const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false }); // create_tag has no trip to check and no addon gate: it writes and answers. - const result = (await tools.get('create_tag')!.call({ name: 'Mine' })) as { isError?: boolean; content: { text: string }[] }; + const result = (await tools.get('create_tag')?.call({ name: 'Mine' })) as { + isError?: boolean; + content: { text: string }[]; + }; expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain('Mine'); }); @@ -116,7 +126,7 @@ describe('demo tool gate over the whole registry', () => { it('DEMOGATE-004: outside demo mode the demo account is an ordinary account', async () => { const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); const tools = await captureTools(registry, { userId: demo.id, scopes: null, isStaticToken: false }); - const result = (await tools.get('create_tag')!.call({ name: 'Mine' })) as { isError?: boolean }; + const result = (await tools.get('create_tag')?.call({ name: 'Mine' })) as { isError?: boolean }; expect(result.isError).toBeUndefined(); }); @@ -153,7 +163,9 @@ describe('trekDemoToolGate', () => { it('DEMOGATE-010: refuses a write tool for a demo account and asks with the caller id', async () => { const isDemo = vi.fn(async () => true); - expect(await trekDemoToolGate(isDemo)({ name: 'w', annotations: { readOnlyHint: false } }, ctx)).toEqual(DEMO_REFUSAL); + expect(await trekDemoToolGate(isDemo)({ name: 'w', annotations: { readOnlyHint: false } }, ctx)).toEqual( + DEMO_REFUSAL, + ); expect(isDemo).toHaveBeenCalledWith(7); }); diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 72fa943d7..1e326ad06 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -238,7 +238,8 @@ describe('updateAccommodation', () => { const before = await linkedReservation(accom.id); await updateRows(await orm(), Reservations, { id: before.id }, { metadata: '{not json' }); - const existing = (await svc.getAccommodation(accom.id, trip.id))!; + const existing = await svc.getAccommodation(accom.id, trip.id); + if (!existing) throw new Error('accommodation not found'); await svc.updateAccommodation(accom.id, existing as Parameters[1], { check_in: '16:00' }); expect(JSON.parse((await linkedReservation(accom.id)).metadata)).toEqual({ check_in_time: '16:00' }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 6f3a62630..d60bf321b 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -1109,11 +1109,11 @@ describe('resetPassword', () => { const { user } = createUser(testDb); const issued = await svc.requestPasswordReset(user.email, null); const row = await findRow(orm, PasswordResetTokens, { user: user.id }); - expect(row!.expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(row?.expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); const aMinuteAgo = new Date(Date.now() - 60_000).toISOString().slice(0, 19).replace('T', ' '); await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: aMinuteAgo }); - expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery ?? '', new_password: 'Fresh123!' })) .toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 }); }); diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index b0e9f09a5..c6c0c5a3d 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -225,11 +225,12 @@ describe('votePoll', () => { it('COLLAB-SVC-006b: a poll whose stored options are not JSON lists with no options and refuses every vote', async () => { const { user1, trip } = setup(); const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - await updateRows(orm, CollabPolls, { id: poll!.id }, { options: '{broken' }); + if (!poll) throw new Error('poll not created'); + await updateRows(orm, CollabPolls, { id: poll.id }, { options: '{broken' }); const [listed] = (await svc.listPolls(trip.id)) as Array<{ options: unknown[] }>; expect(listed.options).toEqual([]); - expect((await svc.votePoll(trip.id, poll!.id, user1.id, 0)).error).toBe('invalid_index'); + expect((await svc.votePoll(trip.id, poll.id, user1.id, 0)).error).toBe('invalid_index'); }); it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', async () => { diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 8f00eed8b..db1e1df37 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -2740,9 +2740,9 @@ describe('entry enrichment', () => { const names = (rows: Array<{ source_trip_id?: number | null; source_trip_name?: string | null }>) => rows.map((e) => [e.source_trip_id ?? null, e.source_trip_name]).sort((x, y) => Number(x[0]) - Number(y[0])); - const listed = names((await svc.listEntries(journey.id, user.id))!); + const listed = names((await svc.listEntries(journey.id, user.id)) ?? []); expect(listed).toEqual([[null, null], [a.id, 'Alps'], [b.id, 'Baltic']].sort((x, y) => Number(x[0]) - Number(y[0]))); - const full = (await svc.getJourneyFull(journey.id, user.id))! as unknown as { entries: Array<{ source_trip_id?: number | null; source_trip_name?: string | null }> }; + const full = (await svc.getJourneyFull(journey.id, user.id)) as unknown as { entries: Array<{ source_trip_id?: number | null; source_trip_name?: string | null }> }; expect(names(full.entries)).toEqual(listed); }); diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts index dff0faec5..99bd180b0 100644 --- a/server/tests/unit/nest/maps.amap.test.ts +++ b/server/tests/unit/nest/maps.amap.test.ts @@ -60,7 +60,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( trekPlacesSearch: vi.fn(async (): Promise => []), })); -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { trekPlacesSearch } from '../../../src/nest/maps/trek-places.client'; diff --git a/server/tests/unit/nest/maps.area.test.ts b/server/tests/unit/nest/maps.area.test.ts index 5e8bd13e4..38ba39df7 100644 --- a/server/tests/unit/nest/maps.area.test.ts +++ b/server/tests/unit/nest/maps.area.test.ts @@ -17,7 +17,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index 4d33772f6..c519c6b6d 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -23,7 +23,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; diff --git a/server/tests/unit/nest/maps.geocode.test.ts b/server/tests/unit/nest/maps.geocode.test.ts index 5a42e9726..754db7082 100644 --- a/server/tests/unit/nest/maps.geocode.test.ts +++ b/server/tests/unit/nest/maps.geocode.test.ts @@ -30,7 +30,6 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; diff --git a/server/tests/unit/nest/maps.gers-details.test.ts b/server/tests/unit/nest/maps.gers-details.test.ts index 77e64beed..9090a22f0 100644 --- a/server/tests/unit/nest/maps.gers-details.test.ts +++ b/server/tests/unit/nest/maps.gers-details.test.ts @@ -156,6 +156,6 @@ describe('MapsService.getPlaceDetails for a gers: id', () => { await expect(svc.getPlaceDetails(1, 'gers:abc-123')).resolves.toEqual({ place: null }); expect(mockById).not.toHaveBeenCalled(); - expect(osmOf.get(svc)!.resolveOsmIdentity).not.toHaveBeenCalled(); + expect(osmOf.get(svc)?.resolveOsmIdentity).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/maps.pois.test.ts b/server/tests/unit/nest/maps.pois.test.ts index b866ef010..66360cc8c 100644 --- a/server/tests/unit/nest/maps.pois.test.ts +++ b/server/tests/unit/nest/maps.pois.test.ts @@ -129,7 +129,9 @@ const osmOf = new WeakMap(); // Overpass is the one network call this file must never make; stubbing it is // also what turns "dropped through" into something a case can assert. function stubOverpass(svc: MapsService) { - return vi.spyOn(osmOf.get(svc)!, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER); + const osm = osmOf.get(svc); + if (!osm) throw new Error('no OSM client recorded for this service'); + return vi.spyOn(osm, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER); } function rows(n: number) { diff --git a/server/tests/unit/nest/maps.search-merge.test.ts b/server/tests/unit/nest/maps.search-merge.test.ts index 3f4b80544..93346ef2e 100644 --- a/server/tests/unit/nest/maps.search-merge.test.ts +++ b/server/tests/unit/nest/maps.search-merge.test.ts @@ -30,7 +30,6 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index d03fe18cf..2eab9f527 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -602,16 +602,17 @@ describe('listOAuthSessions + revokeSession', () => { it('stores both expiries in the canonical text and lists them in the ISO spelling the API has always answered', async () => { const { user } = createUser(testDb); const created = await makeClient(user.id); - const clientId = created.client!.client_id as string; + const clientId = created.client?.client_id as string; await issueTokens(clientId, user.id, ['trips:read']); const row = await findRow(t, OauthTokens, { user: user.id }); - expect(row!.access_token_expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); - expect(row!.refresh_token_expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + if (!row) throw new Error('token row not stored'); + expect(row.access_token_expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(row.refresh_token_expires_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); const [session] = await listOAuthSessions(user.id); - expect(session.access_token_expires_at).toBe(`${row!.access_token_expires_at.replace(' ', 'T')}.000Z`); - expect(session.refresh_token_expires_at).toBe(`${row!.refresh_token_expires_at.replace(' ', 'T')}.000Z`); + expect(session.access_token_expires_at).toBe(`${row.access_token_expires_at.replace(' ', 'T')}.000Z`); + expect(session.refresh_token_expires_at).toBe(`${row.refresh_token_expires_at.replace(' ', 'T')}.000Z`); const [adminRow] = (await svc.listAllOAuthSessions()) as Array>; expect(adminRow.access_token_expires_at).toBe(session.access_token_expires_at); }); @@ -619,7 +620,7 @@ describe('listOAuthSessions + revokeSession', () => { it('a legacy ISO expiry still lists as stored', async () => { const { user } = createUser(testDb); const created = await makeClient(user.id); - const clientId = created.client!.client_id as string; + const clientId = created.client?.client_id as string; await issueTokens(clientId, user.id, ['trips:read']); await updateRows(t, OauthTokens, { user: user.id }, { access_token_expires_at: '2999-01-02T03:04:05.678Z', refresh_token_expires_at: '2999-01-02T03:04:05.678Z' }); @@ -1435,7 +1436,7 @@ describe('admin OAuth sessions', () => { it('a malformed consent row counts as consent to nothing, and a new grant replaces it', async () => { const { user } = createUser(testDb); const created = await makeClient(user.id); - const clientId = created.client!.client_id as string; + const clientId = created.client?.client_id as string; await saveConsent(clientId, user.id, ['trips:read']); await updateRows(t, OauthConsents, { user: user.id }, { scopes: '{broken' }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index d7b81deac..5d258beb0 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -65,7 +65,6 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members. import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { accommodationsOver, makeAccommodationsService } from '../../helpers/accommodations-service'; -import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';