refactor(places): spool place-image and collection uploads through storage

This commit is contained in:
jubnl
2026-08-16 09:44:03 +02:00
parent 7e91f1abaf
commit 8b0c768ba0
9 changed files with 363 additions and 158 deletions
@@ -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);
}
@@ -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],
+18 -32
View File
@@ -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);
}
};
+8 -3
View File
@@ -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);
+18 -1
View File
@@ -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],
@@ -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/<uuid> 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/<uuid> 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');
});
});
+6 -1
View File
@@ -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}`)
@@ -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<unknown>): 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');
@@ -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<PlacesService>), new RuntimeEnvService()).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] });
expect(new PlacesController(svc({ list } as Partial<PlacesService>), 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<PlacesService>);
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<PlacesService>), new RuntimeEnvService()).importGpx(user, '5', file, {}))).toEqual({
expect(thrown(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial<PlacesService>), 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<PlacesService>);
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('<kml/>'), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>), new RuntimeEnvService()).bulkDelete(user, '5', { ids: [] })).toEqual({ deleted: [], count: 0 });
expect(new PlacesController(svc({ removeMany } as Partial<PlacesService>), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>), new RuntimeEnvService()).bulkUpdate(user, '5', { ids: [] })).toEqual({ updated: [], count: 0 });
expect(new PlacesController(svc({ updateMany } as Partial<PlacesService>), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>), 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<PlacesService>), 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<PlacesService>);
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<PlacesService>), 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<PlacesService>), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>), new RuntimeEnvService()).update(user, '5', '9', { route_color: null });
new PlacesController(svc({ update } as Partial<PlacesService>), 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<PlacesService>), new RuntimeEnvService()).update(user, '5', '9', { route_color: '#abc' })).toEqual({ place: { id: 9 } });
expect(new PlacesController(svc({ update } as Partial<PlacesService>), 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<PlacesService>);
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<PlacesService>), new RuntimeEnvService()).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
expect(thrown(() => new PlacesController(svc({ remove, onDeleted } as Partial<PlacesService>), 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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>);
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<PlacesService>), 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<PlacesService>), 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<PlacesService>);
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 {