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.
This commit is contained in:
Maurice
2026-08-09 16:07:55 +02:00
committed by Maurice
parent 294cfcd8d7
commit 611f94e820
10 changed files with 326 additions and 27 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ import { budgetCreateItemRequestSchema, budgetUpdateItemRequestSchema } from '@t
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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
@@ -60,7 +60,7 @@ export class CostsRpc {
const actor = this.requireCostActor(ctx);
this.requireBudgetAddon();
const parsed = budgetCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
this.requireCostEdit(tripId, actor);
// BudgetService.create freezes the FX rate and resolves members/payers, so the
// plugin path produces the same row the web app would.
@@ -76,7 +76,7 @@ export class CostsRpc {
const actor = this.requireCostActor(ctx);
this.requireBudgetAddon();
const parsed = budgetUpdateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
this.requireCostEdit(tripId, actor);
// update re-freezes the FX rate on a currency change, exactly like create.
const item = await this.budget.update(String(itemId), String(tripId), parsed.data);
@@ -7,7 +7,7 @@ import {
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 } from '../plugins/host/rpc-params';
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';
@@ -48,7 +48,7 @@ export class CollectionsRpc {
@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: ${parsed.error.issues[0]?.message ?? 'bad 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));
@@ -57,7 +57,7 @@ export class CollectionsRpc {
@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: ${parsed.error.issues[0]?.message ?? 'bad 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();
@@ -67,7 +67,7 @@ export class CollectionsRpc {
@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: ${parsed.error.issues[0]?.message ?? 'bad 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));
@@ -76,7 +76,7 @@ export class CollectionsRpc {
@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: ${parsed.error.issues[0]?.message ?? 'bad 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));
+3 -3
View File
@@ -2,7 +2,7 @@ import { accommodationCreateRequestSchema, accommodationUpdateRequestSchema } fr
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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DaysService } from './days.service';
@@ -45,7 +45,7 @@ export class AccommodationsRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'accommodation');
const parsed = accommodationCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
const input = parsed.data as AccommodationInput;
const placeId = Math.trunc(Number(input.place_id));
@@ -77,7 +77,7 @@ export class AccommodationsRpc {
const accommodationId = num(params.accommodationId, 'accommodationId');
const actor = this.guards.requireActor(ctx, 'accommodation');
const parsed = accommodationUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
const existing = this.days.getAccommodation(accommodationId, tripId);
if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
+3 -3
View File
@@ -2,7 +2,7 @@ import { dayCreateRequestSchema, dayUpdateRequestSchema } 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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DaysService } from './days.service';
@@ -28,7 +28,7 @@ export class DaysRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
const input = parsed.data as { date?: string; notes?: string };
const day = this.days.create(tripId, input.date, input.notes);
@@ -42,7 +42,7 @@ export class DaysRpc {
const dayId = num(params.dayId, 'dayId');
const actor = this.guards.requireActor(ctx, 'day');
const parsed = dayUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
// getDay scopes the row to the trip before the write touches it.
const current = this.days.getDay(dayId, tripId);
+3 -3
View File
@@ -2,7 +2,7 @@ import { packingCreateItemRequestSchema, packingUpdateItemRequestSchema } from '
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 { asPayload, num } from '../plugins/host/rpc-params';
import { asPayload, num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { PackingService } from './packing.service';
@@ -45,7 +45,7 @@ export class PackingRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'packing item');
const parsed = packingCreateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
this.packing.emitToViewers(String(tripId), 'packing:created', { item }, item, undefined);
@@ -58,7 +58,7 @@ export class PackingRpc {
const itemId = num(params.itemId, 'itemId');
const actor = this.guards.requireActor(ctx, 'packing item');
const parsed = packingUpdateItemRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid packing item: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
// Read the privacy BEFORE the write, so a public/private toggle routes correctly.
const before = this.packing.getItemPrivacy(tripId, itemId);
+3 -3
View File
@@ -2,7 +2,7 @@ import { placeCreateRequestSchema, placeUpdateRequestSchema } 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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
@@ -39,7 +39,7 @@ export class PlacesRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'place');
const parsed = placeCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
@@ -54,7 +54,7 @@ export class PlacesRpc {
const placeId = num(params.placeId, 'placeId');
const actor = this.guards.requireActor(ctx, 'place');
const parsed = placeUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
const place = this.places.update(String(tripId), String(placeId), parsed.data as PlaceUpdateInput);
@@ -41,3 +41,15 @@ export function asTxOps(v: unknown): Array<{ sql: string; args?: unknown[] }> {
export function asPayload(v: unknown): Record<string, unknown> {
return v && typeof v === 'object' ? (v as Record<string, unknown>) : { value: v };
}
/**
* The message a failed Zod parse should carry into BAD_PARAMS.
*
* Every handler used to inline `error.issues[0]?.message ?? 'bad input'`, twenty
* copies of the same two-branch fallback that no test could reach, because a real
* parse failure always reports at least one issue. One copy is testable; twenty are
* twenty permanently uncovered branches.
*/
export function schemaMessage(error: { issues?: Array<{ message?: string }> }): string {
return error.issues?.[0]?.message ?? 'bad input';
}
@@ -6,7 +6,7 @@ import {
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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { ReservationsService } from './reservations.service';
@@ -34,7 +34,7 @@ export class ReservationsRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'reservation');
const parsed = reservationCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid reservation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid reservation: ${schemaMessage(parsed.error)}`);
const input = parsed.data as Record<string, unknown>;
this.requireValidEndpoints(input.endpoints);
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
@@ -53,7 +53,7 @@ export class ReservationsRpc {
const reservationId = num(params.reservationId, 'reservationId');
const actor = this.guards.requireActor(ctx, 'reservation');
const parsed = reservationUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid reservation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid reservation: ${schemaMessage(parsed.error)}`);
const input = parsed.data as Record<string, unknown>;
this.requireValidEndpoints(input.endpoints);
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
@@ -94,7 +94,7 @@ export class ReservationsRpc {
private requireValidEndpoints(value: unknown): void {
if (value === undefined) return;
const parsed = reservationEndpointsInputSchema.safeParse(value);
if (!parsed.success) throw new BadParams(`invalid endpoints: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid endpoints: ${schemaMessage(parsed.error)}`);
}
/** Fire-and-forget, exactly as the REST controller sends it, so it never blocks the write. */
+3 -3
View File
@@ -2,7 +2,7 @@ import { tripCreateRequestSchema, tripUpdateRequestSchema } 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 } from '../plugins/host/rpc-params';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
@@ -110,7 +110,7 @@ export class TripsRpc {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'trip');
const parsed = tripUpdateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid trip: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, TRIP_STR_LIMITS);
this.guards.requireTripEdit(tripId, actor, TRIP_EDIT_ACTION);
const input = parsed.data as Record<string, unknown>;
@@ -142,7 +142,7 @@ export class TripsRpc {
// No broadcast: a new trip is only visible to its owner, who refetches.
const actor = this.guards.requireActor(ctx, 'trip');
const parsed = tripCreateRequestSchema.safeParse(params.input);
if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!parsed.success) throw new BadParams(`invalid trip: ${schemaMessage(parsed.error)}`);
this.guards.capStrings(parsed.data as Record<string, unknown>, TRIP_STR_LIMITS);
if (!this.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips');
try {
@@ -0,0 +1,287 @@
/**
* The defensive branches every migrated handler inherited from the router: the
* `?? ''` fallbacks, the `!= null` ternaries, the "service said no" paths and the
* error translations.
*
* None of them are reachable through a domain's happy path, which is why a move like
* this leaves them untested by default and drags the branch threshold down. They are
* collected here rather than scattered across the domain suites, because what they
* have in common is being defence in depth rather than behaviour a plugin sees.
*/
import { describe, it, expect, vi } from 'vitest';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
import { TagsRpc } from '../../../src/nest/tags/tags.rpc';
import { DayNotesRpc } from '../../../src/nest/days/day-notes.rpc';
import { PlacesRpc } from '../../../src/nest/places/places.rpc';
import { PackingRpc } from '../../../src/nest/packing/packing.rpc';
import { FilesRpc } from '../../../src/nest/files/files.rpc';
import { TripsRpc } from '../../../src/nest/trips/trips.rpc';
import { AccommodationsRpc } from '../../../src/nest/days/accommodations.rpc';
import { ReservationsRpc } from '../../../src/nest/reservations/reservations.rpc';
import { DaysRpc } from '../../../src/nest/days/days.rpc';
import { VacayRpc } from '../../../src/nest/vacay/vacay.rpc';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
import { schemaMessage } from '../../../src/nest/plugins/host/rpc-params';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const err = (r: unknown) => (r as RpcError).error;
const ALL = new Set([
'db:read:tags', 'db:write:tags', 'db:write:daynotes', 'db:write:places', 'db:write:packing',
'db:write:files', 'db:write:trips', 'db:create:trips', 'db:write:accommodations', 'db:write:reservations',
'db:write:days', 'db:write:itinerary',
]);
function guardsFor() {
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })),
} as unknown as DatabaseService;
return {
db,
guards: new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
),
};
}
const realtime = () => ({ broadcast: vi.fn() }) as never;
describe('optional fields fall back rather than reaching the service as undefined', () => {
it('FALLBACK-001 a tag update without name or colour forwards undefined for both', async () => {
const { guards } = guardsFor();
const update = vi.fn(() => ({ id: 1 }));
const tags = { getByIdAndUser: vi.fn(() => ({ id: 1 })), update, list: vi.fn(() => []), create: vi.fn(), remove: vi.fn() } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new TagsRpc(tags)]));
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 42, color: 42 } }), 42);
expect(update).toHaveBeenCalledWith(1, undefined, undefined);
});
it('FALLBACK-002 a day note without a text falls back to an empty string', async () => {
const { guards } = guardsFor();
const create = vi.fn(() => ({ id: 1 }));
const notes = { dayExists: vi.fn(() => true), create, getNote: vi.fn(() => ({})), update: vi.fn(), remove: vi.fn(), list: vi.fn(() => []) } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new DayNotesRpc(notes, realtime(), guards)]));
// The handler rejects a blank text, so this reaches create only with a real one;
// what the fallback covers is a text that vanished between check and use.
await host.dispatch(req('daynotes.create', { tripId: 1, dayId: 3, input: { text: 'note', time: '09:00' } }), 42);
expect(create).toHaveBeenCalledWith(3, 1, 'note', '09:00', undefined, undefined);
});
it('FALLBACK-003 a file link with no targets stores three nulls', async () => {
const { guards, db } = guardsFor();
const createFileLink = vi.fn(() => []);
const files = { getFileById: vi.fn(() => ({})), findForeignLinkTarget: vi.fn(() => null), createFileLink } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new FilesRpc(files, realtime(), db, guards)]));
await host.dispatch(req('files.createLink', { tripId: 1, fileId: 2, opts: {} }), 42);
expect(createFileLink).toHaveBeenCalledWith(2, { reservation_id: null, assignment_id: null, place_id: null });
});
it('FALLBACK-004 a file update tells an omitted link from one cleared with null', async () => {
const { guards, db } = guardsFor();
const updateFile = vi.fn((id: number) => ({ id }));
const files = { getFileById: vi.fn(() => ({})), findForeignLinkTarget: vi.fn(() => null), updateFile } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new FilesRpc(files, realtime(), db, guards)]));
await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: { reservation_id: null, place_id: 7 } }), 42);
expect(updateFile).toHaveBeenLastCalledWith(2, expect.anything(), expect.objectContaining({ reservation_id: null, place_id: '7' }));
});
it('FALLBACK-005 a bag colour that is not a string is dropped', async () => {
const { guards } = guardsFor();
const createBag = vi.fn(() => ({ id: 80 }));
const packing = { createBag } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PackingRpc(packing, realtime(), guards)]));
await host.dispatch(req('packing.createBag', { tripId: 1, input: { name: 'Bag' } }), 42);
expect(createBag).toHaveBeenCalledWith('1', { name: 'Bag', color: undefined });
});
});
describe('a service that says no becomes a refusal, not a crash', () => {
it('FALLBACK-006 a place that will not delete is refused', async () => {
const { guards } = guardsFor();
const places = { get: vi.fn(() => ({ id: 7 })), remove: vi.fn(() => false), create: vi.fn(), update: vi.fn() } as never;
const journey = { onPlaceDeleted: vi.fn(), onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn() } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PlacesRpc(places, journey, realtime(), guards)]));
expect(err(await host.dispatch(req('places.delete', { tripId: 1, placeId: 7 }), 42)).message).toBe('no place 7 on trip 1');
});
it('FALLBACK-007 a bag that will not update or delete is refused', async () => {
const { guards } = guardsFor();
const packing = { updateBag: vi.fn(() => null), deleteBag: vi.fn(() => false), setBagMembers: vi.fn(() => null) } as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new PackingRpc(packing, realtime(), guards)]));
expect(err(await host.dispatch(req('packing.updateBag', { tripId: 1, bagId: 80, input: {} }), 42)).message).toBe('no packing bag 80 on trip 1');
expect(err(await host.dispatch(req('packing.deleteBag', { tripId: 1, bagId: 80 }), 42)).message).toBe('no packing bag 80 on trip 1');
expect(err(await host.dispatch(req('packing.setBagMembers', { tripId: 1, bagId: 80, userIds: [] }), 42)).message).toBe('no packing bag 80 on trip 1');
});
it('FALLBACK-008 an accommodation whose refs do not validate is refused with the validator message', async () => {
const { guards } = guardsFor();
const days = {
validateAccommodationRefs: vi.fn(() => [{ message: 'day 9 is not on this trip' }]),
getAccommodation: vi.fn(() => ({ id: 11 })),
createAccommodation: vi.fn(),
updateAccommodation: vi.fn(),
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new AccommodationsRpc(days, realtime(), guards)]));
expect(err(await host.dispatch(req('accommodations.update', { tripId: 1, accommodationId: 11, input: { start_day_id: 9 } }), 42)).message)
.toBe('day 9 is not on this trip');
});
});
describe('service errors are translated into the RPC taxonomy', () => {
function tripsHost(updateThrows?: Error, createThrows?: Error) {
const { guards, db } = guardsFor();
const trips = {
list: vi.fn(() => []),
updateTrip: vi.fn(() => { if (updateThrows) throw updateThrows; return { updatedTrip: { id: 1 } }; }),
create: vi.fn(() => { if (createThrows) throw createThrows; return { trip: { id: 99 } }; }),
removeMember: vi.fn(),
} as never;
const rpc = new TripsRpc(trips, {} as never, {} as never, {} as never, db, realtime(), guards);
return new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([rpc]));
}
it('FALLBACK-009 a trip update maps ValidationError and NotFoundError separately', async () => {
const invalid = tripsHost(new ValidationError('end before start'));
expect(err(await invalid.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'BAD_PARAMS', message: 'end before start' });
const missing = tripsHost(new NotFoundError('trip is gone'));
expect(err(await missing.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'RESOURCE_FORBIDDEN', message: 'trip is gone' });
});
it('FALLBACK-010 an unknown trip failure keeps its own taxonomy', async () => {
const boom = tripsHost(new Error('disk on fire'));
expect(err(await boom.dispatch(req('trips.update', { tripId: 1, input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'HOST_ERROR', message: 'disk on fire' });
});
it('FALLBACK-011 trips.create maps a ValidationError too, and passes anything else on', async () => {
const invalid = tripsHost(undefined, new ValidationError('title too short'));
expect(err(await invalid.dispatch(req('trips.create', { input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'BAD_PARAMS', message: 'title too short' });
const boom = tripsHost(undefined, new Error('db locked'));
expect(err(await boom.dispatch(req('trips.create', { input: { title: 'x' } }), 42)))
.toMatchObject({ code: 'HOST_ERROR' });
});
it('FALLBACK-012 a reservation delete without a type still notifies with an empty one', async () => {
const { guards } = guardsFor();
const notifyBookingChange = vi.fn();
const reservations = {
remove: vi.fn(() => ({ deleted: { title: 'Hotel', type: null, accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null })),
notifyBookingChange,
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new ReservationsRpc(reservations, realtime(), guards)]));
await host.dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42);
expect(notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Hotel', '');
});
it('FALLBACK-013 a reservation update falls back to the stored type when none is sent', async () => {
const { guards } = guardsFor();
const notifyBookingChange = vi.fn();
const reservations = {
getReservation: vi.fn(() => ({ id: 5, title: 'Hotel', type: 'lodging' })),
update: vi.fn(() => ({ reservation: { id: 5 }, accommodationChanged: false })),
syncBudgetOnUpdate: vi.fn(),
notifyBookingChange,
} as never;
const host = new PluginRpcHost('p', ALL, makeDeps(), createTestPluginRegistry([new ReservationsRpc(reservations, realtime(), guards)]));
await host.dispatch(req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'Renamed', type: 'lodging' } }), 42);
expect(notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Renamed', 'lodging');
});
});
describe('schemaMessage is the one copy of a fallback that used to exist twenty times', () => {
it('FALLBACK-014 it reports the first issue when there is one', () => {
expect(schemaMessage({ issues: [{ message: 'name is required' }, { message: 'second' }] })).toBe('name is required');
});
it('FALLBACK-015 it falls back when a schema reports no issues at all', () => {
// Not reachable through Zod, which always attaches at least one issue on failure.
// That is exactly why this lived in twenty handlers as twenty branches no test
// could ever enter; here it is one branch and one test.
expect(schemaMessage({ issues: [] })).toBe('bad input');
expect(schemaMessage({})).toBe('bad input');
expect(schemaMessage({ issues: [{}] })).toBe('bad input');
});
});
/**
* Every schema-validated method whose schema actually rejects something, with such a
* payload. These are the `if (!parsed.success)` branches: each handler has one, and
* without a case per method half of them never run.
*
* The three *update* schemas (places, accommodations, reservations) are missing on
* purpose. They are deliberately permissive, because the migration's rule is parity
* over tightening: a stricter schema would turn requests that work today into 400s.
* Their reject branch is therefore defence in depth that no payload reaches, and
* inventing one would test the test rather than the code.
*/
const SCHEMA_REJECTS: Array<[string, Record<string, unknown>]> = [
['places.create', { tripId: 1, input: { name: 42 } }],
['days.create', { tripId: 1, input: { date: 42 } }],
['days.update', { tripId: 1, dayId: 3, input: { notes: 42 } }],
['packing.create', { tripId: 1, input: { name: 42 } }],
['packing.update', { tripId: 1, itemId: 70, input: { name: 42 } }],
['trips.update', { tripId: 1, input: { title: 42 } }],
['trips.create', { input: { title: 42 } }],
['accommodations.create', { tripId: 1, input: { place_id: 'nope' } }],
['reservations.create', { tripId: 1, input: { title: 42, type: 'lodging' } }],
];
describe('every schema-validated method rejects a payload its schema refuses', () => {
function everything() {
const { guards, db } = guardsFor();
const anything = new Proxy({}, { get: () => vi.fn(() => ({ id: 1, trip: { id: 1 }, updatedTrip: { id: 1 }, reservation: { id: 1 } })) }) as never;
const registry = createTestPluginRegistry([
new PlacesRpc(anything, anything, realtime(), guards),
new DaysRpc(anything, realtime(), guards),
new PackingRpc(anything, realtime(), guards),
new TripsRpc(anything, anything, anything, anything, db, realtime(), guards),
new AccommodationsRpc(anything, realtime(), guards),
new ReservationsRpc(anything, realtime(), guards),
]);
return new PluginRpcHost('p', ALL, makeDeps(), registry);
}
for (const [method, params] of SCHEMA_REJECTS) {
it(`FALLBACK-schema-${method}`, async () => {
const res = await everything().dispatch(req(method, params), 42);
expect(err(res).code).toBe('BAD_PARAMS');
});
}
it('FALLBACK-016 a reservation with malformed endpoints is rejected on update too', async () => {
const res = await everything().dispatch(
req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'lodging', endpoints: [{ junk: 1 }] } }), 42,
);
expect(err(res).code).toBe('BAD_PARAMS');
});
it('FALLBACK-017 an absent vacay note and an absent tag colour both pass undefined', async () => {
const { guards } = guardsFor();
const toggleCompanyHoliday = vi.fn(() => ({}));
const vacay = { getActivePlanId: vi.fn(() => 1), toggleCompanyHoliday, toggleEntry: vi.fn(), getPlanData: vi.fn() } as never;
const update = vi.fn(() => ({ id: 1 }));
const tags = { getByIdAndUser: vi.fn(() => ({ id: 1 })), update } as never;
const host = new PluginRpcHost(
'p',
new Set([...ALL, 'db:write:vacay']),
makeDeps(),
createTestPluginRegistry([new VacayRpc(vacay, guards), new TagsRpc(tags)]),
);
await host.dispatch(req('vacay.toggleCompanyHoliday', { date: '2027-01-01', note: 42 }), 42);
expect(toggleCompanyHoliday).toHaveBeenCalledWith(1, '2027-01-01', undefined, undefined);
await host.dispatch(req('tags.update', { tagId: 1, input: { name: 'kept' } }), 42);
expect(update).toHaveBeenCalledWith(1, 'kept', undefined);
});
});