test(broadcast-parity): cases for the mail import's write tools

add, move, file, discard and undo of a mail booking and the import
settings each get a case with their REST twin. A trip expectation may
name its own room, for the move that announces in two trips.
This commit is contained in:
Maurice
2026-11-13 19:31:14 +01:00
parent 89b67a791f
commit c13c288f00
3 changed files with 222 additions and 4 deletions
@@ -1,15 +1,28 @@
import { MailIngestAddresses } from '../../../src/db/entities/MailIngestAddresses.entity';
import { MailIngestCandidates } from '../../../src/db/entities/MailIngestCandidates.entity';
import { MailIngestMessages } from '../../../src/db/entities/MailIngestMessages.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { dbNow } from '../../../src/db/types';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import {
AirtrailClient,
type AirtrailAirport,
type AirtrailFlightRaw,
} from '../../../src/nest/integrations/airtrail.client';
import { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { MailIngestApplyService } from '../../../src/nest/mail-ingest/mail-ingest-apply.service';
import { MailIngestTripsService } from '../../../src/nest/mail-ingest/mail-ingest-trips.service';
import { UNDO_WINDOW_DAYS } from '../../../src/nest/mail-ingest/mail-ingest.constants';
import { sealMail } from '../../../src/nest/mail-ingest/mail-seal';
import { makeBudgetItem } from '../../helpers/factories/budget';
import { inContext } from '../../helpers/factories/context';
import { updateRows } from '../../helpers/factories/rows';
import type { CaseGroup } from './types';
import { setAppSetting } from '../../helpers/factories/settings';
import { makeTrip } from '../../helpers/factories/trips';
import type { CaseGroup, Scene } from './types';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { BookingImportPreviewItem } from '@trek/shared';
import { vi } from 'vitest';
@@ -130,6 +143,130 @@ function retype(app: INestApplication, id: number, type: string): Promise<number
}
/** Bookings: reservations, transports, transit, stays and the AirTrail import. */
// -- The booking mail import --------------------------------------------------
const DAY_MS = 86_400_000;
const orm = (app: INestApplication) => app.get(MikroORM);
/** A date `days` from today: the import's checks take a booking only within a few years of now. */
const dayFromToday = (days: number) => new Date(Date.now() + days * DAY_MS).toISOString().slice(0, 10);
const MAIL_DAY = dayFromToday(30);
/** A table booking, as a mail to the scene's owner would have been read. */
const mailBooking = (s: Scene): BookingImportPreviewItem => ({
type: 'restaurant',
title: `Dinner ${s.n}`,
reservation_time: `${MAIL_DAY}T19:00`,
confirmation_number: `MAIL-${s.n}`,
source: { fileName: 'mail', index: 0 },
});
/** The scene's inbox entry and, for a move, the trip it goes to, by the scene's number. */
const mailEntryOf = new Map<number, number>();
const moveTargetOf = new Map<number, number>();
const sceneRow = (ids: Map<number, number>, s: Scene) => {
const id = ids.get(s.n);
if (id === undefined) throw new Error(`scene ${s.n} has no row from its setup`);
return id;
};
const mailEntry = (s: Scene) => sceneRow(mailEntryOf, s);
const moveTarget = (s: Scene) => sceneRow(moveTargetOf, s);
const mailPath = (s: Scene, action: string) => `/api/mail-ingest/candidates/${mailEntry(s)}/${action}`;
/** A mail the scene's owner forwarded, as the intake stores it: sealed, and named by its own address. */
function forwardedMail(s: Scene): Buffer {
return Buffer.from(
[
'From: Owner <owner@example.com>',
'To: trips@example.com',
'Subject: Your table',
`Message-ID: <parity-${s.n}@example.com>`,
'Content-Type: text/plain; charset=utf-8',
'',
`Table for two, reference MAIL-${s.n}.`,
].join('\r\n'),
);
}
/**
* The import switched on, the owner's forwarded mail stored, and one entry
* for its booking: waiting in the inbox, or already added to the scene's
* trip. The add goes through the import's own write, which sends nothing;
* announcing it is the business of the action that adds.
*/
async function mailEntryFor(s: Scene, app: INestApplication, added: boolean): Promise<void> {
const db = orm(app);
await setAppSetting(db, 'mail_ingest_enabled', 'true');
const now = new Date();
const raw = forwardedMail(s);
const item = mailBooking(s);
const id = await inContext(db, async (em) => {
const addressId = await em.getRepository(MailIngestAddresses).insertAddress({
userId: s.owner.id,
tripId: null,
tokenHash: `parity-${s.n}`,
tokenEnc: 'sealed',
createdAt: dbNow(now),
});
const messageId = await em.getRepository(MailIngestMessages).insertIfNew({
transport: 'upload',
messageIdHdr: `<parity-${s.n}@example.com>`,
dedupeHash: `parity-${s.n}`,
addressId,
userId: s.owner.id,
tripId: null,
size: raw.length,
rawEnc: sealMail(raw),
receivedAt: dbNow(now),
});
if (messageId === null) throw new Error(`the mail of scene ${s.n} was stored already`);
const entry = {
messageId,
userId: s.owner.id,
position: 0,
itemJson: JSON.stringify(item),
source: 'kitinerary',
checksJson: null,
uncertainFields: null,
matchJson: null,
suggestedTripId: s.trip.id,
dedupeKey: null,
contentHash: null,
createdAt: dbNow(now),
};
if (!added) {
return em
.getRepository(MailIngestCandidates)
.insertCandidate({ ...entry, resolution: 'pending', reason: 'ask_first' });
}
const trips = app.get(MailIngestTripsService);
const person = await trips.person(s.owner.id);
const trip = person ? await trips.editableTrip(person, s.trip.id) : null;
if (!trip) throw new Error(`the owner of scene ${s.n} cannot add bookings to its trip`);
const apply = app.get(MailIngestApplyService);
const prepared = await apply.prepare(item, trip, s.owner.id, []);
const written = await app.get(UnitOfWork).transactional(() => apply.writeInTx(prepared));
return em.getRepository(MailIngestCandidates).insertCandidate({
...entry,
resolution: 'applied',
reason: null,
resolvedTripId: s.trip.id,
createdIdsJson: JSON.stringify(written.created),
resolvedAt: dbNow(now),
undoUntil: dbNow(new Date(now.getTime() + UNDO_WINDOW_DAYS * DAY_MS)),
});
});
mailEntryOf.set(s.n, id);
}
const waitingMail = (s: Scene, app: INestApplication) => mailEntryFor(s, app, false);
const addedMail = (s: Scene, app: INestApplication) => mailEntryFor(s, app, true);
/**
* The person's inbox count, sent to every one of their sockets (the asking
* tab included, on REST too): each action on an entry changes what waits.
*/
const INBOX_CHANGED = { event: 'mailInbox:changed', user: 'actor', all: true } as const;
export const bookings: CaseGroup = {
name: 'bookings',
prefix: 'BCP-BOOK',
@@ -358,6 +495,71 @@ export const bookings: CaseGroup = {
settle: true,
expect: ['reservation:created', BOOKING_NOTICE],
},
// -- The booking mail import ----------------------------------------------
// A booking from a mail is announced once its write committed, to every
// socket of the trip, the asking tab included: the same announcement the
// import makes when it adds a booking with no tab asking at all.
{
// The booking, the mail as its document, and the inbox that shrank.
tool: 'add_mail_booking',
setup: waitingMail,
args: (s) => ({ candidateId: mailEntry(s) }),
rest: { method: 'post', path: (s) => mailPath(s, 'apply'), body: () => ({}) },
expect: [{ event: 'reservation:created', all: true }, { event: 'file:created', all: true }, INBOX_CHANGED],
},
{
// Out of the scene's trip into the owner's other trip in the booking's dates, its mail with it.
tool: 'move_mail_booking',
setup: async (s, app) => {
await addedMail(s, app);
const target = await makeTrip(orm(app), s.owner.id, {
title: `Elsewhere ${s.n}`,
start_date: dayFromToday(28),
end_date: dayFromToday(32),
});
moveTargetOf.set(s.n, target.id);
},
args: (s) => ({ candidateId: mailEntry(s), trip_id: moveTarget(s) }),
rest: { method: 'post', path: (s) => mailPath(s, 'move'), body: (s) => ({ trip_id: moveTarget(s) }) },
expect: [
{ event: 'reservation:deleted', all: true },
{ event: 'reservation:created', all: true, room: moveTarget },
{ event: 'file:created', all: true, room: moveTarget },
INBOX_CHANGED,
],
},
{
tool: 'file_mail_booking',
setup: waitingMail,
args: (s) => ({ candidateId: mailEntry(s), trip_id: s.trip.id }),
rest: { method: 'post', path: (s) => mailPath(s, 'file'), body: (s) => ({ trip_id: s.trip.id }) },
expect: [{ event: 'file:created', all: true }, INBOX_CHANGED],
},
{
tool: 'discard_mail_booking',
setup: waitingMail,
args: (s) => ({ candidateId: mailEntry(s) }),
rest: { method: 'post', path: (s) => mailPath(s, 'discard') },
expect: [INBOX_CHANGED],
},
{
tool: 'undo_mail_booking',
setup: addedMail,
args: (s) => ({ candidateId: mailEntry(s) }),
rest: { method: 'post', path: (s) => mailPath(s, 'undo'), body: () => ({}) },
expect: [{ event: 'reservation:deleted', all: true }, INBOX_CHANGED],
},
{
// The person's other tabs read their settings card again.
tool: 'update_mail_import_settings',
setup: async (_s, app) => {
await setAppSetting(orm(app), 'mail_ingest_enabled', 'true');
},
args: () => ({ auto_add: false }),
rest: { method: 'patch', path: () => '/api/mail-ingest/settings', body: () => ({ auto_add: false }) },
expect: [{ event: 'mailInbox:settings', user: 'actor', all: true }],
},
],
exempt: [],
};
@@ -40,6 +40,8 @@ interface Wanted {
only: Partial<Record<Surface, Receivers>>;
/** The surfaces limit it differently on purpose, so their receivers are not compared. */
onlyDiffers: boolean;
/** The trip room of this send, when it is not the case's. */
room?: (s: Scene) => number;
}
function wanted(e: Expect): Wanted {
@@ -53,7 +55,14 @@ function wanted(e: Expect): Wanted {
: typeof e.only === 'string'
? { MCP: e.only, REST: e.only }
: { MCP: e.only.mcp, REST: e.only.rest };
return { channel: 'trip', event: e.event, all: e.all === true, only, onlyDiffers: only.MCP !== only.REST };
return {
channel: 'trip',
event: e.event,
all: e.all === true,
only,
onlyDiffers: only.MCP !== only.REST,
room: e.room,
};
}
const signature = (r: { channel: string; event: string }) => `${r.channel} ${r.event}`;
@@ -129,7 +138,7 @@ function checkSurface(surface: Surface, c: ParityCase, records: Recorded[], s: S
);
if (want.user === 'others') return;
} else {
expect(Number(r.to[0]), `sent to another room than the trip's: ${where}`).toBe(room);
expect(Number(r.to[0]), `sent to another room than the trip's: ${where}`).toBe(want.room?.(s) ?? room);
checkLimit(want.only[surface], r, actorId, where);
}
const skips = surface === 'MCP' || want.all ? undefined : REST_SOCKET;
@@ -152,10 +152,17 @@ export type Receivers = 'actor' | 'viewers';
* - `{ event, user }`: to users' own sockets (`broadcastToUser`). `actor` means
* the writer is among the receivers (REST leaves out the requesting socket
* unless `all` is set too), `others` that only other users receive it.
* - `{ event, room }`: to another trip room than the case's, for a write that
* touches two trips (a booking moved from one trip into another).
*/
export type Expect =
| TrekWsTripEventName
| { event: TrekWsTripEventName; all?: true; only?: Receivers | { rest: Receivers; mcp: Receivers } }
| {
event: TrekWsTripEventName;
all?: true;
only?: Receivers | { rest: Receivers; mcp: Receivers };
room?: (s: Scene) => number;
}
| { event: TrekWsUserEventName; user: 'actor' | 'others'; all?: true };
/** The REST twin of a tool, called as the same user with X-Socket-Id 'sock-rest'. */