diff --git a/server/tests/integration/broadcast-parity/bookings.cases.ts b/server/tests/integration/broadcast-parity/bookings.cases.ts index 3bb8fa366..b024360ce 100644 --- a/server/tests/integration/broadcast-parity/bookings.cases.ts +++ b/server/tests/integration/broadcast-parity/bookings.cases.ts @@ -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 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(); +const moveTargetOf = new Map(); +const sceneRow = (ids: Map, 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 ', + 'To: trips@example.com', + 'Subject: Your table', + `Message-ID: `, + '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 { + 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: ``, + 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: [], }; diff --git a/server/tests/integration/broadcast-parity/run-group.ts b/server/tests/integration/broadcast-parity/run-group.ts index fd1f49164..a2d1da5be 100644 --- a/server/tests/integration/broadcast-parity/run-group.ts +++ b/server/tests/integration/broadcast-parity/run-group.ts @@ -40,6 +40,8 @@ interface Wanted { only: Partial>; /** 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; diff --git a/server/tests/integration/broadcast-parity/types.ts b/server/tests/integration/broadcast-parity/types.ts index 18bd6b2e8..5eae6f654 100644 --- a/server/tests/integration/broadcast-parity/types.ts +++ b/server/tests/integration/broadcast-parity/types.ts @@ -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'. */