mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
This commit is contained in:
@@ -154,6 +154,13 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
- name: Typecheck tests
|
||||
# tests/ is outside the build tsconfig, so a service that grows a
|
||||
# constructor parameter does not break its hand-wired call sites at
|
||||
# compile time: the argument arrives as undefined and throws at runtime,
|
||||
# if anything reads it at all. This is the step that catches it.
|
||||
run: cd server && npm run typecheck:tests
|
||||
|
||||
- name: Lint
|
||||
run: cd server && npm run lint:check
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
// ids the plugin already had; never the httpOnly cookie); fully closing it
|
||||
// would require not running plugin client JS at all.
|
||||
const loadsRef = useRef(0)
|
||||
const { locale } = useTranslation()
|
||||
const { locale, t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
// useToast returns a fresh object every render; keep it out of the effect deps
|
||||
@@ -243,6 +243,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
// The ref mirrors the state so the message handler can gate + answer without
|
||||
// side effects inside a setState updater (StrictMode runs updaters twice).
|
||||
const [confirmReq, setConfirmReq] = useState<ConfirmRequest | null>(null)
|
||||
// The iframe document can fail to load on its own: a plugin uninstalled in
|
||||
// another tab, a frame route the host no longer serves, a network blip on a
|
||||
// lazily loaded frame. Without an onError the element just stays blank at full
|
||||
// height, which reads as a hung app rather than as a plugin that is gone.
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const confirmReqRef = useRef<ConfirmRequest | null>(null)
|
||||
// geolocation:read gate — the feed only flags plugins whose grant is recorded.
|
||||
// Read via ref (like toastRef) so the bridge effect doesn't re-run on store churn.
|
||||
@@ -593,14 +598,24 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
// Deliver the context as soon as the document is parsed (the plugin sets up its
|
||||
// message listener during parse), closing the trek:ready race so the theme is
|
||||
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
|
||||
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
|
||||
onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
|
||||
onError={() => setLoadFailed(true)}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title={title || pluginId}
|
||||
className={className}
|
||||
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0 }}
|
||||
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0, display: loadFailed ? 'none' : undefined }}
|
||||
/>
|
||||
{loadFailed && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-center rounded-xl border border-edge bg-surface-2 p-6 text-caption text-content-2"
|
||||
style={{ width: '100%', height: fill ? '100%' : height ?? '100%' }}
|
||||
>
|
||||
{t('plugins.frameLoadFailed')}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmReq != null}
|
||||
onClose={() => answerConfirm(false)}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
||||
"reset-admin": "node reset-admin.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tests": "tsc -p tsconfig.tests.json --noEmit",
|
||||
"gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts",
|
||||
"check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
|
||||
@@ -3,26 +3,16 @@
|
||||
* class — grandfathered when the fail-closed boot gate landed
|
||||
* (validate-body-contracts.ts). Entries are `ControllerClass.methodName`.
|
||||
*
|
||||
* RATCHET ONLY: never add an entry. When a domain migrates, wrap its
|
||||
* @trek/shared schema in a colocated <domain>.dto.ts and DELETE the routes
|
||||
* here — the gate throws on stale entries, so removal is enforced too.
|
||||
* EMPTY, and it stays that way. Every mutation handler in the tree now types
|
||||
* its body with a createZodDto class, so the gate has nothing to forgive. It
|
||||
* still runs: a new handler that reads @Body() without a contract refuses the
|
||||
* boot rather than shipping unvalidated. Adding an entry here to get past that
|
||||
* is not the fix.
|
||||
*
|
||||
* The last seventeen came off in the migration remainder. Several of their
|
||||
* contracts describe rather than constrain, which is deliberate and documented
|
||||
* at each DTO: those handlers own rejections the pipe would otherwise pre-empt,
|
||||
* including two that answer 503 before ever looking at the body and one that
|
||||
* answers `{ route: null }` at 200 for anything it dislikes.
|
||||
*/
|
||||
export const BODY_CONTRACT_ALLOW_LIST: string[] = [
|
||||
'LlmLocalController.pull',
|
||||
'OauthApiController.authorize',
|
||||
'OauthApiController.createClient',
|
||||
'PluginRoutesController.route',
|
||||
'PluginUserSettingsController.update',
|
||||
'PluginsController.activate',
|
||||
'PluginsController.install',
|
||||
'PluginsController.link',
|
||||
'PluginsController.retrust',
|
||||
'PluginsController.setEgressHosts',
|
||||
'PluginsController.uninstall',
|
||||
'PluginsController.updateConfig',
|
||||
'ReservationImportController.confirm',
|
||||
'ReservationImportController.preview',
|
||||
'ReservationImportController.previewAsync',
|
||||
'TagsController.create',
|
||||
'TagsController.update',
|
||||
];
|
||||
export const BODY_CONTRACT_ALLOW_LIST: string[] = [];
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AllowedFileTypesService } from './allowed-file-types.service';
|
||||
|
||||
/**
|
||||
* A leaf so both upload paths can reach the extension list without importing
|
||||
* each other's domain. DatabaseModule is @Global, so this imports nothing.
|
||||
*/
|
||||
@Module({
|
||||
providers: [AllowedFileTypesService],
|
||||
exports: [AllowedFileTypesService],
|
||||
})
|
||||
export class AllowedFileTypesModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants';
|
||||
|
||||
/**
|
||||
* The operator's allowed-extension list, on its own.
|
||||
*
|
||||
* Split out of FilesService so the multer factories can inject it without
|
||||
* pulling the whole files domain into JourneyModule's injector. It is the only
|
||||
* thing an upload's fileFilter needs from the container, and it is a live read:
|
||||
* an admin changing the list in settings applies to the next upload, with no
|
||||
* invalidation wiring.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AllowedFileTypesService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
/** Comma-separated, as the admin panel stores it. `*` means anything. */
|
||||
get(): string {
|
||||
try {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'allowed_file_types'",
|
||||
);
|
||||
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
|
||||
} catch {
|
||||
return DEFAULT_ALLOWED_EXTENSIONS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { FilesService } from './files.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the files domain. Unlike todo.bridge.ts this does
|
||||
* NOT serve a legacy MCP registrar (files has no MCP surface) — it exists only
|
||||
* because multer interceptor options are module-scope object literals (the
|
||||
* UPLOAD configs in files.controller.ts and journey.controller.ts are built
|
||||
* before any DI container exists), yet their fileFilter closures need the
|
||||
* request-time app_settings read. Everything else injects FilesService.
|
||||
* Delete this file if uploads ever move to MulterModule.registerAsync.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new EphemeralTokenService());
|
||||
|
||||
export function getAllowedExtensions(): string {
|
||||
return files.getAllowedExtensions();
|
||||
}
|
||||
@@ -30,34 +30,45 @@ import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import { Trip } from '../permissions/trip.decorator';
|
||||
import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, isVideoExtension } from './files.constants';
|
||||
import { getAllowedExtensions } from './files.bridge';
|
||||
import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto';
|
||||
import { AllowedFileTypesService } from './allowed-file-types.service';
|
||||
|
||||
const UPLOAD = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); },
|
||||
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
|
||||
}),
|
||||
// Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by
|
||||
// the per-type guard in the upload handler (#823).
|
||||
limits: { fileSize: MAX_VIDEO_SIZE },
|
||||
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const reject = () => {
|
||||
// i18n key — the client resolves it via t() (see translateApiError).
|
||||
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
|
||||
err.statusCode = 400;
|
||||
cb(err, false);
|
||||
};
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
|
||||
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
|
||||
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
|
||||
reject();
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Trip-file upload options, built from the container.
|
||||
*
|
||||
* A factory rather than a module-scope literal because the fileFilter reads the
|
||||
* operator's allowed-extension list at request time, and that list lives in the
|
||||
* database. It used to reach it through files.bridge.ts, which built a second
|
||||
* FilesService outside the injector; MulterModule.registerAsync gives the
|
||||
* closure the real one.
|
||||
*/
|
||||
export function buildFilesUploadOptions(allowedTypes: AllowedFileTypesService) {
|
||||
return {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); },
|
||||
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
|
||||
}),
|
||||
// Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by
|
||||
// the per-type guard in the upload handler (#823).
|
||||
limits: { fileSize: MAX_VIDEO_SIZE },
|
||||
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const reject = () => {
|
||||
// i18n key — the client resolves it via t() (see translateApiError).
|
||||
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
|
||||
err.statusCode = 400;
|
||||
cb(err, false);
|
||||
};
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
|
||||
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
|
||||
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
|
||||
reject();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/files — trip file manager (upload, metadata, starring,
|
||||
@@ -105,7 +116,7 @@ export class FilesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
upload(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
|
||||
@@ -8,9 +8,19 @@ import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { AllowedFileTypesModule } from './allowed-file-types.module';
|
||||
import { AllowedFileTypesService } from './allowed-file-types.service';
|
||||
import { buildFilesUploadOptions } from './files.controller';
|
||||
|
||||
@Module({
|
||||
imports: [EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
|
||||
imports: [
|
||||
MulterModule.registerAsync({
|
||||
imports: [AllowedFileTypesModule],
|
||||
inject: [AllowedFileTypesService],
|
||||
useFactory: (allowedTypes: AllowedFileTypesService) => buildFilesUploadOptions(allowedTypes),
|
||||
}),
|
||||
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
|
||||
controllers: [FilesController, FilesDownloadController],
|
||||
providers: [FilesService, FilesRpc],
|
||||
exports: [FilesService],
|
||||
|
||||
@@ -34,31 +34,41 @@ import {
|
||||
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
|
||||
} from './journey.dto';
|
||||
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
|
||||
import { getAllowedExtensions } from '../files/files.bridge';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../../uploads/journey');
|
||||
const IMAGE_UPLOAD = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
|
||||
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
|
||||
}),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
|
||||
if (!allowed.includes('*') && !allowed.includes(ext)) {
|
||||
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Journey image upload options, built from the container.
|
||||
*
|
||||
* Same reason as the trip-file factory: the fileFilter reads the operator's
|
||||
* allowed-extension list at request time. Note this config carries NO
|
||||
* defParamCharset, unlike the trip-file one; that difference is deliberate and
|
||||
* predates the move, so the two are not folded into one shared object.
|
||||
*/
|
||||
export function buildJourneyImageUploadOptions(allowedTypes: AllowedFileTypesService) {
|
||||
return {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
|
||||
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
|
||||
}),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
||||
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
|
||||
if (!allowed.includes('*') && !allowed.includes(ext)) {
|
||||
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
|
||||
err.statusCode = 400;
|
||||
return cb(err, false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Gallery video upload (#823): one video plus an optional client-captured poster
|
||||
// image, written to the same uploads/journey store. Larger cap than images since
|
||||
@@ -161,7 +171,7 @@ export class JourneyController {
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
|
||||
@UseInterceptors(FilesInterceptor('photos'))
|
||||
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
@@ -257,7 +267,7 @@ export class JourneyController {
|
||||
|
||||
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
|
||||
@Post(':id/gallery/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
|
||||
@UseInterceptors(FilesInterceptor('photos'))
|
||||
uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
@@ -360,7 +370,7 @@ export class JourneyController {
|
||||
|
||||
@Post(':id/cover')
|
||||
@HttpCode(200) // Express answers cover with res.json (200).
|
||||
@UseInterceptors(FileInterceptor('cover', IMAGE_UPLOAD))
|
||||
@UseInterceptors(FileInterceptor('cover'))
|
||||
cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
|
||||
@@ -7,10 +7,20 @@ import { MemoriesModule } from '../memories/memories.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { JourneyMcp } from './journey.mcp';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
import { buildJourneyImageUploadOptions } from './journey.controller';
|
||||
|
||||
@Module({
|
||||
// MemoriesModule: the journey gallery streams provider assets and uploads to Immich.
|
||||
imports: [AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
|
||||
imports: [
|
||||
MulterModule.registerAsync({
|
||||
imports: [AllowedFileTypesModule],
|
||||
inject: [AllowedFileTypesService],
|
||||
useFactory: (allowedTypes: AllowedFileTypesService) => buildJourneyImageUploadOptions(allowedTypes),
|
||||
}),
|
||||
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
|
||||
controllers: [JourneyController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyMcp],
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { LlmLocalService } from './llm-local.service';
|
||||
import { LlmLocalPullDto } from './llm-local.dto';
|
||||
|
||||
/**
|
||||
* Admin-only management of a local LLM server (Ollama): list installed models and
|
||||
@@ -24,7 +25,7 @@ export class LlmLocalController {
|
||||
* response body to render a progress bar. Uses @Res() to stream manually.
|
||||
*/
|
||||
@Post('pull')
|
||||
async pull(@Body() body: { baseUrl?: string; model?: string }, @Res() res: Response): Promise<void> {
|
||||
async pull(@Body() body: LlmLocalPullDto, @Res() res: Response): Promise<void> {
|
||||
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The Ollama pull body. Both fields are optional because the service supplies
|
||||
* its own defaults for a missing baseUrl and reports an empty model itself.
|
||||
*/
|
||||
export class LlmLocalPullDto extends createZodDto(
|
||||
z.looseObject({ baseUrl: z.string().optional(), model: z.string().optional() }),
|
||||
) {}
|
||||
@@ -9,6 +9,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import type { User } from '../../types';
|
||||
import type { AuthorizeParams } from './oauth.service';
|
||||
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
@@ -67,10 +68,7 @@ export class OauthApiController {
|
||||
@Post('authorize')
|
||||
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
|
||||
@UseGuards(CookieAuthGuard)
|
||||
authorize(@CurrentUser() user: User, @Body() body: {
|
||||
client_id: string; redirect_uri: string; scope: string; state?: string;
|
||||
code_challenge: string; code_challenge_method: string; approved: boolean; resource?: string;
|
||||
}, @Req() req: Request) {
|
||||
authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
throw new HttpException({ error: 'MCP is not enabled' }, 403);
|
||||
@@ -126,7 +124,7 @@ export class OauthApiController {
|
||||
@Post('clients')
|
||||
@HttpCode(201)
|
||||
@UseGuards(CookieAuthGuard)
|
||||
createClient(@CurrentUser() user: User, @Body() body: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }, @Req() req: Request) {
|
||||
createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
|
||||
if (result.error) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { oauthClientCreateRequestSchema, oauthConsentRequestSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Server-side createZodDto wrappers over the @trek/shared OAuth contracts.
|
||||
*
|
||||
* Both schemas already described these two bodies exactly; they were written
|
||||
* with the contracts and then never wired to the handlers, which declared the
|
||||
* same shape inline as an anonymous type. The inline types are gone now, so the
|
||||
* wire shape has one definition instead of two that could drift.
|
||||
*
|
||||
* Neither handler owns a bespoke required-field message: consent redirects with
|
||||
* `error=access_denied` on a rejection, and client creation surfaces whatever
|
||||
* the service returns. So nothing user-visible moves in front of the pipe here.
|
||||
*/
|
||||
export class OauthConsentDto extends createZodDto(oauthConsentRequestSchema) {}
|
||||
export class OauthClientCreateDto extends createZodDto(oauthClientCreateRequestSchema) {}
|
||||
@@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
import { PluginRouteDto } from '../plugins.dto';
|
||||
|
||||
/**
|
||||
* POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to
|
||||
@@ -158,7 +159,7 @@ export class PluginRoutesController {
|
||||
async route(
|
||||
@Param('pluginId') pluginId: string,
|
||||
@Param('profileId') profileId: string,
|
||||
@Body() body: Record<string, unknown> | undefined,
|
||||
@Body() body: PluginRouteDto,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ route: PluginRouteOut | null }> {
|
||||
if (!pluginsEnabled()) return { route: null };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PluginUserSettingsUpdateDto } from './plugins.dto';
|
||||
|
||||
/**
|
||||
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
|
||||
@@ -74,7 +75,7 @@ export class PluginUserSettingsController {
|
||||
@Post(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { config?: Record<string, unknown> },
|
||||
@Body() body: PluginUserSettingsUpdateDto,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): { config: Record<string, unknown> } {
|
||||
const userId = req.user?.id;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto';
|
||||
|
||||
/**
|
||||
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
|
||||
@@ -70,7 +71,7 @@ export class PluginsController {
|
||||
|
||||
@Post('install')
|
||||
@HttpCode(200)
|
||||
async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) {
|
||||
async install(@Body() body: PluginInstallDto) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
|
||||
try {
|
||||
@@ -104,7 +105,7 @@ export class PluginsController {
|
||||
*/
|
||||
@Post('link')
|
||||
@HttpCode(200)
|
||||
async link(@Body() body: { path?: string }) {
|
||||
async link(@Body() body: PluginLinkDto) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
|
||||
const dir = body?.path?.trim();
|
||||
@@ -122,7 +123,7 @@ export class PluginsController {
|
||||
}
|
||||
|
||||
@Put(':id/config')
|
||||
updateConfig(@Param('id') id: string, @Body() body: Record<string, unknown>) {
|
||||
updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto) {
|
||||
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ export class PluginsController {
|
||||
}
|
||||
|
||||
@Put(':id/egress-hosts')
|
||||
async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) {
|
||||
async setEgressHosts(@Param('id') id: string, @Body() body: PluginEgressHostsDto) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : [];
|
||||
try {
|
||||
@@ -150,7 +151,7 @@ export class PluginsController {
|
||||
|
||||
@Post(':id/activate')
|
||||
@HttpCode(200)
|
||||
async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) {
|
||||
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
try {
|
||||
await this.runtime.activate(id, !!body?.consent);
|
||||
@@ -231,7 +232,7 @@ export class PluginsController {
|
||||
@HttpCode(200)
|
||||
async retrust(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { version?: string; publicKey?: string },
|
||||
@Body() body: PluginRetrustDto,
|
||||
@CurrentUser() user: { id: number },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
@@ -247,7 +248,7 @@ export class PluginsController {
|
||||
|
||||
@Post(':id/uninstall')
|
||||
@HttpCode(200)
|
||||
async uninstall(@Param('id') id: string, @Body() body: { deleteData?: boolean }) {
|
||||
async uninstall(@Param('id') id: string, @Body() body: PluginUninstallDto) {
|
||||
await this.runtime.uninstall(id, !!body?.deleteData);
|
||||
return { status: 'uninstalled' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Body contracts for the plugin surfaces, written to DESCRIBE rather than to
|
||||
* tighten (the journey-contract doctrine, #1842).
|
||||
*
|
||||
* Every one of these handlers already owns its own rejection, and several of
|
||||
* them answer something other than 400 for a body they dislike: activate and
|
||||
* install answer 503 when plugins are disabled, and they check that BEFORE
|
||||
* looking at the body. A schema that rejected first would move a 503 to a 400
|
||||
* for an operator whose server has plugins off. So the fields stay optional and
|
||||
* the objects stay loose: the DTO pins the wire TYPES, and the handler keeps
|
||||
* every decision it already made, in the order it already made it.
|
||||
*/
|
||||
|
||||
/** `id is required` stays the handler's answer, so id is optional here. */
|
||||
export class PluginInstallDto extends createZodDto(
|
||||
z.looseObject({
|
||||
id: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
constraint: z.string().optional(),
|
||||
withDependencies: z.boolean().optional(),
|
||||
}),
|
||||
) {}
|
||||
|
||||
/** `path is required` likewise, and dev-link answers 403 before reading it. */
|
||||
export class PluginLinkDto extends createZodDto(
|
||||
z.looseObject({ path: z.string().optional() }),
|
||||
) {}
|
||||
|
||||
export class PluginActivateDto extends createZodDto(
|
||||
z.looseObject({ consent: z.boolean().optional() }),
|
||||
) {}
|
||||
|
||||
export class PluginUninstallDto extends createZodDto(
|
||||
z.looseObject({ deleteData: z.boolean().optional() }),
|
||||
) {}
|
||||
|
||||
export class PluginRetrustDto extends createZodDto(
|
||||
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `hosts` is deliberately unknown, not `string[]`.
|
||||
*
|
||||
* The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`,
|
||||
* which means an omitted or non-array `hosts` CLEARS the operator egress list.
|
||||
* That is the documented way to reset it, so a schema demanding an array would
|
||||
* not tighten validation, it would remove a working admin action.
|
||||
*/
|
||||
export class PluginEgressHostsDto extends createZodDto(
|
||||
z.looseObject({ hosts: z.unknown().optional() }),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Plugin instance config is defined by the plugin's own manifest, not by TREK,
|
||||
* so there is no shape to assert here beyond "a JSON object".
|
||||
*/
|
||||
export class PluginConfigDto extends createZodDto(z.looseObject({})) {}
|
||||
|
||||
export class PluginUserSettingsUpdateDto extends createZodDto(
|
||||
z.looseObject({ config: z.record(z.string(), z.unknown()).optional() }),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The route hook answers `{ route: null }` at 200 for every input it dislikes:
|
||||
* a missing trip, a bad profile, unusable waypoints, a slow provider. The client
|
||||
* draws straight lines on a null and shows nothing on a 400, so a schema that
|
||||
* rejected a malformed body would turn a graceful fallback into a visible error.
|
||||
* Loose and fully optional on purpose.
|
||||
*/
|
||||
export class PluginRouteDto extends createZodDto(z.looseObject({})) {}
|
||||
@@ -26,6 +26,7 @@ import { AirtrailImportDto } from '../integrations/airtrail.dto';
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { bookingImportModeSchema } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
|
||||
import { BookingImportConfirmDto, BookingImportPreviewDto } from './reservation-import.dto';
|
||||
|
||||
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -127,9 +128,9 @@ export class ReservationImportController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
@Body('mode') rawMode?: string,
|
||||
@Body() body: BookingImportPreviewDto,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
const mode = this.validateImport(tripId, user, files, body?.mode);
|
||||
return this.bookingImport.preview(files!, mode, user.id);
|
||||
}
|
||||
|
||||
@@ -147,9 +148,9 @@ export class ReservationImportController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
@Body('mode') rawMode?: string,
|
||||
@Body() body: BookingImportPreviewDto,
|
||||
): Promise<{ jobId: string }> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
const mode = this.validateImport(tripId, user, files, body?.mode);
|
||||
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
|
||||
return { jobId };
|
||||
}
|
||||
@@ -175,7 +176,7 @@ export class ReservationImportController {
|
||||
async confirm(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { items?: BookingImportPreviewItem[] },
|
||||
@Body() body: BookingImportConfirmDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): Promise<BookingImportConfirmResponse> {
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { z } from 'zod';
|
||||
import { bookingImportPreviewItemSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Server-side createZodDto wrappers for the booking-import bodies.
|
||||
*
|
||||
* Confirm validates each item against the shared contract but leaves `items`
|
||||
* itself optional, which is narrower than `bookingImportConfirmRequestSchema`
|
||||
* on purpose. That schema declares `.min(1)`, and the pipe reports a rejection
|
||||
* as `field: message` — so adopting it verbatim would replace the endpoint's
|
||||
* `{ error: 'items must be a non-empty array' }`. The handler keeps the check
|
||||
* that owns the message, and the DTO adds what the handler never did: per-item
|
||||
* shape validation.
|
||||
*
|
||||
* Preview and its async twin carry a multipart body, so every field arrives as a
|
||||
* string and the real payload is the files. `mode` is described here for the
|
||||
* type, and deliberately not narrowed to the mode enum: `validateImport` parses
|
||||
* it with `bookingImportModeSchema` and owns the `Invalid mode` response. The
|
||||
* object is not strict, because a multipart form may carry fields the browser
|
||||
* adds and the handler ignores.
|
||||
*/
|
||||
export class BookingImportConfirmDto extends createZodDto(
|
||||
z.object({ items: z.array(bookingImportPreviewItemSchema).optional() }),
|
||||
) {}
|
||||
|
||||
export class BookingImportPreviewDto extends createZodDto(
|
||||
z.object({ mode: z.string().optional() }),
|
||||
) {}
|
||||
@@ -4,6 +4,7 @@ import type { User } from '../../types';
|
||||
import { TagsService } from './tags.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TagCreateDto, TagUpdateDto } from './tags.dto';
|
||||
|
||||
/**
|
||||
* /api/tags — per-user place-tag CRUD.
|
||||
@@ -25,11 +26,8 @@ export class TagsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
): { tag: Tag } {
|
||||
create(@CurrentUser() user: User, @Body() body: TagCreateDto): { tag: Tag } {
|
||||
const { name, color } = body;
|
||||
if (!name) {
|
||||
throw new HttpException({ error: 'Tag name is required' }, 400);
|
||||
}
|
||||
@@ -40,9 +38,9 @@ export class TagsController {
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body('name') name?: string,
|
||||
@Body('color') color?: string,
|
||||
@Body() body: TagUpdateDto,
|
||||
): { tag: Tag } {
|
||||
const { name, color } = body;
|
||||
if (!this.tags.getByIdAndUser(id, user.id)) {
|
||||
throw new HttpException({ error: 'Tag not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { z } from 'zod';
|
||||
import { updateTagRequestSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Server-side createZodDto wrappers over the @trek/shared tag contracts.
|
||||
*
|
||||
* Create deliberately does NOT use `createTagRequestSchema` as written. That
|
||||
* schema declares `name: z.string().min(1)`, and the pipe reports a rejection as
|
||||
* `field: message` — so adopting it verbatim would replace the endpoint's
|
||||
* `{ error: 'Tag name is required' }` with `name: ...`. Two tests pin that
|
||||
* string, and more to the point it is what a client shows the user.
|
||||
*
|
||||
* So the DTO accepts the shape and leaves `name` optional, and the handler keeps
|
||||
* the check that owns the message. The endpoint's actual contract is unchanged:
|
||||
* a request without a name still gets 400, with the same body it always had.
|
||||
* What the DTO does add is type and shape validation on everything else, which
|
||||
* is what took this route off the allow-list.
|
||||
*/
|
||||
export class TagCreateDto extends createZodDto(
|
||||
z.object({ name: z.string().optional(), color: z.string().optional() }),
|
||||
) {}
|
||||
|
||||
export class TagUpdateDto extends createZodDto(updateTagRequestSchema) {}
|
||||
@@ -132,6 +132,19 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.body).toEqual({ id: 9 });
|
||||
});
|
||||
|
||||
it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => {
|
||||
// The multer options are built by MulterModule.registerAsync now, and Nest
|
||||
// injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to
|
||||
// resolve is not a boot error, it silently falls back to defaults, which
|
||||
// means no fileFilter and no size cap. This case is what turns that into a
|
||||
// visible failure, since an unfiltered upload would answer 201.
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/9/cover')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.attach('cover', Buffer.from('MZ'), { filename: 'payload.exe', contentType: 'application/octet-stream' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('public journey 404 for an unknown token', async () => {
|
||||
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/public/journey/bad');
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
|
||||
|
||||
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
|
||||
|
||||
if (withResources) registerResources(server, userId);
|
||||
// registerResources gates the journey resources on the same scope list the
|
||||
// tools use, so the harness has to hand it over as well.
|
||||
if (withResources) registerResources(server, userId, scopes ?? null);
|
||||
if (withTools) {
|
||||
// In production bootstrap.ts hands the Nest-discovered registry to
|
||||
// registerTools after app.init(); the harness has no Nest app, so it
|
||||
@@ -68,9 +70,21 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
|
||||
return { client, server, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* callTool is declared as a union of the current result shape and the legacy
|
||||
* `toolResult` compatibility shape. Reading `content` off the union yields
|
||||
* unknown, because the compatibility branch only has an index signature.
|
||||
*/
|
||||
type ToolCallResult = Awaited<ReturnType<Client['callTool']>>;
|
||||
type ToolCallContentResult = Extract<ToolCallResult, { content: unknown[] }>;
|
||||
type ToolTextContent = Extract<ToolCallContentResult['content'][number], { type: 'text' }>;
|
||||
|
||||
/** Parse JSON from a callTool result (first text content item). */
|
||||
export function parseToolResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
|
||||
const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
|
||||
export function parseToolResult(result: ToolCallResult): unknown {
|
||||
// Our own McpServer always answers with the content shape, never the
|
||||
// compatibility one, so pick that branch of the union.
|
||||
const { content } = result as ToolCallContentResult;
|
||||
const text = content.find((c): c is ToolTextContent => c.type === 'text');
|
||||
if (!text) throw new Error('No text content in tool result');
|
||||
return JSON.parse(text.text);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ import { JourneyShareService } from '../../src/nest/journey/journey-share.servic
|
||||
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
|
||||
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
|
||||
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
|
||||
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
|
||||
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { CalendarService } from '../../src/nest/calendar/calendar.service';
|
||||
import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service';
|
||||
import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp';
|
||||
@@ -79,7 +82,20 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi
|
||||
export function createMcpTestRegistry(): McpRegistry {
|
||||
const dbService = new DatabaseService(db);
|
||||
const permissionsService = new PermissionsService(dbService);
|
||||
const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService), new EphemeralTokenService());
|
||||
// Same argument list as auth.bridge.ts. AtlasService used to sit in third
|
||||
// place; when getTravelStats moved onto AtlasService itself the edge was
|
||||
// dropped and four collaborators took its place, but this call site kept the
|
||||
// old shape, so `membership` and `webauthn` held the wrong objects and
|
||||
// userCleanup/mailer/tokens were undefined.
|
||||
const authService = new AuthService(
|
||||
dbService,
|
||||
permissionsService,
|
||||
new TripMembershipService(dbService),
|
||||
new WebauthnConfigService(dbService),
|
||||
new UserCleanupService(dbService),
|
||||
new MailerService(dbService),
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
const realtimeService = new RealtimeService();
|
||||
const queryHelpersService = new QueryHelpersService(dbService);
|
||||
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService);
|
||||
@@ -88,7 +104,10 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService);
|
||||
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
const mapsService = new MapsService(dbService);
|
||||
// Exactly one instance, shared by maps, places and share: its stampede guard
|
||||
// and its on-disk set only work if all three readers see the same maps.
|
||||
const placePhotoCache = new PlacePhotoCacheService(dbService, new RuntimeEnvService());
|
||||
const mapsService = new MapsService(dbService, placePhotoCache);
|
||||
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
|
||||
// The last three were previously omitted, which left them `undefined` at
|
||||
// runtime — silently fine while nothing called them, a TypeError the moment
|
||||
@@ -97,7 +116,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
const placesService = new PlacesService(
|
||||
dbService, permissionsService, realtimeService, mapsService, queryHelpersService,
|
||||
new UnsplashService(dbService, new RuntimeEnvService()),
|
||||
new PlacePhotoCacheService(dbService, new RuntimeEnvService()),
|
||||
placePhotoCache,
|
||||
journeyDomain,
|
||||
);
|
||||
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub());
|
||||
@@ -146,7 +165,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new CollabMcp(collabService, authService, addonsService),
|
||||
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
|
||||
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService),
|
||||
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService),
|
||||
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService),
|
||||
new MapsMcp(mapsService),
|
||||
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
|
||||
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub()), dbService, authService, addonsService),
|
||||
|
||||
@@ -64,6 +64,11 @@ import { CollectionsRpc } from '../../src/nest/collections/collections.rpc';
|
||||
import { makeNotificationsService } from './notifications';
|
||||
import { notificationsStub } from './notifications';
|
||||
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
|
||||
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
|
||||
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
|
||||
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
|
||||
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
|
||||
@@ -89,21 +94,21 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
const collab = new CollabService(dbs, permissions, realtime, notificationsStub());
|
||||
const vacay = new VacayService(dbs, realtime, notificationsStub());
|
||||
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
|
||||
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs), queryHelpers);
|
||||
const photoCache = new PlacePhotoCacheService(dbs, new RuntimeEnvService());
|
||||
const unsplash = new UnsplashService(dbs, new RuntimeEnvService());
|
||||
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
|
||||
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey);
|
||||
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub());
|
||||
const atlas = new AtlasService(dbs);
|
||||
const dayNotes = new DayNotesService(dbs, permissions, realtime);
|
||||
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers);
|
||||
const journey = new JourneyDomainService(dbs);
|
||||
const membership = new TripMembershipService(dbs, permissions, realtime);
|
||||
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
|
||||
const membership = new TripMembershipService(dbs);
|
||||
const notifications = makeNotificationsService(dbs, realtime);
|
||||
const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons);
|
||||
const oauth = new PluginOAuthService(dbs);
|
||||
const accommodations = new AccommodationsService(dbs, permissions, realtime);
|
||||
// Was 12 arguments for a 14-parameter constructor; unsplash and userCleanup were
|
||||
// undefined here and tsconfig only typechecks src, so nothing said so.
|
||||
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, undefined as never);
|
||||
const members = new TripMembersService(dbs, budget, undefined as never, permissions, realtime, notificationsStub());
|
||||
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash);
|
||||
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs), permissions, realtime, notificationsStub());
|
||||
const guards = new PluginGuards(dbs, permissions, addons);
|
||||
|
||||
const registry = createTestPluginRegistry([
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* No real HTTP calls are made.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
@@ -16,6 +16,15 @@ import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc
|
||||
import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
|
||||
/**
|
||||
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
|
||||
* fixture below calls it, and none of these plugins has a stored setting anyway, so
|
||||
* the stub is the one read DbRpc makes, typed off the real signature and answering
|
||||
* "nothing stored" exactly as the real service would.
|
||||
*/
|
||||
const stubUserSettings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
@@ -38,7 +47,7 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
|
||||
};
|
||||
// Only db.* is exercised from a child here; the rest of the surface has its own
|
||||
// unit suites and would drag every domain service into this integration test.
|
||||
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc()]));
|
||||
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc(stubUserSettings as PluginUserSettingsService)]));
|
||||
};
|
||||
const hooks: SupervisorHooks = {
|
||||
onEvent: (_id, topic, data) => events.push({ topic, data }),
|
||||
|
||||
+14
-6
@@ -3,6 +3,20 @@
|
||||
// config.ts, database.ts, etc. pick them up at import time. (Importing from
|
||||
// 'vitest' itself is safe: it is externalized and pulls in no app modules.)
|
||||
import { afterEach } from 'vitest';
|
||||
// The one app module imported here, and it is deliberately a static import even
|
||||
// though import declarations are hoisted above the process.env writes below.
|
||||
// The chain is nominatim.client -> maps.helpers -> app-config, and its only
|
||||
// load-time read of the environment is the UA constant's getAppUrl(), which
|
||||
// looks at APP_URL, ALLOWED_ORIGINS and PORT. This file sets none of those, so
|
||||
// nothing in the chain observes a variable before it is written; config.ts and
|
||||
// database.ts are not on it.
|
||||
//
|
||||
// It also has to be static. A setup file is imported for its side effects, not
|
||||
// awaited as a hook, so a deferred `import()` would still be resolving while the
|
||||
// test file registers its own mocks: booking-import's kitinerary suite replaces
|
||||
// app-config with a factory that has no getAppUrl, and the late import then
|
||||
// resolves maps.helpers against that mock and throws.
|
||||
import { setGeoThrottleInterval } from '../src/nest/geo/nominatim.client';
|
||||
|
||||
// Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests
|
||||
process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
|
||||
@@ -28,10 +42,4 @@ afterEach(async () => {
|
||||
// under test. Nineteen maps cases stub fetch and drive those paths back to back;
|
||||
// under the real 1.1s interval they would spend nineteen seconds asleep. The
|
||||
// cases that are actually about the throttle set it back for themselves.
|
||||
//
|
||||
// Imported dynamically, and down here rather than at the top: the client pulls
|
||||
// in maps.helpers, whose UA constant calls getAppUrl() at module load. A static
|
||||
// import is hoisted above the assignments above it, so the URL would be read
|
||||
// from an environment this file has not finished setting up yet.
|
||||
const { setGeoThrottleInterval } = await import('../src/nest/geo/nominatim.client');
|
||||
setGeoThrottleInterval(0);
|
||||
|
||||
@@ -17,12 +17,10 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { resolve } from 'node:path';
|
||||
import { createTestDb } from '../../helpers/test-db';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts');
|
||||
const MIGRATIONS_PATH = resolve(__dirname, '../../../src/db/migrations.ts');
|
||||
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock('../../../src/config', () => ({
|
||||
}));
|
||||
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { TransitPlace } from '../../../src/nest/transit/transit.helpers';
|
||||
import { TransitService } from '../../../src/nest/transit/transit.service';
|
||||
|
||||
// The transit tools live on the DI-discovered transit.mcp.ts since the transit
|
||||
@@ -157,7 +158,10 @@ describe('MCP transit tools', () => {
|
||||
|
||||
it('forwards stop and route searches and replaces provider endpoint names', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
geocodeMock.mockResolvedValue({ results: [from] });
|
||||
// `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace,
|
||||
// which also carries `type` and `area`; the stop search only reads the name back out,
|
||||
// so the fixture stays as it is instead of growing fields nothing here looks at.
|
||||
geocodeMock.mockResolvedValue({ results: [from as TransitPlace] });
|
||||
planMock.mockResolvedValue({
|
||||
itineraries: [
|
||||
itinerary,
|
||||
|
||||
@@ -35,6 +35,16 @@ function controller(over: Partial<PackingService> = {}) {
|
||||
return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing };
|
||||
}
|
||||
|
||||
/**
|
||||
* An override whose return value the real method signature cannot produce. Every error
|
||||
* branch in PackingService sets a status, so an error envelope without one is not part
|
||||
* of the service's return type, yet the controller still has a fallback for it and
|
||||
* PACKTPL-003 is the case that covers that fallback.
|
||||
*/
|
||||
function offContract<K extends keyof PackingService>(name: K, impl: () => unknown) {
|
||||
return { [name]: vi.fn(impl) } as unknown as Partial<PackingService>;
|
||||
}
|
||||
|
||||
const thrown = (run: () => unknown) => {
|
||||
try {
|
||||
run();
|
||||
@@ -54,12 +64,12 @@ describe('AdminPackingTemplatesController', () => {
|
||||
});
|
||||
|
||||
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => {
|
||||
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) } as Partial<PackingService>);
|
||||
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) });
|
||||
expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||
});
|
||||
|
||||
it('PACKTPL-003 an error without a status defaults to 400', () => {
|
||||
const { c } = controller({ createTemplateCategory: vi.fn(() => ({ error: 'name required' })) } as Partial<PackingService>);
|
||||
const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' })));
|
||||
expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
|
||||
});
|
||||
|
||||
|
||||
@@ -180,8 +180,8 @@ describe('AdminController read-only getters', () => {
|
||||
|
||||
describe('AdminController tokens + sessions', () => {
|
||||
it('mcp token + oauth session deletes return success and map errors', () => {
|
||||
expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken('2')).toEqual({ success: true });
|
||||
expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||
expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true });
|
||||
expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||
expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,9 +45,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||||
@@ -63,7 +63,11 @@ const realtime = new RealtimeService();
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const webauthn = new WebauthnConfigService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs);
|
||||
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, new EphemeralTokenService());
|
||||
// Positional and previously wrong: an AtlasService sat in the membership slot
|
||||
// and the mailer was missing entirely, so `auth` was built with its last four
|
||||
// collaborators shifted by one. Nothing failed, because the version-check path
|
||||
// below never reaches them.
|
||||
const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService());
|
||||
const svc = new AdminService(
|
||||
dbs,
|
||||
new AddonsService(dbs),
|
||||
@@ -72,6 +76,7 @@ const svc = new AdminService(
|
||||
permissions,
|
||||
makeNotificationsService(dbs, realtime),
|
||||
userCleanup,
|
||||
realtime,
|
||||
);
|
||||
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock('../../../src/scheduler', () => ({
|
||||
// Imports (after mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
@@ -30,33 +30,33 @@ beforeEach(() => vi.clearAllMocks());
|
||||
describe('ReservationImportController.preview', () => {
|
||||
it('rejects an invalid mode with 400', async () => {
|
||||
const { c } = make();
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400);
|
||||
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'bogus' }))).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 409 for force-ai when AI is not configured', async () => {
|
||||
const { c } = make({ aiAvailable: vi.fn(() => false) as any });
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'force-ai'))).toBe(409);
|
||||
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'force-ai' }))).toBe(409);
|
||||
});
|
||||
|
||||
it('returns 503 for no-ai when the extractor is unavailable', async () => {
|
||||
const { c } = make({ isAvailable: vi.fn(() => false) as any });
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'no-ai'))).toBe(503);
|
||||
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'no-ai' }))).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 400 when no files are uploaded', async () => {
|
||||
const { c } = make();
|
||||
expect(await status(() => c.preview(user, 't1', [], 'no-ai'))).toBe(400);
|
||||
expect(await status(() => c.preview(user, 't1', [], { mode: 'no-ai' }))).toBe(400);
|
||||
});
|
||||
|
||||
it('passes the parsed mode and user id through to the service', async () => {
|
||||
const { c, svc } = make();
|
||||
await c.preview(user, 't1', [file()], 'fallback-on-empty');
|
||||
await c.preview(user, 't1', [file()], { mode: 'fallback-on-empty' });
|
||||
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
|
||||
});
|
||||
|
||||
it('defaults the mode to no-ai when omitted', async () => {
|
||||
const { c, svc } = make();
|
||||
await c.preview(user, 't1', [file()], undefined);
|
||||
await c.preview(user, 't1', [file()], {});
|
||||
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('CategoriesController (parity with the legacy /api/categories route)',
|
||||
it('404 when the category does not exist', () => {
|
||||
const getById = vi.fn().mockReturnValue(undefined);
|
||||
const update = vi.fn();
|
||||
expect(thrown(() => makeController({ getById, update }).update('9', 'X'))).toEqual({
|
||||
expect(thrown(() => makeController({ getById, update }).update('9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Category not found' },
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
@@ -62,7 +62,7 @@ import { createUser, createTrip, addTripMember, createPlace, createReservation,
|
||||
import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { FilesService } from '../../../src/nest/files/files.service';
|
||||
import { getAllowedExtensions as bridgeGetAllowedExtensions } from '../../../src/nest/files/files.bridge';
|
||||
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
|
||||
import {
|
||||
DEFAULT_ALLOWED_EXTENSIONS,
|
||||
MAX_FILE_SIZE,
|
||||
@@ -558,8 +558,13 @@ describe('verifyTripAccess / can / files.bridge', () => {
|
||||
expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false);
|
||||
});
|
||||
|
||||
it('FILE-SVC-038: files.bridge getAllowedExtensions delegates to the service over the shared db', () => {
|
||||
it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', () => {
|
||||
// Was asserted against files.bridge, which built its own FilesService. The
|
||||
// list moved to a leaf service the multer factories inject, and the property
|
||||
// that matters is unchanged: an admin editing the list in settings applies
|
||||
// to the next upload, with no invalidation wiring.
|
||||
const allowed = new AllowedFileTypesService(new DatabaseService(testDb));
|
||||
setAppSetting(testDb, 'allowed_file_types', 'md,markdown');
|
||||
expect(bridgeGetAllowedExtensions()).toBe('md,markdown');
|
||||
expect(allowed.get()).toBe('md,markdown');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.co
|
||||
import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller';
|
||||
import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller';
|
||||
import type { MemoriesService } from '../../../src/nest/memories/memories.service';
|
||||
import type { AddTripPhotosDto, SynologySettingsDto } from '../../../src/nest/memories/memories.dto';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
@@ -16,6 +17,16 @@ function makeService(overrides: Partial<MemoriesService> = {}): MemoriesService
|
||||
return { ...overrides } as unknown as MemoriesService;
|
||||
}
|
||||
|
||||
/**
|
||||
* A DTO type describes a body that already passed the Zod pipe. The two cases
|
||||
* that use this hand a controller a value the pipe would reject, because what
|
||||
* they pin is the hand-rolled coercion running after validation — the leniency
|
||||
* the Express routers had, which the controllers still carry.
|
||||
*/
|
||||
function offContractBody<T>(body: Record<string, unknown>): T {
|
||||
return body as unknown as T;
|
||||
}
|
||||
|
||||
type MockRes = Response & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
@@ -78,7 +89,13 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif
|
||||
it('ignores a non-array selections payload', async () => {
|
||||
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } });
|
||||
const svc = makeService({ addTripPhotos });
|
||||
await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes());
|
||||
await new UnifiedMemoriesController(svc).addPhotos(
|
||||
user,
|
||||
'5',
|
||||
offContractBody<AddTripPhotosDto>({ selections: 'nope', shared: true }),
|
||||
'sock',
|
||||
makeRes(),
|
||||
);
|
||||
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock');
|
||||
});
|
||||
|
||||
@@ -567,7 +584,11 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn
|
||||
expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true);
|
||||
|
||||
const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) });
|
||||
await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes());
|
||||
await new SynologyMemoriesController(svc2).putSettings(
|
||||
user,
|
||||
offContractBody<SynologySettingsDto>({ synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }),
|
||||
makeRes(),
|
||||
);
|
||||
expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.cont
|
||||
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
||||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||||
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function osvc(o: Partial<OauthService> = {}): OauthService {
|
||||
@@ -83,7 +84,9 @@ describe('OauthPublicController /token', () => {
|
||||
|
||||
it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => {
|
||||
const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' };
|
||||
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body = base) => {
|
||||
// body is the loose form the controller actually reads off req.body, not the
|
||||
// shape of `base`, so a case can add a field such as `resource`.
|
||||
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body: Record<string, string> = base) => {
|
||||
const res = makeRes();
|
||||
opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res);
|
||||
return res;
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
|
||||
mockDbGet: vi.fn(() => undefined as any),
|
||||
// Declared with the shape the fake statement below calls it with (the SQL plus
|
||||
// whatever the caller bound), otherwise the spread has no rest parameter to land in.
|
||||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
|
||||
mockDbRun: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
|
||||
mockDbGet: vi.fn(() => undefined as any),
|
||||
// The prepare() stub below forwards the SQL and then the bound values, so the
|
||||
// spy has to declare that shape. Inferred from a zero-arg implementation it
|
||||
// would reject the spread at the call site.
|
||||
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
|
||||
mockDbRun: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TagsController } from '../../../src/nest/tags/tags.controller';
|
||||
import type { TagsService } from '../../../src/nest/tags/tags.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { Tag } from '@trek/shared';
|
||||
import { anyBody } from '../../helpers/dto';
|
||||
|
||||
const user = { id: 5 } as User;
|
||||
|
||||
@@ -34,7 +35,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
|
||||
describe('POST /', () => {
|
||||
it('400 when name is missing', () => {
|
||||
const create = vi.fn();
|
||||
expect(thrown(() => makeController({ create }).create(user, undefined))).toEqual({
|
||||
expect(thrown(() => makeController({ create }).create(user, anyBody()))).toEqual({
|
||||
status: 400, body: { error: 'Tag name is required' },
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
@@ -42,7 +43,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
|
||||
|
||||
it('creates a tag for the caller', () => {
|
||||
const create = vi.fn().mockReturnValue(tag);
|
||||
expect(makeController({ create }).create(user, 'Beach', '#10b981')).toEqual({ tag });
|
||||
expect(makeController({ create }).create(user, anyBody({ name: 'Beach', color: '#10b981' }))).toEqual({ tag });
|
||||
expect(create).toHaveBeenCalledWith(5, 'Beach', '#10b981');
|
||||
});
|
||||
});
|
||||
@@ -51,7 +52,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
|
||||
it('404 when the tag is not owned by the caller', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(undefined);
|
||||
const update = vi.fn();
|
||||
expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', 'X'))).toEqual({
|
||||
expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', anyBody({ name: 'X' })))).toEqual({
|
||||
status: 404, body: { error: 'Tag not found' },
|
||||
});
|
||||
expect(getByIdAndUser).toHaveBeenCalledWith('9', 5);
|
||||
@@ -61,7 +62,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
|
||||
it('updates an owned tag', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(tag);
|
||||
const update = vi.fn().mockReturnValue({ ...tag, name: 'Hike' });
|
||||
expect(makeController({ getByIdAndUser, update }).update(user, '1', 'Hike')).toEqual({ tag: { ...tag, name: 'Hike' } });
|
||||
expect(makeController({ getByIdAndUser, update }).update(user, '1', anyBody({ name: 'Hike' }))).toEqual({ tag: { ...tag, name: 'Hike' } });
|
||||
expect(update).toHaveBeenCalledWith('1', 'Hike', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,11 @@ import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
|
||||
// Real sibling services over the same in-memory DB — the aggregation runs the
|
||||
// actual SQL of every domain it fans out to, so a shape change downstream shows
|
||||
@@ -75,7 +80,15 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
|
||||
const dbs = () => new DatabaseService(testDb);
|
||||
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
|
||||
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
|
||||
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
|
||||
// One shared cache instance, as in trips.bridge.ts: the in-flight dedup in
|
||||
// PlacePhotoCacheService only works while both consumers hold the same object.
|
||||
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
|
||||
const placesSvc = new PlacesService(
|
||||
dbs(), new PermissionsService(dbs()), new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()),
|
||||
new UnsplashService(dbs(), new RuntimeEnvService()), photoCache,
|
||||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
|
||||
);
|
||||
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
|
||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members.
|
||||
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
||||
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import fs from 'fs';
|
||||
@@ -85,7 +90,24 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
|
||||
const dbs = () => new DatabaseService(testDb);
|
||||
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
|
||||
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
|
||||
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
|
||||
// Same collaborator set the container hands PlacesService (see trips.bridge.ts).
|
||||
// Only the read-model aggregation reaches into places here, but the photo cache,
|
||||
// Unsplash and journey domain are real instances over the same in-memory DB
|
||||
// rather than casts: the place hooks are fire-and-forget behind a catch, so a
|
||||
// missing collaborator would look like a pass while swallowing a TypeError.
|
||||
// One PlacePhotoCacheService for both PlacesService and MapsService, matching
|
||||
// production, where the in-flight dedup only works on a shared instance.
|
||||
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
|
||||
const placesSvc = new PlacesService(
|
||||
dbs(),
|
||||
new PermissionsService(dbs()),
|
||||
new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache),
|
||||
new QueryHelpersService(dbs()),
|
||||
new UnsplashService(dbs(), new RuntimeEnvService()),
|
||||
photoCache,
|
||||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
|
||||
);
|
||||
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
|
||||
const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc);
|
||||
|
||||
|
||||
@@ -35,10 +35,13 @@ function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: I
|
||||
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub());
|
||||
const data = {
|
||||
listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]),
|
||||
createItem: vi.fn((_t: string, i: Record<string, unknown>) => ({ id: 70, ...i })),
|
||||
// The bodies here are the boring fixture; the item writes are typed as Item so a
|
||||
// case can hand back the privacy fields the real service selects (#858), which the
|
||||
// fixture itself never bothers to set.
|
||||
createItem: vi.fn((_t: string, i: Record<string, unknown>): Item => ({ id: 70, ...i })),
|
||||
getItemPrivacy: vi.fn(() => opts.before),
|
||||
updateItem: vi.fn(() => (opts.updated === undefined ? { id: 70, is_private: 0 } : opts.updated)),
|
||||
deleteItem: vi.fn((_t: string, id: string) => (id === '70' ? { id: 70, is_private: 0 } : null)),
|
||||
deleteItem: vi.fn((_t: string, id: string): Item | null => (id === '70' ? { id: 70, is_private: 0 } : null)),
|
||||
listBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]),
|
||||
createBag: vi.fn((_t: string, b: Record<string, unknown>) => ({ id: 80, ...b })),
|
||||
updateBag: vi.fn((_t: string, id: string) => (id === '80' ? { id: 80 } : null)),
|
||||
|
||||
@@ -215,19 +215,19 @@ describe('PluginsController M2 endpoints', () => {
|
||||
|
||||
it('activate spawns via the runtime when enabled', async () => {
|
||||
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
|
||||
const out = await new PluginsController(svc, rt, {} as never).activate('x');
|
||||
const out = await new PluginsController(svc, rt, {} as never).activate('x', {});
|
||||
expect(out).toEqual({ status: 'active' });
|
||||
});
|
||||
|
||||
it('activate is 503 when the runtime is disabled', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('activate surfaces an activation error as 400', async () => {
|
||||
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('deactivate stops the plugin (and cascades to dependents)', async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ReservationsModule } from '../../../src/nest/reservations/reservations.
|
||||
import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc';
|
||||
import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
||||
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
@@ -45,13 +45,15 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; seenActions?: strin
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
notifyBookingChange: vi.fn(),
|
||||
} as unknown as ReservationsService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
// The lodging blocks live in AccommodationsService; AccommodationsRpc still calls its
|
||||
// injected copy `days`, which is why the fixture keeps that name.
|
||||
const days = {
|
||||
validateAccommodationRefs: vi.fn((_t: number, placeId?: number) => (placeId === 404 ? [{ message: 'place 404 is not on this trip' }] : [])),
|
||||
createAccommodation: vi.fn(() => ({ id: 60 })),
|
||||
getAccommodation: vi.fn((id: number) => (id === 11 ? { id: 11 } : undefined)),
|
||||
updateAccommodation: vi.fn(() => ({ id: 11 })),
|
||||
deleteAccommodation: vi.fn(() => ({ linkedReservationId: opts.cascade ? 40 : null, deletedBudgetItemId: opts.cascade ? 7 : null })),
|
||||
} as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
} as unknown as AccommodationsService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
const guards = new PluginGuards(
|
||||
{
|
||||
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)),
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSavePayload } from '../../../src/nest/integrations/airtrail-sync.service';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailSavePayload } from '../../../src/nest/integrations/airtrail.client';
|
||||
|
||||
/**
|
||||
* buildSavePayload spreads the raw flight, so the body also carries the
|
||||
* AirTrail-owned keys the save type deliberately leaves unmodelled (terminal,
|
||||
* gate, actual times, customFields, track). Read those through this view.
|
||||
*/
|
||||
type SavePayloadWithPassthrough = AirtrailSavePayload & Record<string, unknown>;
|
||||
|
||||
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
|
||||
return {
|
||||
@@ -125,7 +132,7 @@ describe('airtrailSync.buildSavePayload', () => {
|
||||
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
|
||||
}),
|
||||
existingFlight(),
|
||||
);
|
||||
) as SavePayloadWithPassthrough | null;
|
||||
expect(payload).toMatchObject({
|
||||
id: 42,
|
||||
from: 'JFK',
|
||||
|
||||
@@ -24,6 +24,15 @@ const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
|
||||
pluginId: 'p',
|
||||
actingUserId,
|
||||
data: {} as PluginRpcContext['data'],
|
||||
// No tags handler reaches for a peer plugin, but the peers are a real collaborator on
|
||||
// the context, so they get functions that throw rather than an object whose methods
|
||||
// would silently be undefined if a handler ever did reach for one.
|
||||
plugins: {
|
||||
call: () => Promise.reject(new Error('ctx: no peer plugin router in these cases')),
|
||||
emit: () => {
|
||||
throw new Error('ctx: no peer plugin router in these cases');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Tag 1 belongs to user 42, nothing else does. */
|
||||
|
||||
@@ -237,6 +237,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'تفعيل الإضافة',
|
||||
'plugins.notFound': 'الإضافة غير موجودة',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'الإضافات الخارجية',
|
||||
'admin.plugins.subtitle': 'ثبّت وأدر إضافات الجهات الخارجية على خادمك.',
|
||||
'admin.plugins.disabledTitle': 'الإضافات الخارجية معطّلة',
|
||||
|
||||
@@ -217,6 +217,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Atualizar → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Ativar plugin',
|
||||
'plugins.notFound': 'Plugin não encontrado',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Instale e gerencie plugins de terceiros na sua instância.',
|
||||
'admin.plugins.disabledTitle': 'Os plugins estão desativados',
|
||||
|
||||
@@ -430,6 +430,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Actualitzar → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Activar el connector',
|
||||
'plugins.notFound': "No s'ha trobat el connector",
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Connectors',
|
||||
'admin.plugins.subtitle': 'Instal·la i gestiona connectors de tercers a la teva instància.',
|
||||
'admin.plugins.disabledTitle': 'Els connectors estan desactivats',
|
||||
|
||||
@@ -218,6 +218,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Povolit plugin',
|
||||
'plugins.notFound': 'Plugin nenalezen',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Pluginy',
|
||||
'admin.plugins.subtitle': 'Instalujte a spravujte pluginy třetích stran na své instanci.',
|
||||
'admin.plugins.disabledTitle': 'Pluginy jsou zakázány',
|
||||
|
||||
@@ -221,6 +221,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Plugin aktivieren',
|
||||
'plugins.notFound': 'Plugin nicht gefunden',
|
||||
'plugins.frameLoadFailed': 'Dieses Plugin konnte nicht geladen werden.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Plugins von Drittanbietern auf deiner Instanz installieren und verwalten.',
|
||||
'admin.plugins.disabledTitle': 'Plugins sind deaktiviert',
|
||||
|
||||
@@ -271,6 +271,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Enable plugin',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Actualizar → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Activar plugin',
|
||||
'plugins.notFound': 'Plugin no encontrado',
|
||||
'plugins.frameLoadFailed': 'No se ha podido cargar este plugin.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Instala y gestiona plugins de terceros en tu instancia.',
|
||||
'admin.plugins.disabledTitle': 'Los plugins están desactivados',
|
||||
|
||||
@@ -222,6 +222,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Activer le plugin',
|
||||
'plugins.notFound': 'Plugin introuvable',
|
||||
'plugins.frameLoadFailed': "Ce plugin n'a pas pu être chargé.",
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Installez et gérez des plugins tiers sur votre instance.',
|
||||
'admin.plugins.disabledTitle': 'Les plugins sont désactivés',
|
||||
|
||||
@@ -266,6 +266,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Ενεργοποίηση plugin',
|
||||
'plugins.notFound': 'Το plugin δεν βρέθηκε',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Εγκαταστήστε και διαχειριστείτε plugins τρίτων στην εγκατάστασή σας.',
|
||||
'admin.plugins.disabledTitle': 'Τα plugins είναι απενεργοποιημένα',
|
||||
|
||||
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Frissítés → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Plugin engedélyezése',
|
||||
'plugins.notFound': 'A plugin nem található',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Pluginok',
|
||||
'admin.plugins.subtitle': 'Harmadik féltől származó pluginok telepítése és kezelése a példányodon.',
|
||||
'admin.plugins.disabledTitle': 'A pluginok le vannak tiltva',
|
||||
|
||||
@@ -262,6 +262,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Aktifkan plugin',
|
||||
'plugins.notFound': 'Plugin tidak ditemukan',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Pasang dan kelola plugin pihak ketiga di instans kamu.',
|
||||
'admin.plugins.disabledTitle': 'Plugin dinonaktifkan',
|
||||
|
||||
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Aggiorna → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Abilita plugin',
|
||||
'plugins.notFound': 'Plugin non trovato',
|
||||
'plugins.frameLoadFailed': 'Impossibile caricare questo plugin.',
|
||||
'admin.plugins.title': 'Plugin',
|
||||
'admin.plugins.subtitle': 'Installa e gestisci plugin di terze parti sulla tua istanza.',
|
||||
'admin.plugins.disabledTitle': 'I plugin sono disabilitati',
|
||||
|
||||
@@ -251,6 +251,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': '更新 → v{version}',
|
||||
'admin.plugins.enabledToggle': 'プラグインを有効にする',
|
||||
'plugins.notFound': 'プラグインが見つかりません',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'プラグイン',
|
||||
'admin.plugins.subtitle': 'サードパーティ製プラグインをインスタンスにインストール・管理します。',
|
||||
'admin.plugins.disabledTitle': 'プラグインは無効になっています',
|
||||
|
||||
@@ -256,6 +256,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': '업데이트 → v{version}',
|
||||
'admin.plugins.enabledToggle': '플러그인 활성화',
|
||||
'plugins.notFound': '플러그인을 찾을 수 없습니다',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': '플러그인',
|
||||
'admin.plugins.subtitle': '인스턴스에 서드파티 플러그인을 설치하고 관리하세요.',
|
||||
'admin.plugins.disabledTitle': '플러그인이 비활성화되어 있습니다',
|
||||
|
||||
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Plugin inschakelen',
|
||||
'plugins.notFound': 'Plugin niet gevonden',
|
||||
'plugins.frameLoadFailed': 'Deze plug-in kon niet worden geladen.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Installeer en beheer plugins van derden op je instantie.',
|
||||
'admin.plugins.disabledTitle': 'Plugins zijn uitgeschakeld',
|
||||
|
||||
@@ -213,6 +213,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Włącz wtyczkę',
|
||||
'plugins.notFound': 'Nie znaleziono wtyczki',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Wtyczki',
|
||||
'admin.plugins.subtitle': 'Instaluj wtyczki innych autorów i zarządzaj nimi na swojej instancji.',
|
||||
'admin.plugins.disabledTitle': 'Wtyczki są wyłączone',
|
||||
|
||||
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Обновить → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Включить плагин',
|
||||
'plugins.notFound': 'Плагин не найден',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Плагины',
|
||||
'admin.plugins.subtitle': 'Устанавливайте сторонние плагины и управляйте ими на вашем экземпляре.',
|
||||
'admin.plugins.disabledTitle': 'Плагины отключены',
|
||||
|
||||
@@ -276,6 +276,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Uppdatera → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Aktivera plugin',
|
||||
'plugins.notFound': 'Pluginet hittades inte',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Installera och hantera tredjepartsplugins på din instans.',
|
||||
'admin.plugins.disabledTitle': 'Plugins är inaktiverade',
|
||||
|
||||
@@ -264,6 +264,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Güncelle → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Eklentiyi etkinleştir',
|
||||
'plugins.notFound': 'Eklenti bulunamadı',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Örneğinizde üçüncü taraf eklentileri yükleyin ve yönetin.',
|
||||
'admin.plugins.disabledTitle': 'Eklentiler devre dışı',
|
||||
|
||||
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Оновити → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Увімкнути плагін',
|
||||
'plugins.notFound': 'Плагін не знайдено',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Плагіни',
|
||||
'admin.plugins.subtitle': 'Встановлюйте сторонні плагіни та керуйте ними на вашому інстансі.',
|
||||
'admin.plugins.disabledTitle': 'Плагіни вимкнено',
|
||||
|
||||
@@ -275,6 +275,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Cập nhật → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Bật plugin',
|
||||
'plugins.notFound': 'Không tìm thấy plugin',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Cài đặt và quản lý các plugin bên thứ ba trên hệ thống của bạn.',
|
||||
'admin.plugins.disabledTitle': 'Plugin đang bị tắt',
|
||||
|
||||
@@ -251,6 +251,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': '更新 → v{version}',
|
||||
'admin.plugins.enabledToggle': '啟用外掛',
|
||||
'plugins.notFound': '找不到外掛',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': '外掛',
|
||||
'admin.plugins.subtitle': '在你的執行個體上安裝並管理第三方外掛。',
|
||||
'admin.plugins.disabledTitle': '外掛已停用',
|
||||
|
||||
@@ -211,6 +211,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': '更新 → v{version}',
|
||||
'admin.plugins.enabledToggle': '启用插件',
|
||||
'plugins.notFound': '未找到插件',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': '插件',
|
||||
'admin.plugins.subtitle': '在你的实例上安装和管理第三方插件。',
|
||||
'admin.plugins.disabledTitle': '插件功能已禁用',
|
||||
|
||||
Reference in New Issue
Block a user