Files
TREK/server/src/nest/collections/collections.rpc.ts
T
Maurice 611f94e820 Consolidate the schema-error fallback and cover the defensive branches
Aiming the new files at 95% rather than at the 80% gate turned up something worth
fixing rather than testing around: `error.issues[0]?.message ?? 'bad input'` had
been copied into twenty handlers. That is twenty two-branch fallbacks that no test
can reach, because a real Zod failure always carries at least one issue. They are
now one schemaMessage() in rpc-params.ts, with one test that does reach both sides.

The rest of this is the branches the handlers inherited from the router: the
`!= null` link ternaries, the "service said no" paths, the ValidationError and
NotFoundError translations, and a reject case per schema-validated method.

Two limits found while measuring, both real rather than laziness. istanbul counts
each @PluginController line as a branch and only ever takes one side, so eight
branches across the rpc files can never be covered and a two-method file caps out
around 50%. And the three update schemas stay deliberately permissive, because the
migration's rule is parity over tightening, so their reject branch is defence in
depth no payload reaches. Everything else went from 50 uncovered branches to 29.
2026-08-09 16:38:43 +02:00

118 lines
5.5 KiB
TypeScript

import {
collectionCopyToTripRequestSchema,
collectionCreateRequestSchema,
collectionSavePlaceRequestSchema,
collectionUpdateRequestSchema,
} from '@trek/shared';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { ADDON_IDS } from '../../addons';
import { CollectionsService } from './collections.service';
/**
* The collections surface a plugin may reach (#plugins).
*
* CollectionsService decides per-collection access itself (owner, admin or editor via
* its assertAccess / assertCanEdit), and reports a refusal as a status-tagged error
* rather than a typed one. mapCollectionError turns those statuses into the RPC error
* classes, so a 403 or 404 reaches the plugin as RESOURCE_FORBIDDEN and a 400 or 409
* as BAD_PARAMS, instead of leaking out as HOST_ERROR.
*/
@PluginController()
export class CollectionsRpc {
constructor(
private readonly collections: CollectionsService,
private readonly guards: PluginGuards,
) {}
@PluginMethod('collections.listMine', { permission: 'db:read:collections' })
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const userId = this.requireCollectionsUser(ctx, 'reads');
this.requireCollectionsAddon();
return this.collections.listCollections(userId);
}
@PluginMethod('collections.get', { permission: 'db:read:collections' })
get(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
// getCollection is user-scoped by the service, so a plugin only ever fetches one
// the acting user may see.
const userId = this.requireCollectionsUser(ctx, 'reads');
const id = num(params.id, 'id');
this.requireCollectionsAddon();
return this.collections.getCollection(userId, id);
}
@PluginMethod('collections.create', { permission: 'db:write:collections' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const parsed = collectionCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.createCollection(userId, parsed.data as never));
}
@PluginMethod('collections.update', { permission: 'db:write:collections' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const parsed = collectionUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
const id = num(params.id, 'id');
this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.updateCollection(userId, id, parsed.data as never, undefined));
}
@PluginMethod('collections.savePlace', { permission: 'db:write:collections' })
savePlace(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const parsed = collectionSavePlaceRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.savePlace(userId, parsed.data as never, undefined));
}
@PluginMethod('collections.copyToTrip', { permission: 'db:write:collections' })
copyToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const parsed = collectionCopyToTripRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid copy request: ${schemaMessage(parsed.error)}`);
const userId = this.requireCollectionsUser(ctx, 'writes');
this.requireCollectionsAddon();
return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never));
}
@PluginMethod('collections.deletePlace', { permission: 'db:write:collections' })
deletePlace(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const userId = this.requireCollectionsUser(ctx, 'writes');
const placeId = num(params.placeId, 'placeId');
this.requireCollectionsAddon();
this.mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined));
return { deleted: true };
}
private requireCollectionsUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number {
if (ctx.actingUserId === undefined) {
throw new ForbiddenResource(`collection ${kind} require an authenticated user context`);
}
return ctx.actingUserId;
}
private requireCollectionsAddon(): void {
this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
}
/** The service's status-tagged errors, mapped onto the RPC error taxonomy. */
private mapCollectionError<T>(fn: () => T): T {
try {
return fn();
} catch (e) {
const status = (e as { status?: number })?.status;
const msg = e instanceof Error ? e.message : 'collection error';
if (status === 403 || status === 404) throw new ForbiddenResource(msg);
if (status === 400 || status === 409) throw new BadParams(msg);
throw e;
}
}
}