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';