mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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'. */
|
||||
|
||||
Reference in New Issue
Block a user