From 8b0c768ba038175e6a183ba1be2591c88ed481e1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 16 Aug 2026 09:44:03 +0200 Subject: [PATCH] refactor(places): spool place-image and collection uploads through storage --- .../collections/collections.controller.ts | 51 +++--- .../nest/collections/collections.module.ts | 23 ++- server/src/nest/common/place-image-upload.ts | 50 ++---- server/src/nest/places/places.controller.ts | 11 +- server/src/nest/places/places.module.ts | 19 ++- server/tests/integration/collections.test.ts | 156 ++++++++++++++++++ server/tests/integration/places.test.ts | 7 +- .../unit/nest/collections.controller.test.ts | 76 +++++---- .../tests/unit/nest/places.controller.test.ts | 128 +++++++------- 9 files changed, 363 insertions(+), 158 deletions(-) create mode 100644 server/tests/integration/collections.test.ts diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index e1f85a729..30adf0be7 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -18,11 +18,8 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; -import type { Request } from 'express'; -import { diskStorage } from 'multer'; +import type { Options } from 'multer'; import path from 'path'; -import fs from 'fs'; -import { v4 as uuidv4 } from 'uuid'; import type { User } from '../../types'; import { CollectionsService } from './collections.service'; import { AddonGuard } from '../addons/addon.guard'; @@ -30,7 +27,8 @@ import { RequireAddon } from '../addons/require-addon.decorator'; import { ADDON_IDS } from '../../addons'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload'; +import { PLACE_IMAGE_FILE_FILTER } from '../common/place-image-upload'; +import { StorageService } from '../storage/storage.service'; import { placeImageUrl } from '../places/place-image'; import { CollectionCreateDto, @@ -56,23 +54,16 @@ import { } from './collections.dto'; import { PlaceRatingDto } from '../places/places.dto'; -const MAX_COVER_SIZE = 20 * 1024 * 1024; -const coversDir = path.join(__dirname, '../../../uploads/covers'); -const COVER_UPLOAD = { - storage: diskStorage({ - destination: (_req, _file, cb) => { - if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true }); - cb(null, coversDir); - }, - filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), - }), - limits: { fileSize: MAX_COVER_SIZE }, - fileFilter: (_req: Request, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { - const ext = path.extname(file.originalname).toLowerCase(); - const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; - if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) cb(null, true); - else cb(new Error('Only jpg, png, gif, webp images allowed'), false); - }, +export const MAX_COVER_SIZE = 20 * 1024 * 1024; +// Duplicated on purpose from trips.controller.ts (historical parity — no +// drive-by consolidation). Quirk preserved: a plain Error without statusCode +// maps to 500, not 400. Passed inline on the cover route; the storage engine +// comes from collections.module.ts's storage-upload factory options. +const COVER_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) cb(null, true); + else cb(new Error('Only jpg, png, gif, webp images allowed')); }; /** @@ -89,7 +80,7 @@ const COVER_UPLOAD = { @UseGuards(AddonGuard, JwtAuthGuard) @RequireAddon(ADDON_IDS.COLLECTIONS, 'Collections') export class CollectionsController { - constructor(private readonly collections: CollectionsService, private readonly env: RuntimeEnvService) {} + constructor(private readonly collections: CollectionsService, private readonly env: RuntimeEnvService, private readonly storage: StorageService) {} // ── Lists ───────────────────────────────────────────────────────────────── @Get() @@ -184,8 +175,8 @@ export class CollectionsController { @Post('places/:pid/image') @HttpCode(200) - @UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD)) - uploadPlaceImage( + @UseInterceptors(FileInterceptor('image', { fileFilter: PLACE_IMAGE_FILE_FILTER })) + async uploadPlaceImage( @CurrentUser() user: User, @Param('pid') pid: string, @UploadedFile() file: Express.Multer.File | undefined, @@ -195,6 +186,9 @@ export class CollectionsController { throw new HttpException(DEMO_WRITE_ERROR, 403); } if (!file) throw new HttpException({ error: 'No image uploaded' }, 400); + // Commit the spooled upload to its final storage location (atomic + // same-volume rename) before the DB row references the final path. + await this.storage.put('places', file.filename, { tmpPath: file.path }); return this.collections.setPlaceImage(user.id, Number(pid), placeImageUrl(file.filename), socketId); } @@ -360,12 +354,15 @@ export class CollectionsController { } @Post(':id/cover') - @UseInterceptors(FileInterceptor('cover', COVER_UPLOAD)) - uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { + @UseInterceptors(FileInterceptor('cover', { fileFilter: COVER_FILE_FILTER })) + async uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { if (isDemoWriteBlocked(this.env, user.email)) { throw new HttpException(DEMO_WRITE_ERROR, 403); } if (!file) throw new HttpException({ error: 'No image uploaded' }, 400); + // Commit the spooled upload to its final storage location (atomic + // same-volume rename) before the DB row references the final path. + await this.storage.put('covers', file.filename, { tmpPath: file.path }); const coverUrl = `/uploads/covers/${file.filename}`; return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId); } diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index ee569a326..4f0468c55 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -9,12 +9,33 @@ import { AddonsModule } from '../addons/addons.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { MulterModule } from '@nestjs/platform-express'; +import { StorageModule } from '../storage/storage.module'; +import { StorageService } from '../storage/storage.service'; +import { buildStorageUploadOptions } from '../storage/storage-upload.factory'; +import { MAX_COVER_SIZE } from './collections.controller'; /** Collections domain (saved-places library). Registered in AppModule. * Exports CollectionsService for in-container consumers (PluginsModule's * RPC host deps factory). */ @Module({ - imports: [NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule], + imports: [ + // Two upload routes, two categories: the per-file resolver routes each part + // to the right spool. Fieldname routing is exact — FileInterceptor('cover')/ + // FileInterceptor('image') reject any other part with LIMIT_UNEXPECTED_FILE + // before the storage callbacks run. Both routes pass their fileFilter + // inline (cover keeps its plain-Error 500 quirk; image its statusCode 400). + MulterModule.registerAsync({ + imports: [StorageModule], + inject: [StorageService], + useFactory: (storage: StorageService) => + buildStorageUploadOptions(storage, { + category: (_req, file) => (file.fieldname === 'image' ? 'places' : 'covers'), + maxSize: MAX_COVER_SIZE, // same 20 MB cap for covers and place images + }), + }), + StorageModule, + NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule], controllers: [CollectionsController], providers: [CollectionsService, CollectionsMcp, CollectionsRpc], exports: [CollectionsService], diff --git a/server/src/nest/common/place-image-upload.ts b/server/src/nest/common/place-image-upload.ts index 61aacb21f..b8f304a4a 100644 --- a/server/src/nest/common/place-image-upload.ts +++ b/server/src/nest/common/place-image-upload.ts @@ -1,38 +1,24 @@ -import fs from 'fs'; import path from 'path'; -import { diskStorage } from 'multer'; -import { v4 as uuidv4 } from 'uuid'; -import type { Request } from 'express'; -import { PLACE_IMAGES_DIR } from '../places/place-image'; +import type { Options } from 'multer'; -const MAX_PLACE_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB — same cap as covers. +export const MAX_PLACE_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB — same cap as covers. /** - * Multer config for the custom place-image upload (#1136). Mirrors the collection - * COVER_UPLOAD: server-chosen UUID filename, image-only filter, written to the - * dedicated uploads/places dir. Shared by the trip-place and collection-place - * upload endpoints. + * fileFilter for the custom place-image upload (#1136), shared by the + * trip-place and collection-place upload endpoints (passed inline on each + * route; the storage engine — spool destination + UUID filename — comes from + * the owning module's storage-upload factory options). */ -export const PLACE_IMAGE_UPLOAD = { - storage: diskStorage({ - destination: (_req, _file, cb) => { - if (!fs.existsSync(PLACE_IMAGES_DIR)) fs.mkdirSync(PLACE_IMAGES_DIR, { recursive: true }); - cb(null, PLACE_IMAGES_DIR); - }, - filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), - }), - limits: { fileSize: MAX_PLACE_IMAGE_SIZE }, - fileFilter: (_req: Request, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { - const ext = path.extname(file.originalname).toLowerCase(); - const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; - if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) { - cb(null, true); - } else { - // Carry statusCode so TrekExceptionFilter maps the rejection to a 400 rather - // than a 500 (same contract as the avatar upload's fileFilter). - const err: Error & { statusCode?: number } = new Error('Only jpg, png, gif, webp images allowed'); - err.statusCode = 400; - cb(err, false); - } - }, +export const PLACE_IMAGE_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) { + cb(null, true); + } else { + // Carry statusCode so TrekExceptionFilter maps the rejection to a 400 rather + // than a 500 (same contract as the avatar upload's fileFilter). + const err: Error & { statusCode?: number } = new Error('Only jpg, png, gif, webp images allowed'); + err.statusCode = 400; + cb(err); + } }; diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 9d13543db..1d97f3c22 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -27,7 +27,8 @@ import { isUpdateConflict } from '../common/conflictResult'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; -import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload'; +import { PLACE_IMAGE_FILE_FILTER } from '../common/place-image-upload'; +import { StorageService } from '../storage/storage.service'; import { placeImageUrl } from './place-image'; import { PlaceBulkDeleteDto, @@ -93,6 +94,7 @@ export class PlacesController { constructor( private readonly places: PlacesService, private readonly env: RuntimeEnvService, + private readonly storage: StorageService, ) {} private requireTrip(tripId: string, user: User) { @@ -340,8 +342,8 @@ export class PlacesController { @Post(':id/image') @HttpCode(200) - @UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD)) - uploadImage( + @UseInterceptors(FileInterceptor('image', { fileFilter: PLACE_IMAGE_FILE_FILTER })) + async uploadImage( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -358,6 +360,9 @@ export class PlacesController { if (!file) { throw new HttpException({ error: 'No image uploaded' }, 400); } + // Commit the spooled upload to its final storage location (atomic + // same-volume rename) before the DB row references the final path. + await this.storage.put('places', file.filename, { tmpPath: file.path }); // Reuse the existing image_url slot (the top-precedence thumbnail source); the // update path reclaims any previously uploaded file it replaces. const result = this.places.update(tripId, id, { image_url: placeImageUrl(file.filename) } as never); diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 07725ff76..5a45a9586 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -15,6 +15,11 @@ import { QueryHelpersModule } from '../query-helpers/query-helpers.module'; import { MapsModule } from '../maps/maps.module'; import { AuthModule } from '../auth/auth.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; +import { MulterModule } from '@nestjs/platform-express'; +import { StorageModule } from '../storage/storage.module'; +import { StorageService } from '../storage/storage.service'; +import { buildStorageUploadOptions } from '../storage/storage-upload.factory'; +import { MAX_PLACE_IMAGE_SIZE } from '../common/place-image-upload'; /** * Places domain (S8 — Phase 2 trip sub-domain). Depends on L4 Categories + L5 @@ -25,7 +30,19 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; * is no places.bridge.ts: nothing outside the container consumes this domain. */ @Module({ - imports: [McpSharedModule, PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule, AssignmentsDomainModule], + imports: [ + // Module-level options deliberately carry NO fileFilter: the GPX/KML import + // route keeps its inline memoryStorage config and would inherit a + // module-level filter via the interceptor's shallow merge — the image + // route passes PLACE_IMAGE_FILE_FILTER inline instead. + MulterModule.registerAsync({ + imports: [StorageModule], + inject: [StorageService], + useFactory: (storage: StorageService) => + buildStorageUploadOptions(storage, { category: 'places', maxSize: MAX_PLACE_IMAGE_SIZE }), + }), + StorageModule, + McpSharedModule, PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule, AssignmentsDomainModule], controllers: [PlacesController], providers: [PlacesService, PlacesMcp, PlacesRpc], exports: [PlacesService], diff --git a/server/tests/integration/collections.test.ts b/server/tests/integration/collections.test.ts new file mode 100644 index 000000000..e9187b24a --- /dev/null +++ b/server/tests/integration/collections.test.ts @@ -0,0 +1,156 @@ +/** + * Collections upload integration tests (COLL-P01…P05). + * + * Cover + place-image upload parity, written BEFORE the storage-upload swap: + * statuses, bodies, on-disk layout — including the cover filter's plain-Error + * 500 quirk (shared with the trip cover config) and the place-image filter's + * statusCode-400 contract. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; +import path from 'path'; +import fs from 'fs'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); + +import { buildApp } from '../../src/bootstrap'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import { createUser } from '../helpers/factories'; +import { authCookie } from '../helpers/auth'; + +let nestApp: INestApplication; +let app: Application; +const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); +const coversDir = path.join(__dirname, '../../uploads/covers'); +const placesDir = path.join(__dirname, '../../uploads/places'); + +function createCollection(ownerId: number): number { + return Number(testDb.prepare("INSERT INTO collections (owner_id, name) VALUES (?, 'C')").run(ownerId).lastInsertRowid); +} + +function createCollectionPlace(collectionId: number, ownerId: number): number { + return Number(testDb.prepare("INSERT INTO collection_places (collection_id, owner_id, name) VALUES (?, ?, 'P')").run(collectionId, ownerId).lastInsertRowid); +} + +beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); + +beforeEach(() => { + resetTestDb(testDb); + resetRateLimits(nestApp); + // Enable the collections addon (the controller sits behind AddonGuard). + testDb.prepare( + "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collections', 'Collections', 'Saved places', 'global', 'Bookmark', 1, 40)" + ).run(); +}); + +afterAll(async () => { + await nestApp.close(); + testDb.close(); + fs.rmSync(coversDir, { recursive: true, force: true }); + fs.rmSync(placesDir, { recursive: true, force: true }); +}); + +describe('Collection cover upload', () => { + it('COLL-P01 — cover upload stores /uploads/covers/ and writes the file', async () => { + const { user } = createUser(testDb); + const collectionId = createCollection(user.id); + + const res = await request(app) + .post(`/api/addons/collections/${collectionId}/cover`) + .set('Cookie', authCookie(user.id)) + .attach('cover', FIXTURE_IMG, 'cover.png'); + expect(res.status).toBe(201); + expect(res.body.cover_image).toMatch(/^\/uploads\/covers\/[0-9a-f-]{36}\.png$/); + const diskName = res.body.cover_image.replace('/uploads/covers/', ''); + expect(fs.existsSync(path.join(coversDir, diskName))).toBe(true); + }); + + it('COLL-P02 — no file → 400 "No image uploaded"', async () => { + const { user } = createUser(testDb); + const collectionId = createCollection(user.id); + + const res = await request(app) + .post(`/api/addons/collections/${collectionId}/cover`) + .set('Cookie', authCookie(user.id)); + expect(res.status).toBe(400); + expect(res.body.error).toBe('No image uploaded'); + }); + + it('COLL-P03 — non-image cover is 500 (plain-Error filter quirk — pinned, do not "fix")', async () => { + const { user } = createUser(testDb); + const collectionId = createCollection(user.id); + + const res = await request(app) + .post(`/api/addons/collections/${collectionId}/cover`) + .set('Cookie', authCookie(user.id)) + .attach('cover', Buffer.from('plain text'), { filename: 'doc.txt', contentType: 'text/plain' }); + expect(res.status).toBe(500); + }); +}); + +describe('Collection place image upload', () => { + it('COLL-P04 — place image upload stores /uploads/places/ and writes the file', async () => { + const { user } = createUser(testDb); + const collectionId = createCollection(user.id); + const placeId = createCollectionPlace(collectionId, user.id); + + const res = await request(app) + .post(`/api/addons/collections/places/${placeId}/image`) + .set('Cookie', authCookie(user.id)) + .attach('image', FIXTURE_IMG, 'photo.jpg'); + expect(res.status).toBe(200); + expect(res.body.image_url).toMatch(/^\/uploads\/places\/[0-9a-f-]{36}\.jpg$/); + const diskName = res.body.image_url.replace('/uploads/places/', ''); + expect(fs.existsSync(path.join(placesDir, diskName))).toBe(true); + }); + + it('COLL-P05 — non-image place upload is 400 with the bespoke message', async () => { + const { user } = createUser(testDb); + const collectionId = createCollection(user.id); + const placeId = createCollectionPlace(collectionId, user.id); + + const res = await request(app) + .post(`/api/addons/collections/places/${placeId}/image`) + .set('Cookie', authCookie(user.id)) + .attach('image', Buffer.from('%PDF-1.4'), { filename: 'doc.pdf', contentType: 'application/pdf' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Only jpg, png, gif, webp images allowed'); + }); +}); diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 7223d6ce7..583cc3565 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -1046,7 +1046,12 @@ describe('Custom place image upload', () => { .set('Cookie', authCookie(user.id)) .attach('image', FIXTURE_JPEG); expect(res.status).toBe(200); - expect(res.body.place.image_url).toMatch(/^\/uploads\/places\//); + expect(res.body.place.image_url).toMatch(/^\/uploads\/places\/[0-9a-f-]{36}\./); + // The bytes land at the final uploads/places path under the bare uuid name. + const fsMod = require('fs') as typeof import('fs'); + const pathMod = require('path') as typeof import('path'); + const diskName = res.body.place.image_url.replace('/uploads/places/', ''); + expect(fsMod.existsSync(pathMod.join(__dirname, '../../uploads/places', diskName))).toBe(true); const cleared = await request(app) .put(`/api/trips/${trip.id}/places/${place.id}`) diff --git a/server/tests/unit/nest/collections.controller.test.ts b/server/tests/unit/nest/collections.controller.test.ts index d264d051e..4ba5b9a0e 100644 --- a/server/tests/unit/nest/collections.controller.test.ts +++ b/server/tests/unit/nest/collections.controller.test.ts @@ -3,8 +3,11 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.serv import { HttpException } from '@nestjs/common'; import { CollectionsController } from '../../../src/nest/collections/collections.controller'; import type { CollectionsService } from '../../../src/nest/collections/collections.service'; +import type { StorageService } from '../../../src/nest/storage/storage.service'; import type { User } from '../../../src/types'; +const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService; + const user = { id: 1, username: 'owner', role: 'user', email: 'u@example.test' } as User; /** Service mock — access granted + owner by default; each method a spy. */ @@ -53,12 +56,21 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { throw new Error('expected the handler to throw'); } +async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to throw'); +} + describe('CollectionsController', () => { afterEach(() => { delete process.env.DEMO_MODE; }); it('lists / creates / reads / deletes lists (pass-throughs)', () => { const svc = makeService(); - const c = new CollectionsController(svc, new RuntimeEnvService()); + const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); expect(c.list(user)).toEqual({ collections: [], incomingInvites: [] }); expect(c.create(user, { name: 'Italy' } as never)).toEqual({ id: 7 }); expect(c.get(user, '3')).toEqual({ collection: { id: 3 }, places: [] }); @@ -74,7 +86,7 @@ describe('CollectionsController', () => { // bad payload before the handler runs. it('reorders a valid array', () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).reorder(user, { orderedIds: [3, 1, 2] } as never)).toEqual({ success: true }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).reorder(user, { orderedIds: [3, 1, 2] } as never)).toEqual({ success: true }); expect(svc.reorderCollections).toHaveBeenCalledWith(1, [3, 1, 2]); }); }); @@ -82,7 +94,7 @@ describe('CollectionsController', () => { describe('places', () => { it('savePlace / updatePlace / setStatus / deletePlace forward the socket id', () => { const svc = makeService(); - const c = new CollectionsController(svc, new RuntimeEnvService()); + const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); c.savePlace(user, { collection_id: 3, name: 'A' } as never, 'sid'); expect(svc.savePlace).toHaveBeenCalledWith(1, { collection_id: 3, name: 'A' }, 'sid'); c.updatePlace(user, '9', { name: 'B' } as never, 'sid'); @@ -101,7 +113,7 @@ describe('CollectionsController', () => { // bad payload before the handler runs. it('deleteMany deletes a valid list', () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).deleteMany(user, { ids: [1, 2] } as never, 'sid')).toEqual({ deleted: [1, 2] }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).deleteMany(user, { ids: [1, 2] } as never, 'sid')).toEqual({ deleted: [1, 2] }); expect(svc.deletePlacesMany).toHaveBeenCalledWith(1, [1, 2], 'sid'); }); }); @@ -109,12 +121,12 @@ describe('CollectionsController', () => { describe('membership lookup', () => { it('parses lat/lng when present', () => { const svc = makeService(); - new CollectionsController(svc, new RuntimeEnvService()).membership(user, 'gpid', undefined, 'Rome', '41.9', '12.5'); + new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user, 'gpid', undefined, 'Rome', '41.9', '12.5'); expect(svc.findMembership).toHaveBeenCalledWith(1, { google_place_id: 'gpid', google_ftid: undefined, name: 'Rome', lat: 41.9, lng: 12.5 }); }); it('leaves lat/lng undefined when absent', () => { const svc = makeService(); - new CollectionsController(svc, new RuntimeEnvService()).membership(user); + new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user); expect(svc.findMembership).toHaveBeenCalledWith(1, { google_place_id: undefined, google_ftid: undefined, name: undefined, lat: undefined, lng: undefined }); }); }); @@ -122,29 +134,29 @@ describe('CollectionsController', () => { describe('invites (owner-gated)', () => { it('403 when a non-owner invites', () => { const svc = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService()).invite(user, { collection_id: 3, user_id: 2 } as never))) + expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can invite' } }); }); it('surfaces a sendInvite error with its status', () => { const svc = makeService({ sendInvite: vi.fn().mockReturnValue({ error: 'Already a member', status: 409 }) }); - expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService()).invite(user, { collection_id: 3, user_id: 2 } as never))) + expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 409, body: { error: 'Already a member' } }); }); it('invites successfully as owner', () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).invite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); }); it('accept surfaces an error, else succeeds', () => { const bad = makeService({ acceptInvite: vi.fn().mockReturnValue({ error: 'Gone', status: 404 }) }); - expect(thrown(() => new CollectionsController(bad, new RuntimeEnvService()).acceptInvite(user, { collection_id: 3 } as never, 'sid'))) + expect(thrown(() => new CollectionsController(bad, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid'))) .toEqual({ status: 404, body: { error: 'Gone' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).acceptInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); expect(svc.acceptInvite).toHaveBeenCalledWith(1, 3, 'sid'); }); it('decline + leave forward the socket id', () => { const svc = makeService(); - const c = new CollectionsController(svc, new RuntimeEnvService()); + const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); expect(c.declineInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); expect(svc.declineInvite).toHaveBeenCalledWith(1, 3, 'sid'); expect(c.leave(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); @@ -152,64 +164,66 @@ describe('CollectionsController', () => { }); it('cancel is owner-gated', () => { const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService()).cancelInvite(user, { collection_id: 3, user_id: 2 } as never))) + expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can cancel invites' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).cancelInvite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); }); }); describe('members', () => { it('removeMember is owner-gated', () => { const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService()).removeMember(user, { collection_id: 3, user_id: 2 } as never))) + expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can remove members' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).removeMember(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); expect(svc.removeMember).toHaveBeenCalledWith(1, 3, 2); }); it('availableUsers is owner-gated', () => { const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService()).availableUsers(user, '3'))) + expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).availableUsers(user, '3'))) .toEqual({ status: 403, body: { error: 'Only the owner can manage members' } }); - expect(new CollectionsController(makeService(), new RuntimeEnvService()).availableUsers(user, '3')).toEqual({ users: [{ id: 2 }] }); + expect(new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).availableUsers(user, '3')).toEqual({ users: [{ id: 2 }] }); }); }); describe('uploadCover', () => { const file = { filename: 'x.jpg' } as Express.Multer.File; - it('403 in demo mode for a demo user', () => { + it('403 in demo mode for a demo user', async () => { process.env.DEMO_MODE = 'true'; const demo = { ...user, email: 'demo@trek.app' } as User; - expect(thrown(() => new CollectionsController(makeService(), new RuntimeEnvService()).uploadCover(demo, '3', file))) + expect(await thrownAsync(() => new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).uploadCover(demo, '3', file))) .toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); }); - it('400 when no file was uploaded', () => { - expect(thrown(() => new CollectionsController(makeService(), new RuntimeEnvService()).uploadCover(user, '3', undefined))) + it('400 when no file was uploaded', async () => { + expect(await thrownAsync(() => new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).uploadCover(user, '3', undefined))) .toEqual({ status: 400, body: { error: 'No image uploaded' } }); }); - it('stores the cover and forwards the socket id', () => { + it('commits + stores the cover and forwards the socket id', async () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService()).uploadCover(user, '3', file, 'sid')).toEqual({ id: 3 }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).uploadCover(user, '3', file, 'sid')).toEqual({ id: 3 }); + expect(storageStub.put).toHaveBeenCalledWith('covers', 'x.jpg', { tmpPath: undefined }); expect(svc.setCollectionCover).toHaveBeenCalledWith(1, 3, '/uploads/covers/x.jpg', 'sid'); }); }); describe('uploadPlaceImage', () => { const file = { filename: 'x.jpg' } as Express.Multer.File; - it('403 in demo mode for a demo user', () => { + it('403 in demo mode for a demo user', async () => { process.env.DEMO_MODE = 'true'; const demo = { ...user, email: 'demo@trek.app' } as User; - expect(thrown(() => new CollectionsController(makeService(), new RuntimeEnvService()).uploadPlaceImage(demo, '9', file))) + expect(await thrownAsync(() => new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).uploadPlaceImage(demo, '9', file))) .toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } }); }); - it('400 when no file was uploaded', () => { - expect(thrown(() => new CollectionsController(makeService(), new RuntimeEnvService()).uploadPlaceImage(user, '9', undefined))) + it('400 when no file was uploaded', async () => { + expect(await thrownAsync(() => new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).uploadPlaceImage(user, '9', undefined))) .toEqual({ status: 400, body: { error: 'No image uploaded' } }); }); - it('stores the place image via /uploads/places and forwards the socket id', () => { + it('commits + stores the place image via /uploads/places and forwards the socket id', async () => { const svc = makeService({ setPlaceImage: vi.fn().mockReturnValue({ id: 9 }) }); - expect(new CollectionsController(svc, new RuntimeEnvService()).uploadPlaceImage(user, '9', file, 'sid')).toEqual({ id: 9 }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).uploadPlaceImage(user, '9', file, 'sid')).toEqual({ id: 9 }); + expect(storageStub.put).toHaveBeenCalledWith('places', 'x.jpg', { tmpPath: undefined }); expect(svc.setPlaceImage).toHaveBeenCalledWith(1, 9, '/uploads/places/x.jpg', 'sid'); }); }); @@ -217,7 +231,7 @@ describe('CollectionsController', () => { describe('labels', () => { it('create / update / delete / assign / unassign forward user + socket id', () => { const svc = makeService(); - const c = new CollectionsController(svc, new RuntimeEnvService()); + const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); c.createLabel(user, { collection_id: 3, name: 'Berlin', color: '#ff0000' } as never, 'sock'); expect(svc.createLabel).toHaveBeenCalledWith(1, 3, 'Berlin', '#ff0000', 'sock'); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 12f4af731..b3b34f91c 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -3,8 +3,11 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.serv import { HttpException } from '@nestjs/common'; import { PlacesController } from '../../../src/nest/places/places.controller'; import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { StorageService } from '../../../src/nest/storage/storage.service'; import type { User } from '../../../src/types'; +const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService; + const user = { id: 1, role: 'user', email: 'u@example.test' } as User; const trip = { user_id: 1 }; @@ -45,14 +48,14 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // by calling the method directly. it('GET / lists with filters', () => { const list = vi.fn().mockReturnValue([{ id: 1 }]); - expect(new PlacesController(svc({ list } as Partial), new RuntimeEnvService()).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] }); + expect(new PlacesController(svc({ list } as Partial), new RuntimeEnvService(), storageStub).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] }); expect(list).toHaveBeenCalledWith('5', { search: 'beach', category: 'cat', tag: 'tag' }); }); describe('POST / (create)', () => { it('400 on an over-long name (length guard before permission)', () => { const canEdit = vi.fn().mockReturnValue(false); // would 403 if reached - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService()).create(user, '5', { name: 'x'.repeat(201) }))).toEqual({ + expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'x'.repeat(201) }))).toEqual({ status: 400, body: { error: 'name must be 200 characters or less' }, }); expect(canEdit).not.toHaveBeenCalled(); @@ -62,10 +65,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // pins `name`, so the ZodValidationPipe rejects a nameless body before the // handler runs (see the e2e suite for the envelope it produces). it('403 without place_edit, then creates + hooks', () => { - expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } }); const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const onCreated = vi.fn(); const s = svc({ create, broadcast, onCreated } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 9 } }, 'sock'); expect(onCreated).toHaveBeenCalledWith('5', 9); }); @@ -74,22 +77,22 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou describe('POST /import/gpx', () => { const file = { buffer: Buffer.from('gpx'), originalname: 'r.gpx' } as Express.Multer.File; it('400 without a file', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService()).importGpx(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); }); it('400 when all import types are disabled', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService()).importGpx(user, '5', file, { importWaypoints: 'false', importRoutes: 'false', importTracks: 'false' }))).toEqual({ + expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, { importWaypoints: 'false', importRoutes: 'false', importTracks: 'false' }))).toEqual({ status: 400, body: { error: 'No import types selected' }, }); }); it('400 when the GPX yields nothing', () => { - expect(thrown(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService()).importGpx(user, '5', file, {}))).toEqual({ + expect(thrown(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'No matching places found in GPX file' }, }); }); it('imports and broadcasts per place', () => { const broadcast = vi.fn(); const s = svc({ importGpx: vi.fn().mockReturnValue({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }), broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).importGpx(user, '5', file, {}, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).importGpx(user, '5', file, {}, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }); expect(broadcast).toHaveBeenCalledTimes(2); }); }); @@ -97,23 +100,23 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou describe('POST /import/map', () => { const file = { buffer: Buffer.from(''), originalname: 'm.kml' } as Express.Multer.File; it('400 without a file', async () => { - expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService()).importMap(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importMap(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); }); it('403 without place_edit (permission runs before the file check)', async () => { const importMapFile = vi.fn(); const s = svc({ canEdit: vi.fn().mockReturnValue(false), importMapFile } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(importMapFile).not.toHaveBeenCalled(); }); it('400 when both import types are disabled', async () => { - expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService()).importMap(user, '5', file, { importPoints: 'false', importPaths: 'false' }))).toEqual({ + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).importMap(user, '5', file, { importPoints: 'false', importPaths: 'false' }))).toEqual({ status: 400, body: { error: 'No import types selected' }, }); }); it('400 when the map file has no Placemarks (and carries the summary through)', async () => { const summary = { totalPlacemarks: 0 }; const s = svc({ importMapFile: vi.fn().mockResolvedValue({ places: [], summary }) } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}))).toEqual({ + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'No valid Placemarks found in map file', summary }, }); }); @@ -121,26 +124,26 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const broadcast = vi.fn(); const result = { places: [{ id: 1 }, { id: 2 }], summary: { totalPlacemarks: 2 }, count: 2 }; const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}, 'sock')).toEqual(result); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}, 'sock')).toEqual(result); expect(broadcast).toHaveBeenCalledTimes(2); expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 1 } }, 'sock'); }); it('passes a missing summary through (no zero-placemark guard) and still imports', async () => { const result = { places: [{ id: 7 }] }; const s = svc({ importMapFile: vi.fn().mockResolvedValue(result), broadcast: vi.fn() } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {})).toEqual(result); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {})).toEqual(result); }); it('wraps a thrown Error from the service in a 400 with its message', async () => { const s = svc({ importMapFile: vi.fn().mockRejectedValue(new Error('bad kml')) } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'bad kml' } }); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'bad kml' } }); }); it('falls back to a generic 400 message for a non-Error rejection', async () => { const s = svc({ importMapFile: vi.fn().mockRejectedValue('boom') } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'Failed to import map file' } }); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}))).toEqual({ status: 400, body: { error: 'Failed to import map file' } }); }); it('re-throws an HttpException raised inside the try untouched', async () => { const s = svc({ importMapFile: vi.fn().mockRejectedValue(new HttpException({ error: 'teapot' }, 418)) } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importMap(user, '5', file, {}))).toEqual({ status: 418, body: { error: 'teapot' } }); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importMap(user, '5', file, {}))).toEqual({ status: 418, body: { error: 'teapot' } }); }); }); @@ -150,29 +153,29 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // handler runs. it('maps a service { error, status } to the same response', async () => { const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'List is empty' } }); + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'List is empty' } }); }); it('imports a naver list and returns the count + listName', async () => { const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService()).importNaver(user, '5', { url: 'http://x' })).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importNaver(user, '5', { url: 'http://x' })).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); }); it('forwards the enrich flag + userId and broadcasts each imported place', async () => { const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 }); const broadcast = vi.fn(); const s = svc({ importGoogleList, broadcast } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService()).importGoogle(user, '5', { url: 'http://x', enrich: true }, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'http://x', enrich: true }, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 }); expect(broadcast).toHaveBeenCalledTimes(2); }); it('wraps a thrown Error in the provider-specific 400 (Google)', async () => { const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' }, }); }); it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => { const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial); - expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService()).importNaver(user, '5', { url: 'http://x' }))).toEqual({ + expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importNaver(user, '5', { url: 'http://x' }))).toEqual({ status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' }, }); }); @@ -184,14 +187,14 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou // element before the handler runs. it('returns empty for an empty list without touching the service', () => { const removeMany = vi.fn(); - expect(new PlacesController(svc({ removeMany } as Partial), new RuntimeEnvService()).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 }); + expect(new PlacesController(svc({ removeMany } as Partial), new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 }); expect(removeMany).not.toHaveBeenCalled(); }); it('deletes, fires hooks + broadcasts per deleted id', () => { const removeMany = vi.fn().mockReturnValue([1, 2]); const onDeleted = vi.fn(); const broadcast = vi.fn(); const scopedIds = vi.fn().mockReturnValue([1, 2]); const s = svc({ removeMany, onDeleted, broadcast, scopedIds } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2 }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock')).toEqual({ deleted: [1, 2], count: 2 }); expect(onDeleted).toHaveBeenCalledTimes(2); expect(broadcast).toHaveBeenCalledTimes(2); }); @@ -203,7 +206,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const scopedIds = vi.fn().mockReturnValue([1]); const onDeleted = vi.fn(); const s = svc({ removeMany, scopedIds, onDeleted, broadcast: vi.fn() } as Partial); - new PlacesController(s, new RuntimeEnvService()).bulkDelete(user, '5', { ids: [1, 99] }); + new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 99] }); expect(scopedIds).toHaveBeenCalledWith('5', [1, 99]); expect(onDeleted).toHaveBeenCalledTimes(1); expect(onDeleted).toHaveBeenCalledWith(1); @@ -216,7 +219,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const linkedExpenseIds = vi.fn().mockReturnValue([77]); const broadcast = vi.fn(); const s = svc({ removeMany, linkedExpenseIds, broadcast } as Partial); - new PlacesController(s, new RuntimeEnvService()).bulkDelete(user, '5', { ids: [1, 2] }, 'sock'); + new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1, 2] }, 'sock'); expect(linkedExpenseIds).toHaveBeenCalledWith('5', [1, 2]); expect(linkedExpenseIds.mock.invocationCallOrder[0]).toBeLessThan(removeMany.mock.invocationCallOrder[0]); @@ -226,24 +229,24 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou describe('POST /bulk-update', () => { it('404 when trip not accessible, 403 without place_edit (before any write)', () => { - expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }), new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 403, body: { error: 'No permission' } }); + expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }), new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 403, body: { error: 'No permission' } }); }); // Same as bulk-delete: placeBulkUpdateRequestSchema types `ids`, so the // pipe owns that 400 now. `.min(1)` was deliberately left off the schema so // the empty-list short-circuit below stays reachable. it('400 when no patch field is present', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [1] }))).toEqual({ status: 400, body: { error: 'Provide at least one field to update' } }); + expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [1] }))).toEqual({ status: 400, body: { error: 'Provide at least one field to update' } }); }); it('returns empty for an empty list without touching the service', () => { const updateMany = vi.fn(); - expect(new PlacesController(svc({ updateMany } as Partial), new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [] })).toEqual({ updated: [], count: 0 }); + expect(new PlacesController(svc({ updateMany } as Partial), new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [] })).toEqual({ updated: [], count: 0 }); expect(updateMany).not.toHaveBeenCalled(); }); it('updates, fires hooks + broadcasts per updated place', () => { const updateMany = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); const onUpdated = vi.fn(); const broadcast = vi.fn(); const s = svc({ updateMany, onUpdated, broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [1, 2], category_id: 3 }, 'sock')).toEqual({ updated: [1, 2], count: 2 }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [1, 2], category_id: 3 }, 'sock')).toEqual({ updated: [1, 2], count: 2 }); expect(updateMany).toHaveBeenCalledWith('5', [1, 2], { category_id: 3 }); expect(onUpdated).toHaveBeenCalledTimes(2); expect(broadcast).toHaveBeenCalledWith('5', 'place:updated', { place: { id: 1 } }, 'sock'); @@ -251,22 +254,22 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou it('passes category_id: null through to clear the category', () => { const updateMany = vi.fn().mockReturnValue([{ id: 1 }]); const s = svc({ updateMany } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [1], category_id: null })).toEqual({ updated: [1], count: 1 }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).bulkUpdate(user, '5', { ids: [1], category_id: null })).toEqual({ updated: [1], count: 1 }); expect(updateMany).toHaveBeenCalledWith('5', [1], { category_id: null }); }); }); it('GET /:id returns the place when found, 404 when missing', () => { - expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService()).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService(), storageStub).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).get(user, '5', '9')).toEqual({ place: { id: 9 } }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).get(user, '5', '9')).toEqual({ place: { id: 9 } }); }); it('PUT /:id 404 when missing, else updates + hooks', () => { - expect(thrown(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService()).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Place not found' } }); + expect(thrown(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService(), storageStub).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Place not found' } }); const update = vi.fn().mockReturnValue({ id: 9 }); const onUpdated = vi.fn(); const broadcast = vi.fn(); const s = svc({ update, onUpdated, broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).update(user, '5', '9', { name: 'X' }, 'sock')).toEqual({ place: { id: 9 } }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).update(user, '5', '9', { name: 'X' }, 'sock')).toEqual({ place: { id: 9 } }); expect(onUpdated).toHaveBeenCalledWith(9); }); @@ -274,10 +277,10 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou it('400s on anything that is not a hex colour, before the permission check', () => { const canEdit = vi.fn().mockReturnValue(false); // would 403 if it got that far const err = { status: 400, body: { error: 'route_color must be a hex colour like #4f46e5' } }; - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService()).update(user, '5', '9', { route_color: 'red' }))).toEqual(err); - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService()).update(user, '5', '9', { route_color: '#12345' }))).toEqual(err); - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService()).update(user, '5', '9', { route_color: 123 }))).toEqual(err); - expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService()).create(user, '5', { name: 'T', route_color: 'red' }))).toEqual(err); + expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: 'red' }))).toEqual(err); + expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: '#12345' }))).toEqual(err); + expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: 123 }))).toEqual(err); + expect(thrown(() => new PlacesController(svc({ canEdit }), new RuntimeEnvService(), storageStub).create(user, '5', { name: 'T', route_color: 'red' }))).toEqual(err); expect(canEdit).not.toHaveBeenCalled(); }); @@ -285,19 +288,19 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const update = vi.fn().mockReturnValue({ id: 9, route_color: '#e11d48' }); const broadcast = vi.fn(); const s = svc({ update, broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).update(user, '5', '9', { route_color: '#e11d48' }, 'sock')) + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: '#e11d48' }, 'sock')) .toEqual({ place: { id: 9, route_color: '#e11d48' } }); expect(update).toHaveBeenCalledWith('5', '9', expect.objectContaining({ route_color: '#e11d48' }), undefined); // The colour has to reach the other members too, not just the DB. expect(broadcast).toHaveBeenCalledWith('5', 'place:updated', { place: { id: 9, route_color: '#e11d48' } }, 'sock'); - new PlacesController(svc({ update } as Partial), new RuntimeEnvService()).update(user, '5', '9', { route_color: null }); + new PlacesController(svc({ update } as Partial), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: null }); expect(update).toHaveBeenLastCalledWith('5', '9', expect.objectContaining({ route_color: null }), undefined); }); it('accepts the short #abc form', () => { const update = vi.fn().mockReturnValue({ id: 9 }); - expect(new PlacesController(svc({ update } as Partial), new RuntimeEnvService()).update(user, '5', '9', { route_color: '#abc' })).toEqual({ place: { id: 9 } }); + expect(new PlacesController(svc({ update } as Partial), new RuntimeEnvService(), storageStub).update(user, '5', '9', { route_color: '#abc' })).toEqual({ place: { id: 9 } }); }); }); @@ -305,7 +308,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou const update = vi.fn().mockReturnValue({ conflict: true, server: { id: 9, name: 'Theirs' } }); const onUpdated = vi.fn(); const broadcast = vi.fn(); const s = svc({ update, onUpdated, broadcast } as Partial); - expect(thrown(() => new PlacesController(s, new RuntimeEnvService()).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({ + expect(thrown(() => new PlacesController(s, new RuntimeEnvService(), storageStub).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({ status: 409, body: { error: 'conflict', server: { id: 9, name: 'Theirs' } }, }); expect(update).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), '2026-01-01 00:00:00'); @@ -316,11 +319,11 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou it('DELETE /:id fires the hook then 404 / success', () => { const onDeleted = vi.fn(); const remove = vi.fn().mockReturnValue(false); - expect(thrown(() => new PlacesController(svc({ remove, onDeleted } as Partial), new RuntimeEnvService()).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + expect(thrown(() => new PlacesController(svc({ remove, onDeleted } as Partial), new RuntimeEnvService(), storageStub).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } }); expect(onDeleted).toHaveBeenCalledWith(9); expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]); const s = svc({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).remove(user, '5', '9')).toEqual({ success: true }); + expect(new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({ success: true }); }); // #1745: a place on another trip must 404 without the hook ever running — @@ -329,56 +332,57 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou it('DELETE /:id 404s a foreign place before the journey hook runs', () => { const onDeleted = vi.fn(); const remove = vi.fn(); const s = svc({ get: vi.fn().mockReturnValue(null), onDeleted, remove } as Partial); - expect(thrown(() => new PlacesController(s, new RuntimeEnvService()).remove(user, '5', '99'))).toEqual({ status: 404, body: { error: 'Place not found' } }); + expect(thrown(() => new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '99'))).toEqual({ status: 404, body: { error: 'Place not found' } }); expect(onDeleted).not.toHaveBeenCalled(); expect(remove).not.toHaveBeenCalled(); }); it('GET /:id/image maps service error + returns photos', async () => { const s = svc({ searchImage: vi.fn().mockResolvedValue({ photos: [{ url: 'x' }] }) } as Partial); - expect(await new PlacesController(s, new RuntimeEnvService()).image(user, '5', '9')).toEqual({ photos: [{ url: 'x' }] }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).image(user, '5', '9')).toEqual({ photos: [{ url: 'x' }] }); const e = svc({ searchImage: vi.fn().mockResolvedValue({ error: 'No key', status: 400 }) } as Partial); - expect(await thrownAsync(() => new PlacesController(e, new RuntimeEnvService()).image(user, '5', '9'))).toEqual({ status: 400, body: { error: 'No key' } }); + expect(await thrownAsync(() => new PlacesController(e, new RuntimeEnvService(), storageStub).image(user, '5', '9'))).toEqual({ status: 400, body: { error: 'No key' } }); }); it('GET /:id/image turns an unexpected throw into a 500, but re-throws an HttpException as-is', async () => { const boom = svc({ searchImage: vi.fn().mockRejectedValue(new Error('Unsplash down')) } as Partial); - expect(await thrownAsync(() => new PlacesController(boom, new RuntimeEnvService()).image(user, '5', '9'))).toEqual({ status: 500, body: { error: 'Error searching for image' } }); + expect(await thrownAsync(() => new PlacesController(boom, new RuntimeEnvService(), storageStub).image(user, '5', '9'))).toEqual({ status: 500, body: { error: 'Error searching for image' } }); const http = svc({ searchImage: vi.fn().mockRejectedValue(new HttpException({ error: 'rate limited' }, 429)) } as Partial); - expect(await thrownAsync(() => new PlacesController(http, new RuntimeEnvService()).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } }); + expect(await thrownAsync(() => new PlacesController(http, new RuntimeEnvService(), storageStub).image(user, '5', '9'))).toEqual({ status: 429, body: { error: 'rate limited' } }); }); describe('POST /:id/image (custom place image #1136)', () => { const file = { filename: 'abc.jpg' } as Express.Multer.File; - it('404 when the trip is not accessible, 403 without place_edit', () => { - expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }), new RuntimeEnvService()).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).uploadImage(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission' } }); + it('404 when the trip is not accessible, 403 without place_edit', async () => { + expect(await thrownAsync(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) }), new RuntimeEnvService(), storageStub).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrownAsync(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) }), new RuntimeEnvService(), storageStub).uploadImage(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission' } }); }); - it('400 when no file was uploaded', () => { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService()).uploadImage(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); + it('400 when no file was uploaded', async () => { + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).uploadImage(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } }); }); - it('404 when the place is missing (service returns null)', () => { - expect(thrown(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService()).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Place not found' } }); + it('404 when the place is missing (service returns null)', async () => { + expect(await thrownAsync(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial), new RuntimeEnvService(), storageStub).uploadImage(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Place not found' } }); }); - it('stores the uploaded file as image_url, broadcasts + fires the update hook', () => { + it('stores the uploaded file as image_url, broadcasts + fires the update hook', async () => { const update = vi.fn().mockReturnValue({ id: 9 }); const onUpdated = vi.fn(); const broadcast = vi.fn(); const s = svc({ update, onUpdated, broadcast } as Partial); - expect(new PlacesController(s, new RuntimeEnvService()).uploadImage(user, '5', '9', file, 'sock')).toEqual({ place: { id: 9 } }); + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).uploadImage(user, '5', '9', file, 'sock')).toEqual({ place: { id: 9 } }); + expect(storageStub.put).toHaveBeenCalledWith('places', 'abc.jpg', { tmpPath: undefined }); expect(update).toHaveBeenCalledWith('5', '9', { image_url: '/uploads/places/abc.jpg' }); expect(broadcast).toHaveBeenCalledWith('5', 'place:updated', { place: { id: 9 } }, 'sock'); expect(onUpdated).toHaveBeenCalledWith(9); }); - it('403 in demo mode for a demo account', () => { + it('403 in demo mode for a demo account', async () => { const prev = process.env.DEMO_MODE; process.env.DEMO_MODE = 'true'; const demo = { ...user, email: 'demo@trek.app' } as User; try { - expect(thrown(() => new PlacesController(svc(), new RuntimeEnvService()).uploadImage(demo, '5', '9', file))).toEqual({ + expect(await thrownAsync(() => new PlacesController(svc(), new RuntimeEnvService(), storageStub).uploadImage(demo, '5', '9', file))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, }); } finally {