mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
1112 lines
42 KiB
TypeScript
1112 lines
42 KiB
TypeScript
import { BucketList } from '../../../src/db/entities/BucketList.entity';
|
|
import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity';
|
|
import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity';
|
|
import { JourneyBooks } from '../../../src/db/entities/JourneyBooks.entity';
|
|
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
|
import { TripJournalLinks } from '../../../src/db/entities/TripJournalLinks.entity';
|
|
import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity';
|
|
import { VacayShares } from '../../../src/db/entities/VacayShares.entity';
|
|
import { VacayYears } from '../../../src/db/entities/VacayYears.entity';
|
|
import { withRequestContext } from '../../../src/nest/database/request-context';
|
|
import { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { JourneyRecordedDaysService } from '../../../src/nest/journey/journey-recorded-days.service';
|
|
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
|
|
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
|
import { addCollectionMember } from '../../helpers/factories/collections';
|
|
import { inContext } from '../../helpers/factories/context';
|
|
import { addJourneyContributor, linkTripToJourney } from '../../helpers/factories/journeys';
|
|
import { createRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { addVacayPlanMember } from '../../helpers/factories/vacay';
|
|
import { holidayCode } from './harness';
|
|
import type { CaseGroup, ParityCase, RestCall, Scene } from './types';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { blankBook, blankSpread, type BookDocument, type DawarichTrackSegment } from '@trek/shared';
|
|
|
|
import { vi } from 'vitest';
|
|
|
|
/**
|
|
* A user's own addons: journey, atlas, collections, vacay, dawarich and the
|
|
* school holiday catalog. Their events go to users rather than to a trip room,
|
|
* and every write reaches the writer's own tabs: REST leaves out only the tab
|
|
* that asked, MCP none, so the tab the assistant runs in shows the change.
|
|
*/
|
|
const HOLIDAY_CATALOG = "the admin's school holiday catalog is global: no room, and the registry has no event for it";
|
|
const TOKEN_ONLY = 'manages a public link token: nothing on any screen follows it live, so there is nothing to send';
|
|
const onJourney = (s: Scene) => ({ journeyId: s.journey.id });
|
|
const WINTER = { name: 'Winter', startDate: '2027-02-01', endDate: '2027-02-12' };
|
|
|
|
/** The scene's first day, which the Dawarich stays below fall on. */
|
|
const DAY = '2027-05-03';
|
|
const VACAY = '/api/addons/vacay';
|
|
const ATLAS = '/api/addons/atlas';
|
|
const COLLECTIONS = '/api/addons/collections';
|
|
const DAWARICH = '/api/integrations/dawarich';
|
|
|
|
const ormOf = (app: INestApplication) => app.get(MikroORM);
|
|
|
|
/**
|
|
* Rows a case's setup makes beyond the scene's needs, by scene, so its
|
|
* arguments and REST paths can point at them. Each scene makes its own, so
|
|
* the MCP run and the REST run never share one.
|
|
*/
|
|
const made = new Map<string, number>();
|
|
const keep = (s: Scene, key: string, id: number) => made.set(`${s.n}:${key}`, id);
|
|
function kept(s: Scene, key: string): number {
|
|
const id = made.get(`${s.n}:${key}`);
|
|
if (id === undefined) throw new Error(`the setup of scene ${s.n} made no ${key}`);
|
|
return id;
|
|
}
|
|
|
|
/** A stay Dawarich recorded for the owner on the scene's first day, waiting for review. */
|
|
async function recordStay(s: Scene, app: INestApplication): Promise<void> {
|
|
const row = await createRow(ormOf(app), DawarichVisitSuggestions, {
|
|
user: s.owner.id,
|
|
source_visit_id: `visit-${s.n}`,
|
|
trip: s.trip.id,
|
|
name: `Café de Flore ${s.n}`,
|
|
lat: 48.854,
|
|
lng: 2.3326,
|
|
started_at: `${DAY}T10:00:00+02:00`,
|
|
ended_at: `${DAY}T11:30:00+02:00`,
|
|
duration_minutes: 90,
|
|
local_date: DAY,
|
|
source_hash: `stay-${s.n}`,
|
|
});
|
|
keep(s, 'stay', row.id);
|
|
}
|
|
|
|
/**
|
|
* The in-app notification an invite or a share sends the other user runs
|
|
* detached, so it would land in whichever case comes next. Both surfaces
|
|
* call the same service method for it; the cases here are about the domain
|
|
* events, so the notification is held back while they run.
|
|
*/
|
|
function quietNotifications(app: INestApplication): () => void {
|
|
const spy = vi.spyOn(app.get(NotificationsService), 'send').mockResolvedValue({ attempted: 0, delivered: 0 });
|
|
return () => spy.mockRestore();
|
|
}
|
|
|
|
/** One recorded day as Dawarich would answer it, so no instance is asked. */
|
|
const SEGMENTS: DawarichTrackSegment[] = [
|
|
{
|
|
points: [
|
|
[48.8566, 2.3522],
|
|
[48.8606, 2.3376],
|
|
],
|
|
mode: 'walking',
|
|
startedAt: `${DAY}T09:00:00Z`,
|
|
endedAt: `${DAY}T09:40:00Z`,
|
|
distanceMeters: 1200,
|
|
},
|
|
];
|
|
const DAY_WINDOW = { from: `${DAY}T00:00:00Z`, to: `${DAY}T23:59:59Z` };
|
|
/** The hash the preview would have shown for SEGMENTS, worked out by the service in the stub. */
|
|
let recordedHash = '';
|
|
|
|
const atlasCases: ParityCase[] = [
|
|
{
|
|
tool: 'create_bucket_list_item',
|
|
args: (s) => ({ name: `Lisbon ${s.n}` }),
|
|
rest: { method: 'post', path: () => `${ATLAS}/bucket-list`, body: (s) => ({ name: `Lisbon ${s.n}` }) },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_bucket_list_item',
|
|
needs: ['bucket'],
|
|
args: (s) => ({ itemId: s.bucket.id, notes: 'In spring' }),
|
|
rest: { method: 'put', path: (s) => `${ATLAS}/bucket-list/${s.bucket.id}`, body: () => ({ notes: 'In spring' }) },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_bucket_list_item',
|
|
needs: ['bucket'],
|
|
args: (s) => ({ itemId: s.bucket.id }),
|
|
rest: { method: 'delete', path: (s) => `${ATLAS}/bucket-list/${s.bucket.id}` },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'mark_country_visited',
|
|
args: () => ({ country_code: 'PT' }),
|
|
rest: { method: 'post', path: () => `${ATLAS}/country/PT/mark` },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'unmark_country_visited',
|
|
args: () => ({ country_code: 'PT' }),
|
|
rest: { method: 'delete', path: () => `${ATLAS}/country/PT/mark` },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'mark_region_visited',
|
|
args: () => ({ regionCode: 'PT-11', regionName: 'Lisboa', countryCode: 'PT' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${ATLAS}/region/PT-11/mark`,
|
|
body: () => ({ name: 'Lisboa', country_code: 'PT' }),
|
|
},
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'unmark_region_visited',
|
|
args: () => ({ regionCode: 'PT-11' }),
|
|
rest: { method: 'delete', path: () => `${ATLAS}/region/PT-11/mark` },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
];
|
|
|
|
const dawarichCases: ParityCase[] = [
|
|
{
|
|
// The planner's way: once the stop is on a day, the journey the trip is
|
|
// linked to gains its skeleton and hears about it (G7).
|
|
tool: 'accept_dawarich_suggestion_as_place',
|
|
needs: ['journey'],
|
|
setup: async (s, app) => {
|
|
await recordStay(s, app);
|
|
await linkTripToJourney(ormOf(app), s.journey.id, s.trip.id);
|
|
},
|
|
args: (s) => ({ suggestionId: kept(s, 'stay'), tripId: s.trip.id, dayId: s.days[0].id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`,
|
|
body: (s) => ({ target: 'place', tripId: s.trip.id, dayId: s.days[0].id }),
|
|
},
|
|
expect: ['place:created', 'assignment:created', { event: 'journey:trip:synced', user: 'actor' }],
|
|
},
|
|
{
|
|
// Announced after the commit, no longer from inside the transaction (G35).
|
|
tool: 'accept_dawarich_suggestion_as_journal_entry',
|
|
needs: ['journey'],
|
|
setup: recordStay,
|
|
args: (s) => ({ suggestionId: kept(s, 'stay'), journalId: s.journey.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`,
|
|
body: (s) => ({ target: 'journal', journalId: s.journey.id }),
|
|
},
|
|
expect: [{ event: 'journey:entry:created', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'mark_bucket_list_item_visited_from_dawarich',
|
|
needs: ['bucket'],
|
|
setup: recordStay,
|
|
args: (s) => ({ suggestionId: kept(s, 'stay'), bucketListItemId: s.bucket.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`,
|
|
body: (s) => ({ target: 'bucket_list', bucketListItemId: s.bucket.id }),
|
|
},
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'confirm_bucket_list_visits',
|
|
needs: ['bucket'],
|
|
args: (s) => ({ itemIds: [s.bucket.id] }),
|
|
rest: { method: 'post', path: () => `${DAWARICH}/bucket-list/confirm`, body: (s) => ({ itemIds: [s.bucket.id] }) },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'clear_bucket_list_visit',
|
|
needs: ['bucket'],
|
|
setup: async (s, app) => {
|
|
await updateRows(ormOf(app), BucketList, { id: s.bucket.id }, { visited_at: `${DAY}T10:00:00Z` });
|
|
},
|
|
args: (s) => ({ itemId: s.bucket.id }),
|
|
rest: { method: 'delete', path: (s) => `${DAWARICH}/bucket-list/${s.bucket.id}/visit` },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'accept_atlas_countries',
|
|
args: () => ({ countryCodes: ['PT'] }),
|
|
rest: { method: 'post', path: () => `${DAWARICH}/atlas/accept`, body: () => ({ countryCodes: ['PT'] }) },
|
|
expect: [{ event: 'atlas:changed', user: 'actor' }],
|
|
},
|
|
];
|
|
|
|
const journeyCases: ParityCase[] = [
|
|
{
|
|
tool: 'create_journey',
|
|
args: (s) => ({ title: `Portugal ${s.n}` }),
|
|
rest: { method: 'post', path: () => '/api/journeys', body: (s) => ({ title: `Portugal ${s.n}` }) },
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_journey',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, title: `Algarve ${s.n}` }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `/api/journeys/${s.journey.id}`,
|
|
body: (s) => ({ title: `Algarve ${s.n}` }),
|
|
},
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
// Was journey:entry:updated with a payload the registry refuses.
|
|
tool: 'restore_journey_suggestions',
|
|
needs: ['journey'],
|
|
setup: async (s, app) => {
|
|
await updateRows(ormOf(app), JourneyEntries, { id: s.journeyEntry.id }, { dismissed: 1 });
|
|
},
|
|
args: onJourney,
|
|
rest: { method: 'post', path: (s) => `/api/journeys/${s.journey.id}/suggestions/restore` },
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_journey',
|
|
needs: ['journey'],
|
|
args: onJourney,
|
|
rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}` },
|
|
expect: [{ event: 'journey:deleted', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_journey_trip',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, tripId: s.trip.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `/api/journeys/${s.journey.id}/trips`,
|
|
body: (s) => ({ trip_id: s.trip.id }),
|
|
},
|
|
// The route has never read X-Socket-Id here; the asking tab reloads on its own echo.
|
|
expect: [{ event: 'journey:trip:synced', user: 'actor', all: true }],
|
|
},
|
|
{
|
|
tool: 'remove_journey_trip',
|
|
needs: ['journey'],
|
|
setup: async (s, app) => {
|
|
await linkTripToJourney(ormOf(app), s.journey.id, s.trip.id);
|
|
},
|
|
args: (s) => ({ journeyId: s.journey.id, tripId: s.trip.id }),
|
|
rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/trips/${s.trip.id}` },
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'create_journey_entry',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, entry_date: DAY, title: 'Arrival' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `/api/journeys/${s.journey.id}/entries`,
|
|
body: () => ({ entry_date: DAY, title: 'Arrival' }),
|
|
},
|
|
expect: [{ event: 'journey:entry:created', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_journey_entry',
|
|
needs: ['journey'],
|
|
args: (s) => ({ entryId: s.journeyEntry.id, title: 'Sunset at the river' }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `/api/journeys/entries/${s.journeyEntry.id}`,
|
|
body: () => ({ title: 'Sunset at the river' }),
|
|
},
|
|
expect: [{ event: 'journey:entry:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_journey_entry',
|
|
needs: ['journey'],
|
|
args: (s) => ({ entryId: s.journeyEntry.id }),
|
|
rest: { method: 'delete', path: (s) => `/api/journeys/entries/${s.journeyEntry.id}` },
|
|
expect: [{ event: 'journey:entry:deleted', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'reorder_journey_entries',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, orderedIds: [s.journeyEntry.id] }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `/api/journeys/${s.journey.id}/entries/reorder`,
|
|
body: (s) => ({ orderedIds: [s.journeyEntry.id] }),
|
|
},
|
|
expect: [{ event: 'journey:entries:reordered', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'reorder_journey_entry_photos',
|
|
needs: ['journey'],
|
|
setup: async (s, app) => {
|
|
const photo = await withRequestContext(ormOf(app), () =>
|
|
app.get(JourneyDomainService).addProviderPhoto(s.journeyEntry.id, s.owner.id, 'immich', `asset-${s.n}`),
|
|
);
|
|
if (!photo) throw new Error('the entry took no photo');
|
|
keep(s, 'photo', photo.id);
|
|
},
|
|
args: (s) => ({ entryId: s.journeyEntry.id, orderedIds: [kept(s, 'photo')] }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `/api/journeys/entries/${s.journeyEntry.id}/photos/reorder`,
|
|
body: (s) => ({ orderedIds: [kept(s, 'photo')] }),
|
|
},
|
|
expect: [{ event: 'journey:entry:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_journey_contributor',
|
|
needs: ['journey', 'member'],
|
|
args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id, role: 'viewer' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `/api/journeys/${s.journey.id}/contributors`,
|
|
body: (s) => ({ user_id: s.member.id, role: 'viewer' }),
|
|
},
|
|
// To the owner and the new contributor alike; the contributor routes read no X-Socket-Id.
|
|
expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }],
|
|
},
|
|
{
|
|
tool: 'update_journey_contributor_role',
|
|
needs: ['journey', 'member'],
|
|
setup: async (s, app) => {
|
|
await addJourneyContributor(ormOf(app), s.journey.id, s.member.id, 'viewer');
|
|
},
|
|
args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id, role: 'editor' }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `/api/journeys/${s.journey.id}/contributors/${s.member.id}`,
|
|
body: () => ({ role: 'editor' }),
|
|
},
|
|
expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }],
|
|
},
|
|
{
|
|
// The one taken off is told too, from the audience read before the delete.
|
|
tool: 'remove_journey_contributor',
|
|
needs: ['journey', 'member'],
|
|
setup: async (s, app) => {
|
|
await addJourneyContributor(ormOf(app), s.journey.id, s.member.id, 'viewer');
|
|
},
|
|
args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id }),
|
|
rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/contributors/${s.member.id}` },
|
|
expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }],
|
|
},
|
|
{
|
|
// A preference of the writer's own: only their tabs read the journey again.
|
|
tool: 'update_journey_preferences',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, hide_skeletons: true }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `/api/journeys/${s.journey.id}/preferences`,
|
|
body: () => ({ hide_skeletons: true }),
|
|
},
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_journey_provider_photos',
|
|
needs: ['journey'],
|
|
args: (s) => ({ journeyId: s.journey.id, provider: 'immich', asset_ids: [`asset-${s.n}`] }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `/api/journeys/${s.journey.id}/gallery/provider-photos`,
|
|
body: (s) => ({ provider: 'immich', asset_ids: [`asset-${s.n}`] }),
|
|
},
|
|
// The capture times would be asked of the library, detached: it learns nothing here.
|
|
stub: (app) => {
|
|
const spy = vi.spyOn(app.get(PhotoCaptureBackfillService), 'run').mockResolvedValue(false);
|
|
return () => spy.mockRestore();
|
|
},
|
|
settle: true,
|
|
expect: [{ event: 'journey:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'link_trip_journal',
|
|
needs: ['journey'],
|
|
args: (s) => ({ tripId: s.trip.id, journey_id: s.journey.id, members: [] }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `/api/trips/${s.trip.id}/journal`,
|
|
body: (s) => ({ journey_id: s.journey.id, members: [] }),
|
|
},
|
|
expect: ['trip:journal:changed'],
|
|
},
|
|
{
|
|
tool: 'set_trip_journal_members',
|
|
needs: ['journey'],
|
|
setup: linkJournal,
|
|
args: (s) => ({ tripId: s.trip.id, members: [] }),
|
|
rest: { method: 'patch', path: (s) => `/api/trips/${s.trip.id}/journal/members`, body: () => ({ members: [] }) },
|
|
expect: ['trip:journal:changed'],
|
|
},
|
|
{
|
|
tool: 'unlink_trip_journal',
|
|
needs: ['journey'],
|
|
setup: linkJournal,
|
|
args: (s) => ({ tripId: s.trip.id }),
|
|
rest: { method: 'delete', path: (s) => `/api/trips/${s.trip.id}/journal` },
|
|
expect: ['trip:journal:changed'],
|
|
},
|
|
{
|
|
tool: 'take_over_journey_day_stays',
|
|
needs: ['journey'],
|
|
setup: recordStay,
|
|
args: (s) => ({
|
|
journeyId: s.journey.id,
|
|
date: DAY,
|
|
groups: [{ kind: 'single', stays: [{ suggestionId: kept(s, 'stay') }] }],
|
|
}),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}/stays/import`,
|
|
body: (s) => ({ groups: [{ kind: 'single', stays: [{ suggestionId: kept(s, 'stay') }] }] }),
|
|
},
|
|
expect: [{ event: 'journey:entry:created', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'save_journey_recorded_day',
|
|
needs: ['journey'],
|
|
stub: (app) => {
|
|
recordedHash = app.get(JourneyRecordedDaysService).hashOf(SEGMENTS, 0);
|
|
const spy = vi.spyOn(app.get(DawarichTracksService), 'fetchDay').mockResolvedValue({
|
|
segments: SEGMENTS,
|
|
sourceKind: 'tracks',
|
|
utcOffsetMinutes: 0,
|
|
offsetSource: 'chosen',
|
|
sourceOffsetMinutes: null,
|
|
window: DAY_WINDOW,
|
|
fetchedAt: `${DAY}T23:59:59Z`,
|
|
truncated: false,
|
|
});
|
|
return () => spy.mockRestore();
|
|
},
|
|
args: (s) => ({ journeyId: s.journey.id, date: DAY, utcOffsetMinutes: 0, expectedHash: recordedHash }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}`,
|
|
body: () => ({ utcOffsetMinutes: 0, expectedHash: recordedHash }),
|
|
},
|
|
expect: [{ event: 'journey:recording:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_journey_recorded_day',
|
|
needs: ['journey'],
|
|
setup: async (s, app) => {
|
|
const day = {
|
|
date: DAY,
|
|
sourceKind: 'tracks' as const,
|
|
utcOffsetMinutes: 0,
|
|
window: DAY_WINDOW,
|
|
segments: SEGMENTS,
|
|
truncated: false,
|
|
fetchedAt: `${DAY}T23:59:59Z`,
|
|
contentHash: `${'0'.repeat(63)}${s.n % 10}`,
|
|
};
|
|
await withRequestContext(ormOf(app), () =>
|
|
app.get(JourneyRecordedDaysService).keep(s.journey.id, s.owner.id, day, false),
|
|
);
|
|
},
|
|
args: (s) => ({ journeyId: s.journey.id, date: DAY }),
|
|
rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}` },
|
|
expect: [{ event: 'journey:recording:updated', user: 'actor' }],
|
|
},
|
|
];
|
|
|
|
/** The scene's journey shown in its trip, as the link route writes it, without the event the route sends. */
|
|
async function linkJournal(s: Scene, app: INestApplication): Promise<void> {
|
|
await inContext(ormOf(app), (em) =>
|
|
em.getRepository(TripJournalLinks).upsertLink(s.trip.id, s.journey.id, s.owner.id, Date.now()),
|
|
);
|
|
}
|
|
|
|
const vacayCases: ParityCase[] = [
|
|
{
|
|
tool: 'update_vacay_plan',
|
|
needs: ['vacay'],
|
|
args: () => ({ block_weekends: false }),
|
|
rest: { method: 'put', path: () => `${VACAY}/plan`, body: () => ({ block_weekends: false }) },
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'set_vacay_color',
|
|
needs: ['vacay'],
|
|
args: () => ({ color: '#22c55e' }),
|
|
rest: { method: 'put', path: () => `${VACAY}/color`, body: () => ({ color: '#22c55e' }) },
|
|
expect: [{ event: 'vacay:update', user: 'actor' }],
|
|
},
|
|
{
|
|
// The invitee hears the invite; the inviter's own tabs read the plan again,
|
|
// which now lists it as pending.
|
|
tool: 'send_vacay_invite',
|
|
needs: ['vacay', 'member'],
|
|
stub: quietNotifications,
|
|
args: (s) => ({ targetUserId: s.member.id }),
|
|
rest: { method: 'post', path: () => `${VACAY}/invite`, body: (s) => ({ user_id: s.member.id }) },
|
|
expect: [
|
|
{ event: 'vacay:invite', user: 'others' },
|
|
{ event: 'vacay:settings', user: 'actor' },
|
|
],
|
|
},
|
|
{
|
|
tool: 'accept_vacay_invite',
|
|
actor: 'admin',
|
|
needs: ['vacay'],
|
|
setup: async (s, app) => {
|
|
await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending');
|
|
},
|
|
args: (s) => ({ planId: s.vacay.id }),
|
|
rest: { method: 'post', path: () => `${VACAY}/invite/accept`, body: (s) => ({ plan_id: s.vacay.id }) },
|
|
expect: [{ event: 'vacay:accepted', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'decline_vacay_invite',
|
|
actor: 'admin',
|
|
needs: ['vacay'],
|
|
setup: async (s, app) => {
|
|
await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending');
|
|
},
|
|
args: (s) => ({ planId: s.vacay.id }),
|
|
rest: { method: 'post', path: () => `${VACAY}/invite/decline`, body: (s) => ({ plan_id: s.vacay.id }) },
|
|
// The plan's owner and the one declining, whose tabs still showed the invite (G34).
|
|
expect: [{ event: 'vacay:declined', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'cancel_vacay_invite',
|
|
needs: ['vacay'],
|
|
setup: async (s, app) => {
|
|
await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending');
|
|
},
|
|
args: (s) => ({ targetUserId: s.admin.id }),
|
|
rest: { method: 'post', path: () => `${VACAY}/invite/cancel`, body: (s) => ({ user_id: s.admin.id }) },
|
|
// The invitee and the plan's own tabs, which listed the invite as pending (G34).
|
|
expect: [{ event: 'vacay:cancelled', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'dissolve_vacay_plan',
|
|
needs: ['vacay'],
|
|
setup: async (s, app) => {
|
|
await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id);
|
|
},
|
|
args: () => ({}),
|
|
rest: { method: 'post', path: () => `${VACAY}/dissolve` },
|
|
// Everyone on the plan, the one dissolving it now included (G34).
|
|
expect: [{ event: 'vacay:dissolved', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_vacay_year',
|
|
needs: ['vacay'],
|
|
args: () => ({ year: 2032 }),
|
|
rest: { method: 'post', path: () => `${VACAY}/years`, body: () => ({ year: 2032 }) },
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_vacay_year',
|
|
needs: ['vacay'],
|
|
setup: async (s, app) => {
|
|
await createRow(ormOf(app), VacayYears, { plan: s.vacay.id, year: 2031 });
|
|
},
|
|
args: () => ({ year: 2031 }),
|
|
rest: { method: 'delete', path: () => `${VACAY}/years/2031` },
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'toggle_vacay_entry',
|
|
needs: ['vacay'],
|
|
args: () => ({ date: '2027-06-01' }),
|
|
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
|
|
expect: [{ event: 'vacay:update', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'toggle_company_holiday',
|
|
needs: ['vacay'],
|
|
args: () => ({ date: '2027-06-02' }),
|
|
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
|
|
expect: [{ event: 'vacay:update', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_vacay_stats',
|
|
needs: ['vacay'],
|
|
args: () => ({ year: 2027, vacationDays: 25 }),
|
|
rest: { method: 'put', path: () => `${VACAY}/stats/2027`, body: () => ({ vacation_days: 25 }) },
|
|
expect: [{ event: 'vacay:update', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_holiday_calendar',
|
|
needs: ['vacay'],
|
|
args: () => ({ region: 'DE' }),
|
|
rest: { method: 'post', path: () => `${VACAY}/plan/holiday-calendars`, body: () => ({ region: 'DE' }) },
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_holiday_calendar',
|
|
needs: ['vacay'],
|
|
setup: holidayCalendar,
|
|
args: (s) => ({ calendarId: kept(s, 'calendar'), label: 'Bavaria' }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `${VACAY}/plan/holiday-calendars/${kept(s, 'calendar')}`,
|
|
body: () => ({ label: 'Bavaria' }),
|
|
},
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_holiday_calendar',
|
|
needs: ['vacay'],
|
|
setup: holidayCalendar,
|
|
args: (s) => ({ calendarId: kept(s, 'calendar') }),
|
|
rest: { method: 'delete', path: (s) => `${VACAY}/plan/holiday-calendars/${kept(s, 'calendar')}` },
|
|
expect: [{ event: 'vacay:settings', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'share_vacay_calendar',
|
|
needs: ['vacay', 'member'],
|
|
stub: quietNotifications,
|
|
args: (s) => ({ targetUserId: s.member.id }),
|
|
rest: { method: 'post', path: () => `${VACAY}/shares`, body: (s) => ({ user_id: s.member.id }) },
|
|
// The viewer, then the owner's other tabs, which list the share.
|
|
expect: [{ event: 'vacay:share', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'unshare_vacay_calendar',
|
|
needs: ['vacay', 'member'],
|
|
setup: async (s, app) => {
|
|
const share = await createRow(ormOf(app), VacayShares, { owner: s.owner.id, user: s.member.id });
|
|
keep(s, 'share', share.id);
|
|
},
|
|
args: (s) => ({ shareId: kept(s, 'share') }),
|
|
rest: { method: 'delete', path: (s) => `${VACAY}/shares/${kept(s, 'share')}` },
|
|
expect: [{ event: 'vacay:share-removed', user: 'actor' }],
|
|
},
|
|
];
|
|
|
|
/** A public holiday calendar on the owner's plan. */
|
|
async function holidayCalendar(s: Scene, app: INestApplication): Promise<void> {
|
|
const calendar = await createRow(ormOf(app), VacayHolidayCalendars, { plan: s.vacay.id, region: 'DE' });
|
|
keep(s, 'calendar', calendar.id);
|
|
}
|
|
|
|
/** A label of the owner's collection, written as a row, so the setup sends nothing. */
|
|
async function collectionLabel(s: Scene, app: INestApplication): Promise<void> {
|
|
const label = await createRow(ormOf(app), CollectionLabels, { collection: s.collection.id, name: `Rooftops ${s.n}` });
|
|
keep(s, 'label', label.id);
|
|
}
|
|
|
|
const collectionCases: ParityCase[] = [
|
|
{
|
|
// The owner's other tabs list the new collection (G33).
|
|
tool: 'create_collection',
|
|
args: (s) => ({ name: `Lisbon bakeries ${s.n}` }),
|
|
rest: { method: 'post', path: () => COLLECTIONS, body: (s) => ({ name: `Lisbon bakeries ${s.n}` }) },
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_collection',
|
|
needs: ['collection'],
|
|
args: (s) => ({ collectionId: s.collection.id, name: `Porto ${s.n}` }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `${COLLECTIONS}/${s.collection.id}`,
|
|
body: (s) => ({ name: `Porto ${s.n}` }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
// The owner's own tabs too, not only the members' (G33).
|
|
tool: 'delete_collection',
|
|
needs: ['collection'],
|
|
args: (s) => ({ collectionId: s.collection.id }),
|
|
rest: { method: 'delete', path: (s) => `${COLLECTIONS}/${s.collection.id}` },
|
|
expect: [{ event: 'collections:deleted', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'reorder_collections',
|
|
needs: ['collection'],
|
|
args: (s) => ({ orderedIds: [s.collection.id] }),
|
|
rest: { method: 'post', path: () => `${COLLECTIONS}/reorder`, body: (s) => ({ orderedIds: [s.collection.id] }) },
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'save_place_to_collection',
|
|
needs: ['collection'],
|
|
args: (s) => ({ collection_id: s.collection.id, name: `Pont Neuf ${s.n}` }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/places`,
|
|
body: (s) => ({ collection_id: s.collection.id, name: `Pont Neuf ${s.n}` }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
// place2, not place: the saved place already sits where `place` is, and a duplicate is skipped silently.
|
|
tool: 'save_trip_places_to_collection',
|
|
needs: ['collection', 'place2'],
|
|
args: (s) => ({ collectionId: s.collection.id, tripId: s.trip.id, placeIds: [s.place2.id] }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/places/from-trip-many`,
|
|
body: (s) => ({ collection_id: s.collection.id, source_trip_id: s.trip.id, source_place_ids: [s.place2.id] }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_collection_place',
|
|
needs: ['collection'],
|
|
args: (s) => ({ placeId: s.collectionPlace.id, name: `Saint-Chapelle ${s.n}` }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}`,
|
|
body: (s) => ({ name: `Saint-Chapelle ${s.n}` }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'set_collection_place_status',
|
|
needs: ['collection'],
|
|
args: (s) => ({ placeId: s.collectionPlace.id, status: 'visited' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}/status`,
|
|
body: () => ({ status: 'visited' }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
// The saved place and the trip's `place` stand on the same spot, which is the match.
|
|
tool: 'set_collection_place_status_from_trip',
|
|
needs: ['collection', 'place'],
|
|
args: (s) => ({ trip_id: s.trip.id, place_ids: [s.place.id], status: 'visited' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/places/status-from-trip`,
|
|
body: (s) => ({ trip_id: s.trip.id, place_ids: [s.place.id], status: 'visited' }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'rate_collection_place',
|
|
needs: ['collection'],
|
|
args: (s) => ({ placeId: s.collectionPlace.id, rating: 4 }),
|
|
rest: {
|
|
method: 'put',
|
|
path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}/rating`,
|
|
body: () => ({ rating: 4 }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_collection_place',
|
|
needs: ['collection'],
|
|
args: (s) => ({ placeId: s.collectionPlace.id }),
|
|
rest: { method: 'delete', path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}` },
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'create_collection_label',
|
|
needs: ['collection'],
|
|
args: (s) => ({ collection_id: s.collection.id, name: 'Rooftops' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/labels`,
|
|
body: (s) => ({ collection_id: s.collection.id, name: 'Rooftops' }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'update_collection_label',
|
|
needs: ['collection'],
|
|
setup: collectionLabel,
|
|
args: (s) => ({ labelId: kept(s, 'label'), name: 'Viewpoints' }),
|
|
rest: {
|
|
method: 'patch',
|
|
path: (s) => `${COLLECTIONS}/labels/${kept(s, 'label')}`,
|
|
body: () => ({ name: 'Viewpoints' }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_collection_label',
|
|
needs: ['collection'],
|
|
setup: collectionLabel,
|
|
args: (s) => ({ labelId: kept(s, 'label') }),
|
|
rest: { method: 'delete', path: (s) => `${COLLECTIONS}/labels/${kept(s, 'label')}` },
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'assign_collection_labels',
|
|
needs: ['collection'],
|
|
setup: collectionLabel,
|
|
args: (s) => ({ label_ids: [kept(s, 'label')], place_ids: [s.collectionPlace.id] }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/labels/assign`,
|
|
body: (s) => ({ label_ids: [kept(s, 'label')], place_ids: [s.collectionPlace.id] }),
|
|
},
|
|
expect: [{ event: 'collections:updated', user: 'actor' }],
|
|
},
|
|
{
|
|
// The invitee hears the invite; the owner's tabs list it as pending.
|
|
tool: 'invite_to_collection',
|
|
needs: ['collection', 'member'],
|
|
stub: quietNotifications,
|
|
args: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'editor' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/invite`,
|
|
body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'editor' }),
|
|
},
|
|
expect: [
|
|
{ event: 'collections:invite', user: 'others' },
|
|
{ event: 'collections:updated', user: 'actor' },
|
|
],
|
|
},
|
|
{
|
|
tool: 'set_collection_member_role',
|
|
needs: ['collection', 'member'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.member.id);
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id, userId: s.member.id, role: 'viewer' }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/members/role`,
|
|
body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'viewer' }),
|
|
},
|
|
// Everyone on the list, then the member once more to re-gate their view; the route reads no X-Socket-Id.
|
|
expect: [
|
|
{ event: 'collections:updated', user: 'actor', all: true },
|
|
{ event: 'collections:updated', user: 'others', all: true },
|
|
],
|
|
},
|
|
{
|
|
tool: 'remove_collection_member',
|
|
needs: ['collection', 'member'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.member.id);
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id, userId: s.member.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/members/remove`,
|
|
body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id }),
|
|
},
|
|
expect: [
|
|
{ event: 'collections:left', user: 'actor', all: true },
|
|
{ event: 'collections:removed', user: 'others', all: true },
|
|
],
|
|
},
|
|
{
|
|
tool: 'cancel_collection_invite',
|
|
needs: ['collection', 'member'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.member.id, { status: 'pending' });
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id, userId: s.member.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/invite/cancel`,
|
|
body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id }),
|
|
},
|
|
// The invitee, then the owner's tabs, which listed the invite as pending (G33).
|
|
expect: [
|
|
{ event: 'collections:cancelled', user: 'others' },
|
|
{ event: 'collections:updated', user: 'actor' },
|
|
],
|
|
},
|
|
{
|
|
tool: 'accept_collection_invite',
|
|
actor: 'admin',
|
|
needs: ['collection'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.admin.id, { status: 'pending' });
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/invite/accept`,
|
|
body: (s) => ({ collection_id: s.collection.id }),
|
|
},
|
|
expect: [{ event: 'collections:accepted', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'decline_collection_invite',
|
|
actor: 'admin',
|
|
needs: ['collection'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.admin.id, { status: 'pending' });
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id }),
|
|
rest: {
|
|
method: 'post',
|
|
path: () => `${COLLECTIONS}/invite/decline`,
|
|
body: (s) => ({ collection_id: s.collection.id }),
|
|
},
|
|
// The owner and the one declining, whose tabs still showed the invite (G33).
|
|
expect: [{ event: 'collections:declined', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'leave_collection',
|
|
actor: 'admin',
|
|
needs: ['collection'],
|
|
setup: async (s, app) => {
|
|
await addCollectionMember(ormOf(app), s.collection.id, s.admin.id);
|
|
},
|
|
args: (s) => ({ collectionId: s.collection.id }),
|
|
rest: { method: 'post', path: () => `${COLLECTIONS}/leave`, body: (s) => ({ collection_id: s.collection.id }) },
|
|
// Those staying, then the one leaving, whose tabs leave the list as a removed member's do (G33).
|
|
expect: [
|
|
{ event: 'collections:left', user: 'others' },
|
|
{ event: 'collections:removed', user: 'actor' },
|
|
],
|
|
},
|
|
];
|
|
|
|
/** The page ids of the scenes' books, in page order, so the tools can name them. */
|
|
const BOOK_PAGES = ['sp-cover', 'sp-first', 'sp-inner', 'sp-last', 'sp-back'];
|
|
|
|
/** A blank book for the scene's journey, the same one each time it is asked for. */
|
|
function bookOf(s: Scene) {
|
|
let next = 0;
|
|
return blankBook({ title: `Book ${s.n}`, newId: () => BOOK_PAGES[next++] ?? `sp-extra-${next}` });
|
|
}
|
|
|
|
/** The scene's book with a second inner page after the first, so a page has somewhere to move. */
|
|
function twoInnerBook(s: Scene): BookDocument {
|
|
const book = bookOf(s);
|
|
const second = { ...blankSpread('inner'), id: 'sp-inner-2' };
|
|
return { ...book, spreads: [...book.spreads.slice(0, 3), second, ...book.spreads.slice(3)] };
|
|
}
|
|
|
|
/** The scene's journey with its book at version 1, written as a row so the setup sends nothing. */
|
|
async function seedBook(s: Scene, app: INestApplication, document: BookDocument = bookOf(s)): Promise<void> {
|
|
await insertRow(ormOf(app), JourneyBooks, {
|
|
journey: s.journey.id,
|
|
title: `Book ${s.n}`,
|
|
document: JSON.stringify(document),
|
|
version: 1,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The Studio's save, the one route that writes a book: every book tool's twin.
|
|
* It saves the scene's book as it stands, which sends the same event as any
|
|
* change would; what each tool changes is pinned in its own suites.
|
|
*/
|
|
const saveBook = (baseVersion?: number): RestCall => ({
|
|
method: 'put',
|
|
path: (s) => `/api/journeys/${s.journey.id}/book`,
|
|
body: (s) => ({ title: `Book ${s.n}`, document: bookOf(s), baseVersion }),
|
|
});
|
|
|
|
/**
|
|
* The Studio book's tools. Each write tells every member's tabs that the book
|
|
* moved on; REST leaves out the tab that saved, MCP none, so the Studio the
|
|
* assistant runs beside shows its change.
|
|
*/
|
|
const bookCases: ParityCase[] = [
|
|
{
|
|
tool: 'create_book',
|
|
needs: ['journey'],
|
|
args: onJourney,
|
|
rest: saveBook(),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'add_book_pages',
|
|
needs: ['journey'],
|
|
setup: seedBook,
|
|
args: (s) => ({ journeyId: s.journey.id, afterSpreadId: 'sp-inner' }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
// A real move: a move to where the page already is writes nothing and sends nothing.
|
|
tool: 'move_book_page',
|
|
needs: ['journey'],
|
|
setup: (s, app) => seedBook(s, app, twoInnerBook(s)),
|
|
args: (s) => ({ journeyId: s.journey.id, spreadId: 'sp-inner', toIndex: 3 }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'delete_book_pages',
|
|
needs: ['journey'],
|
|
setup: seedBook,
|
|
args: (s) => ({ journeyId: s.journey.id, spreadIds: ['sp-inner'] }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'edit_book_page',
|
|
needs: ['journey'],
|
|
setup: seedBook,
|
|
args: (s) => ({ journeyId: s.journey.id, spreadId: 'sp-inner', ops: [{ op: 'background', color: '#f4efe6' }] }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
tool: 'set_book_cover',
|
|
needs: ['journey'],
|
|
setup: seedBook,
|
|
args: (s) => ({ journeyId: s.journey.id, title: `Lisbon ${s.n}` }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
{
|
|
// An undo puts the page back where it is, at the version the change left.
|
|
tool: 'restore_book_page',
|
|
needs: ['journey'],
|
|
setup: seedBook,
|
|
args: (s) => ({ journeyId: s.journey.id, pages: [{ spread: bookOf(s).spreads[2], index: 2 }], expectedVersion: 1 }),
|
|
rest: saveBook(1),
|
|
expect: [{ event: 'journey:book:saved', user: 'actor' }],
|
|
},
|
|
];
|
|
|
|
export const user: CaseGroup = {
|
|
name: 'user',
|
|
prefix: 'BCP-USER',
|
|
cases: [...atlasCases, ...dawarichCases, ...journeyCases, ...vacayCases, ...collectionCases, ...bookCases],
|
|
exempt: [
|
|
{ tool: 'create_journey_share_link', reason: TOKEN_ONLY, needs: ['journey'], args: onJourney },
|
|
{
|
|
tool: 'delete_journey_share_link',
|
|
reason: TOKEN_ONLY,
|
|
needs: ['journey'],
|
|
args: onJourney,
|
|
first: { tool: 'create_journey_share_link', args: onJourney },
|
|
},
|
|
{
|
|
tool: 'dismiss_dawarich_suggestion',
|
|
reason:
|
|
"takes a stay out of the caller's own Dawarich review list, for which the registry has no event; the journey day review shows the stay as skipped too, but both read their rows when they open, so only a review left open shows the old state until its next read",
|
|
setup: recordStay,
|
|
args: (s) => ({ suggestionId: kept(s, 'stay') }),
|
|
},
|
|
{
|
|
tool: 'create_manual_school_holiday_country',
|
|
reason: HOLIDAY_CATALOG,
|
|
actor: 'admin',
|
|
args: (s) => ({ code: holidayCode(s.n, 0), name: `Holiday land ${s.n}` }),
|
|
},
|
|
{
|
|
tool: 'create_manual_school_holiday_region',
|
|
reason: HOLIDAY_CATALOG,
|
|
actor: 'admin',
|
|
needs: ['holidayCountry'],
|
|
args: (s) => ({ country: s.holidayCountry.code, name: `North ${s.n}`, revision: 0, holidays: [WINTER] }),
|
|
},
|
|
{
|
|
tool: 'update_manual_school_holiday_region',
|
|
reason: HOLIDAY_CATALOG,
|
|
actor: 'admin',
|
|
needs: ['holidayRegion'],
|
|
args: (s) => ({
|
|
regionId: s.holidayRegion.id,
|
|
name: `South ${s.n}`,
|
|
revision: s.holidayRegion.revision,
|
|
holidays: [WINTER],
|
|
}),
|
|
},
|
|
{
|
|
tool: 'delete_manual_school_holiday_region',
|
|
reason: HOLIDAY_CATALOG,
|
|
actor: 'admin',
|
|
needs: ['holidayRegion'],
|
|
args: (s) => ({ regionId: s.holidayRegion.id, revision: s.holidayRegion.revision }),
|
|
},
|
|
{
|
|
tool: 'delete_manual_school_holiday_country',
|
|
reason: HOLIDAY_CATALOG,
|
|
actor: 'admin',
|
|
needs: ['holidayCountry'],
|
|
args: (s) => ({ code: s.holidayCountry.code }),
|
|
},
|
|
],
|
|
};
|