feat(plugins): extend the output allowlist to child rows and whole-row read models

The output contract cut only the top level of an entity result. A derived
key's value went out untouched, so the children that are whole rows still
carried every column: a day's notes_items are full day_notes entities and
a reservation's endpoints are SELECT e.* on reservation_endpoints. About
fifty methods were also left as readModel and passed through whole, many
of them backed by SELECT * reads (day_accommodations, day_notes, todos,
tags, packing bags, journal entries, collab, collections, vacay plans).
A new column on any of those tables reached every plugin by default,
which is what the contract was meant to stop and what the SDK README and
the wiki already claimed it did.

PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold
rows of another entity, and the shaper cuts those rows down to their own
field list, recursively. dayNote and reservationEndpoint are the child
tables that were whole rows; day.assignments and place.tags are held to
the assignment and tag lists too, although their reads already name
their columns. The remaining derived children (travelers, participants,
ratings, budget members, payers and receipts, packing recipients,
attachments, voters, bag members) are built field by field, which the
contract comment now states as the rule for a derived key without a
nested entity.

The whole-row read models get entity contracts: accommodation, dayNote,
todo, tag, category, packingBag, fileLink, collabNote, collabPoll,
collabMessage, journey, journalEntry, bucketItem and collection, and
vacayPlan with its holiday calendars. collections.listMine,
collections.get and vacay.mine return envelopes around rows; an entity
with a null table describes those, so their rows are reached through the
same nesting. Every list holds exactly the keys each read returned
before, so no plugin loses a field. What stays readModel is built field
by field from named columns, and each such entry says what it returns:
setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the
two vacay toggles and journal.addEntryPhoto.

The schema test now covers every child and new table, and calls each
newly shaped method on the real services, comparing nested rows key for
key so the only field allowed to disappear is a withheld one. A further
case proves the nested rows it compares are not empty. gen:plugin-facts
publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin
wiki pages and server/CLAUDE.md describe the nested rows and list the new
entities.
This commit is contained in:
Maurice
2026-10-08 18:02:39 +02:00
parent 0c9e1cd639
commit 9dec36995e
12 changed files with 973 additions and 192 deletions
@@ -4,24 +4,44 @@
* Two questions, both answered on the migrated schema and the real domain services:
*
* - Does every column of a whole-row table have a decision? A migration that adds a
* column to `trips` fails here until the column is listed as published or withheld,
* which is the point: a new column no longer reaches plugins by default, and nobody
* forgets to decide.
* column to `trips`, or to a child table such as `day_notes` or
* `reservation_endpoints`, fails here until the column is listed as published or
* withheld, which is the point: a new column no longer reaches plugins by default,
* and nobody forgets to decide.
* - Does the contract drop anything a plugin received before it existed? Each entity
* method is called once directly on its handler and its result is put through the
* contract; the only keys allowed to disappear are the withheld credentials. A new
* key a read model starts carrying fails here the same way a new column does.
* contract; the only keys allowed to disappear, at the top or in a nested child row,
* are the withheld credentials. A new key a read model starts carrying fails here
* the same way a new column does.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createPluginRpcHostParts } from '../../helpers/plugin-host';
import { addTripMember, createBudgetItem, createDayAssignment, createPackingItem, createPlace, createReservation, createTrip, createUser } from '../../helpers/factories';
import {
addTripMember,
createBudgetItem,
createCollabNote,
createDayAccommodation,
createDayAssignment,
createDayNote,
createJourney,
createJourneyEntry,
createPackingItem,
createPlace,
createReservation,
createTag,
createTodoItem,
createTrip,
createUser,
linkTripToJourney,
} from '../../helpers/factories';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
import type { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_PERMISSIONS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
PLUGIN_ENTITY_CONTRACT,
PLUGIN_ENTITY_NESTED,
PLUGIN_METHOD_OUTPUT,
shapePluginOutput,
type PluginEntityContract,
@@ -33,41 +53,83 @@ afterAll(() => testDb.close());
const ENTITIES = Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[];
const contractOf = (entity: PluginEntityName): PluginEntityContract => PLUGIN_ENTITY_CONTRACT[entity];
/** The entities read from a table; the envelopes (table null) have no columns to check. */
const TABLE_ENTITIES = ENTITIES.filter((e) => contractOf(e).table !== null);
const tableOf = (entity: PluginEntityName): string => {
const table = contractOf(entity).table;
if (table === null) throw new Error(`${entity} is an envelope, not a table`);
return table;
};
const columnsOf = (table: string): string[] =>
(testDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name);
describe('the output contract against the migrated schema', () => {
it.each(ENTITIES)('OUTCONTRACT-SCHEMA-001 %s names only columns its table has', (entity) => {
it.each(TABLE_ENTITIES)('OUTCONTRACT-SCHEMA-001 %s names only columns its table has', (entity) => {
const contract = contractOf(entity);
const columns = columnsOf(contract.table);
const columns = columnsOf(tableOf(entity));
expect(columns.length).toBeGreaterThan(0);
expect(contract.columns.filter((c) => !columns.includes(c))).toEqual([]);
expect(contract.withheld.filter((c) => !columns.includes(c))).toEqual([]);
});
it.each(ENTITIES.filter((e) => contractOf(e).wholeRow))(
it.each(TABLE_ENTITIES.filter((e) => contractOf(e).wholeRow))(
'OUTCONTRACT-SCHEMA-002 every column of %s is published or withheld',
(entity) => {
const contract = contractOf(entity);
const decided = new Set([...contract.columns, ...contract.withheld]);
// A failure names the new column: add it to `columns` to publish it to plugins,
// or to `withheld` if it is a credential.
expect(columnsOf(contract.table).filter((c) => !decided.has(c))).toEqual([]);
expect(columnsOf(tableOf(entity)).filter((c) => !decided.has(c))).toEqual([]);
},
);
});
/**
* `shaped` is `raw` put through the contract of `entity`: same keys in the same order
* minus the withheld ones, the same values, and every nested child row held to its own
* entity the same way.
*/
function expectOnlyWithheldDropped(entity: PluginEntityName, raw: unknown, shaped: unknown, where: string): void {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
expect(shaped, where).toBe(raw);
return;
}
const rawRow = raw as Record<string, unknown>;
const shapedRow = shaped as Record<string, unknown>;
const withheld: readonly string[] = contractOf(entity).withheld;
expect(Object.keys(shapedRow), where).toEqual(Object.keys(rawRow).filter((k) => !withheld.includes(k)));
const nested: Readonly<Record<string, PluginEntityName>> = PLUGIN_ENTITY_NESTED[entity] ?? {};
for (const key of Object.keys(shapedRow)) {
const child = nested[key];
const rawValue = rawRow[key];
if (!child) {
expect(shapedRow[key], `${where}.${key}`).toBe(rawValue);
} else if (Array.isArray(rawValue)) {
const shapedList = shapedRow[key] as unknown[];
expect(shapedList).toHaveLength(rawValue.length);
rawValue.forEach((item, i) => expectOnlyWithheldDropped(child, item, shapedList[i], `${where}.${key}[${i}]`));
} else {
expectOnlyWithheldDropped(child, rawValue, shapedRow[key], `${where}.${key}`);
}
}
}
describe('the output contract drops only the withheld fields', () => {
let host: PluginRpcHost;
let callHandler: (method: string, params: Record<string, unknown>) => Promise<unknown>;
let ownerId: number;
let memberId: number;
let tripId: number;
let dayId: number;
let dayIds: number[];
let placeId: number;
let journeyId: number;
beforeAll(async () => {
// Collab, journey, atlas, vacay and collections are addons; their methods refuse
// while the addon is off.
testDb.exec('UPDATE addons SET enabled = 1');
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
ownerId = owner.id;
@@ -77,12 +139,23 @@ describe('the output contract drops only the withheld fields', () => {
addTripMember(testDb, trip.id, member.id);
// A real credential in the row, so "withheld" is proven rather than vacuous.
testDb.prepare("UPDATE trips SET feed_token = 'feed-secret' WHERE id = ?").run(trip.id);
dayId = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(trip.id) as { id: number }).id;
dayIds = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as Array<{ id: number }>).map(
(d) => d.id,
);
placeId = createPlace(testDb, trip.id).id;
createDayAssignment(testDb, dayId, placeId);
const tag = createTag(testDb, owner.id);
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(placeId, tag.id);
createDayAssignment(testDb, dayIds[0], placeId);
createDayNote(testDb, dayIds[0], trip.id);
createBudgetItem(testDb, trip.id);
createPackingItem(testDb, trip.id);
createReservation(testDb, trip.id, { day_id: dayId });
createReservation(testDb, trip.id, { day_id: dayIds[0] });
createTodoItem(testDb, trip.id);
createCollabNote(testDb, trip.id, owner.id);
createDayAccommodation(testDb, trip.id, placeId, dayIds[0], dayIds[1]);
journeyId = createJourney(testDb, owner.id).id;
linkTripToJourney(testDb, journeyId, trip.id);
createJourneyEntry(testDb, journeyId, owner.id, { title: 'Arrival' });
const parts = await createPluginRpcHostParts(testDb);
host = parts.factory.create('contract', new Set<string>(KNOWN_PERMISSIONS), {
@@ -108,64 +181,129 @@ describe('the output contract drops only the withheld fields', () => {
};
});
/** The keys the contract may remove from `raw`: the entity's withheld columns. */
const expectOnlyWithheldDropped = (method: string, raw: unknown) => {
/** Calls `method` on its handler and holds its result to the method's contract. */
const callAndCheck = async (method: string, params: Record<string, unknown>): Promise<unknown> => {
const raw = await callHandler(method, params);
const output = PLUGIN_METHOD_OUTPUT[method as keyof typeof PLUGIN_METHOD_OUTPUT];
if (output.kind !== 'entity') throw new Error(`${method} is not an entity method`);
const withheld: readonly string[] = contractOf(output.entity).withheld;
const rawRows = (output.many ? raw : [raw]) as Array<Record<string, unknown>>;
const shapedRows = (output.many ? shapePluginOutput(method, raw) : [shapePluginOutput(method, raw)]) as Array<
Record<string, unknown>
>;
expect(rawRows.length).toBeGreaterThan(0);
rawRows.forEach((row, i) => {
// Same keys, same order, same values: only a withheld key may go.
expect(Object.keys(shapedRows[i])).toEqual(Object.keys(row).filter((k) => !withheld.includes(k)));
for (const key of Object.keys(shapedRows[i])) expect(shapedRows[i][key]).toBe(row[key]);
});
const shaped = shapePluginOutput(method, raw);
if (output.many) {
expect(Array.isArray(raw), method).toBe(true);
const rawRows = raw as unknown[];
expect(rawRows.length, method).toBeGreaterThan(0);
rawRows.forEach((item, i) => expectOnlyWithheldDropped(output.entity, item, (shaped as unknown[])[i], `${method}[${i}]`));
} else {
expect(raw, method).toBeTruthy();
expectOnlyWithheldDropped(output.entity, raw, shaped, method);
}
return raw;
};
const idOf = (value: unknown): number => (value as { id: number }).id;
it('OUTCONTRACT-001 the writes return every field they returned before, minus credentials', async () => {
const write = async (method: string, params: Record<string, unknown>) => {
const raw = await callHandler(method, params);
expectOnlyWithheldDropped(method, raw);
return raw as { id: number };
};
await write('trips.update', { tripId, input: { title: 'Renamed' } });
await write('trips.create', { input: { title: 'Another trip' } });
const place = await write('places.create', { tripId, input: { name: 'Museum', lat: 1, lng: 2 } });
await write('places.update', { tripId, placeId: place.id, input: { name: 'Museum of Art' } });
const day = await write('days.create', { tripId, input: { notes: 'spare day' } });
await write('days.update', { tripId, dayId: day.id, input: { title: 'Spare' } });
await write('days.create', { tripId, input: { dated: true } });
await write('itinerary.assign', { tripId, dayId, placeId: place.id });
const reservation = await write('reservations.create', { tripId, input: { title: 'Flight', type: 'flight' } });
await write('reservations.update', { tripId, reservationId: reservation.id, input: { title: 'Flight home' } });
const item = await write('packing.create', { tripId, input: { name: 'Socks' } });
await write('packing.update', { tripId, itemId: item.id, input: { name: 'Wool socks' } });
await callAndCheck('trips.update', { tripId, input: { title: 'Renamed' } });
await callAndCheck('trips.create', { input: { title: 'Another trip' } });
const place = idOf(await callAndCheck('places.create', { tripId, input: { name: 'Museum', lat: 1, lng: 2 } }));
await callAndCheck('places.update', { tripId, placeId: place, input: { name: 'Museum of Art' } });
await callAndCheck('places.update', { tripId, placeId, input: { name: 'Tagged place' } });
const day = idOf(await callAndCheck('days.create', { tripId, input: { notes: 'spare day' } }));
await callAndCheck('days.update', { tripId, dayId: day, input: { title: 'Spare' } });
await callAndCheck('days.update', { tripId, dayId: dayIds[0], input: { title: 'Arrival' } });
await callAndCheck('days.create', { tripId, input: { dated: true } });
await callAndCheck('itinerary.assign', { tripId, dayId: dayIds[1], placeId: place });
const reservation = idOf(
await callAndCheck('reservations.create', {
tripId,
input: {
title: 'Flight',
type: 'flight',
endpoints: [
{ role: 'from', name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.56 },
{ role: 'to', name: 'Rome', code: 'FCO', lat: 41.8, lng: 12.25 },
],
},
}),
);
await callAndCheck('reservations.update', { tripId, reservationId: reservation, input: { title: 'Flight home' } });
const accommodation = idOf(
await callAndCheck('accommodations.create', {
tripId,
input: { place_id: placeId, start_day_id: dayIds[1], end_day_id: dayIds[2] },
}),
);
await callAndCheck('accommodations.update', { tripId, accommodationId: accommodation, input: { notes: 'Late check-in' } });
const bag = idOf(await callAndCheck('packing.createBag', { tripId, input: { name: 'Backpack' } }));
await callAndCheck('packing.updateBag', { tripId, bagId: bag, input: { name: 'Big backpack' } });
const item = idOf(await callAndCheck('packing.create', { tripId, input: { name: 'Socks' } }));
await callAndCheck('packing.update', { tripId, itemId: item, input: { name: 'Wool socks' } });
const content = Buffer.from('boarding pass').toString('base64');
const file = await write('files.create', { tripId, input: { name: 'pass.txt', content_base64: content, mimetype: 'text/plain' } });
await write('files.update', { tripId, fileId: file.id, input: { description: 'Boarding pass' } });
const cost = await write('costs.create', { tripId, input: { name: 'Dinner', total_price: 10 } });
await write('costs.update', { tripId, itemId: cost.id, input: { name: 'Lunch' } });
const file = idOf(
await callAndCheck('files.create', { tripId, input: { name: 'pass.txt', content_base64: content, mimetype: 'text/plain' } }),
);
await callAndCheck('files.update', { tripId, fileId: file, input: { description: 'Boarding pass' } });
await callAndCheck('files.createLink', { tripId, fileId: file, opts: { reservation_id: reservation } });
const cost = idOf(await callAndCheck('costs.create', { tripId, input: { name: 'Dinner', total_price: 10 } }));
await callAndCheck('costs.update', { tripId, itemId: cost, input: { name: 'Lunch' } });
const note = idOf(await callAndCheck('daynotes.create', { tripId, dayId: dayIds[0], input: { text: 'Breakfast' } }));
await callAndCheck('daynotes.update', { tripId, dayId: dayIds[0], noteId: note, input: { text: 'Late breakfast' } });
const todo = idOf(await callAndCheck('todos.create', { tripId, input: { name: 'Book train' } }));
await callAndCheck('todos.update', { tripId, todoId: todo, input: { name: 'Book the train' } });
const tag = idOf(await callAndCheck('tags.create', { input: { name: 'Food' } }));
await callAndCheck('tags.update', { tagId: tag, input: { name: 'Good food' } });
await callAndCheck('collab.createNote', { tripId, input: { title: 'Ideas' } });
const poll = idOf(await callAndCheck('collab.createPoll', { tripId, input: { question: 'Where?', options: ['Rome', 'Milan'] } }));
await callAndCheck('collab.votePoll', { tripId, pollId: poll, optionIndex: 0 });
const message = idOf(await callAndCheck('collab.createMessage', { tripId, text: 'Hello' }));
await callAndCheck('collab.createMessage', { tripId, text: 'Reply', replyTo: message });
await callAndCheck('journal.createJourney', { input: { title: 'Italy' } });
const entry = idOf(await callAndCheck('journal.createEntry', { journeyId, input: { title: 'Day one', entry_date: '2026-05-01' } }));
await callAndCheck('journal.updateEntry', { entryId: entry, input: { story: 'We arrived.' } });
await callAndCheck('atlas.createBucketItem', { input: { name: 'Lisbon' } });
const collection = idOf(await callAndCheck('collections.create', { input: { name: 'Rome' } }));
await callAndCheck('collections.update', { id: collection, input: { name: 'Rome again' } });
});
it('OUTCONTRACT-002 the reads return every field they returned before, minus credentials', async () => {
const collection = idOf(await callHandler('collections.create', { input: { name: 'Read me' } }));
const reads: Array<[string, Record<string, unknown>]> = [
['trips.getById', { tripId }],
['trips.listMine', {}],
['trips.getPlaces', { tripId }],
['trips.getDays', { tripId }],
['trips.getReservations', { tripId }],
['trips.getAccommodations', { tripId }],
['reservations.listMine', {}],
['trips.members', { tripId }],
['users.getById', { id: memberId }],
['packing.list', { tripId }],
['packing.listBags', { tripId }],
['files.list', { tripId }],
['costs.getByTrip', { tripId }],
['costs.listMine', {}],
['daynotes.list', { tripId, dayId: dayIds[0] }],
['todos.list', { tripId }],
['tags.list', {}],
['categories.list', {}],
['collab.listNotes', { tripId }],
['collab.listPolls', { tripId }],
['collab.listMessages', { tripId }],
['journal.listMine', {}],
['journal.getEntries', { journeyId }],
['atlas.bucketList', {}],
['collections.listMine', {}],
['collections.get', { id: collection }],
['vacay.mine', {}],
];
for (const [method, params] of reads) expectOnlyWithheldDropped(method, await callHandler(method, params));
for (const [method, params] of reads) await callAndCheck(method, params);
});
it('OUTCONTRACT-004 the nested child rows are exercised, not vacuous', async () => {
const days = (await callHandler('trips.getDays', { tripId })) as Array<{ notes_items: unknown[]; assignments: unknown[] }>;
expect(days.some((d) => d.notes_items.length > 0)).toBe(true);
expect(days.some((d) => d.assignments.length > 0)).toBe(true);
const reservations = (await callHandler('trips.getReservations', { tripId })) as Array<{ endpoints: unknown[] }>;
expect(reservations.some((r) => r.endpoints.length > 0)).toBe(true);
});
it('OUTCONTRACT-003 through the router, a trip never carries its feed token', async () => {
@@ -11,6 +11,7 @@ import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { KNOWN_METHODS, UNCONDITIONAL_METHODS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import {
PLUGIN_ENTITY_CONTRACT,
PLUGIN_ENTITY_NESTED,
PLUGIN_METHOD_OUTPUT,
pluginEntityFields,
returnsEntity,
@@ -34,6 +35,28 @@ describe('the contract table', () => {
}
});
it.each(Object.entries(PLUGIN_ENTITY_NESTED))(
'OUTCONTRACT-UNIT-012 %s nests defined entities under its own derived keys',
(entity, nested) => {
const derived: readonly string[] = contractOf(entity as PluginEntityName).derived;
for (const [key, child] of Object.entries(nested ?? {})) {
expect(derived).toContain(key);
expect(ENTITIES).toContain(child);
}
},
);
it.each(ENTITIES.filter((e) => contractOf(e).table === null))(
'OUTCONTRACT-UNIT-013 the envelope %s has no columns of its own',
(entity) => {
const contract = contractOf(entity);
expect(contract.columns).toEqual([]);
expect(contract.withheld).toEqual([]);
expect(contract.wholeRow).toBe(false);
expect(contract.derived.length).toBeGreaterThan(0);
},
);
it.each(ENTITIES)('OUTCONTRACT-UNIT-003 %s lists each field once and never publishes a withheld column', (entity) => {
const contract = contractOf(entity);
const fields = pluginEntityFields(entity);
@@ -50,8 +73,9 @@ describe('the contract table', () => {
it('OUTCONTRACT-UNIT-005 returnsEntity tells the entity methods from the rest', () => {
expect(returnsEntity('trips.getById')).toBe(true);
expect(returnsEntity('costs.listMine')).toBe(true);
expect(returnsEntity('tags.list')).toBe(true);
expect(returnsEntity('db.query')).toBe(false);
expect(returnsEntity('tags.list')).toBe(false);
expect(returnsEntity('atlas.visited')).toBe(false);
expect(returnsEntity('no.such.method')).toBe(false);
});
});
@@ -71,17 +95,51 @@ describe('shapePluginOutput', () => {
expect(Object.keys(shaped as object)).toEqual(['title', 'id', 'day_count']);
});
it('OUTCONTRACT-UNIT-007 shapes every row of a list and leaves nested values untouched', () => {
const assignments = [{ id: 1, place: { id: 2, anything: true } }];
it('OUTCONTRACT-UNIT-007 shapes every row of a list and the child rows it carries', () => {
const place = { id: 2, anything: true };
const shaped = shapePluginOutput('trips.getDays', [
{ id: 1, date: '2026-05-01', assignments, secret_new: 1 },
{
id: 1,
date: '2026-05-01',
assignments: [{ id: 1, place, assignment_new_column: 1 }],
notes_items: [{ id: 5, text: 'Breakfast', day_note_new_column: 'x' }],
secret_new: 1,
},
{ id: 2, date: '2026-05-02', notes_items: [] },
]) as Array<Record<string, unknown>>;
expect(shaped).toEqual([
{ id: 1, date: '2026-05-01', assignments },
{ id: 1, date: '2026-05-01', assignments: [{ id: 1, place }], notes_items: [{ id: 5, text: 'Breakfast' }] },
{ id: 2, date: '2026-05-02', notes_items: [] },
]);
expect(shaped[0].assignments).toBe(assignments);
// A derived value without a nested entity (the assignment's place, built field by
// field) is passed on as the same object.
expect((shaped[0].assignments as Array<Record<string, unknown>>)[0].place).toBe(place);
});
it('OUTCONTRACT-UNIT-014 a child row is cut down however deep it sits', () => {
const shaped = shapePluginOutput('vacay.mine', {
plan: { id: 1, owner_id: 2, plan_new_column: 1, holiday_calendars: [{ id: 3, label: 'DE', calendar_new_column: 1 }] },
users: [{ id: 2, username: 'ana', color: '#fff' }],
isOwner: true,
envelope_new_key: 1,
});
expect(shaped).toEqual({
plan: { id: 1, owner_id: 2, holiday_calendars: [{ id: 3, label: 'DE' }] },
users: [{ id: 2, username: 'ana', color: '#fff' }],
isOwner: true,
});
});
it('OUTCONTRACT-UNIT-015 a single child row is shaped and a missing one keeps its form', () => {
expect(shapePluginOutput('collections.get', { collection: { id: 1, name: 'Rome', new_column: 1 }, places: [] })).toEqual({
collection: { id: 1, name: 'Rome' },
places: [],
});
expect(shapePluginOutput('collections.get', { collection: null, places: [] })).toEqual({ collection: null, places: [] });
expect(shapePluginOutput('trips.getReservations', [{ id: 1, endpoints: null }])).toEqual([{ id: 1, endpoints: null }]);
expect(shapePluginOutput('trips.getReservations', [{ id: 1, endpoints: [{ id: 2, code: 'FRA', secret: 1 }, 7] }])).toEqual([
{ id: 1, endpoints: [{ id: 2, code: 'FRA' }, 7] },
]);
});
it('OUTCONTRACT-UNIT-008 a missing row keeps its wire form', () => {
@@ -97,9 +155,9 @@ describe('shapePluginOutput', () => {
it('OUTCONTRACT-UNIT-009 a host or read-model result is returned as the same object', () => {
const own = [{ anything: 1 }];
expect(shapePluginOutput('db.query', own)).toBe(own);
const tags = [{ id: 1, name: 'work', brand_new_column: true }];
expect(shapePluginOutput('tags.list', tags)).toBe(tags);
expect(shapePluginOutput('no.such.method', tags)).toBe(tags);
const visited = { countries: [{ country_code: 'DE', brand_new_column: true }], regions: [] };
expect(shapePluginOutput('atlas.visited', visited)).toBe(visited);
expect(shapePluginOutput('no.such.method', visited)).toBe(visited);
});
});
@@ -111,9 +169,9 @@ class ProbeRpc {
return { id: 1, title: 'Japan', feed_token: 'secret', added_later: ctx.actingUserId };
}
@PluginMethod('tags.list', { permission: 'db:read:tags' })
tags() {
return [{ id: 1, name: 'work', added_later: true }];
@PluginMethod('atlas.visited', { permission: 'db:read:atlas' })
visited() {
return { countries: [{ country_code: 'DE', added_later: true }], regions: [] };
}
}
@@ -130,6 +188,9 @@ describe('the router applies the contract', () => {
});
it('OUTCONTRACT-UNIT-011 a read-model result reaches the plugin with every key', async () => {
expect(await dispatch('tags.list', 'db:read:tags')).toEqual([{ id: 1, name: 'work', added_later: true }]);
expect(await dispatch('atlas.visited', 'db:read:atlas')).toEqual({
countries: [{ country_code: 'DE', added_later: true }],
regions: [],
});
});
});