mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -35,7 +35,7 @@ export class BookingImportService {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
private resolveDayId(tripId: string, iso: string | null | undefined): number | null {
|
||||
private async resolveDayId(tripId: string, iso: string | null | undefined): Promise<number | null> {
|
||||
if (!iso) return null;
|
||||
const date = iso.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
@@ -284,8 +284,8 @@ export class BookingImportService {
|
||||
// the accommodation row is actually inserted (createReservation gates on them).
|
||||
let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined;
|
||||
if (item.type === 'hotel' && _accommodation) {
|
||||
const startDayId = this.resolveDayId(tripId, _accommodation.check_in);
|
||||
const endDayId = this.resolveDayId(tripId, _accommodation.check_out);
|
||||
const startDayId = await this.resolveDayId(tripId, _accommodation.check_in);
|
||||
const endDayId = await this.resolveDayId(tripId, _accommodation.check_out);
|
||||
createAccommodation = {
|
||||
place_id: placeId,
|
||||
start_day_id: startDayId ?? undefined,
|
||||
|
||||
@@ -144,7 +144,7 @@ export class CalendarService {
|
||||
* because a user-supplied SUMMARY can legitimately contain the literal
|
||||
* "END:VEVENT". Handing out the parts removes the need to reassemble them.
|
||||
*/
|
||||
buildTripCalendar(tripId: string | number): TripCalendar {
|
||||
async buildTripCalendar(tripId: string | number): Promise<TripCalendar> {
|
||||
const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
|
||||
if (!trip) throw new NotFoundError('Trip not found');
|
||||
|
||||
@@ -316,7 +316,7 @@ export class CalendarService {
|
||||
|
||||
// Transport/flight reservations carry no top-level reservation_time; their
|
||||
// times live per endpoint (local_date + local_time) in reservation_endpoints.
|
||||
const endpointsMap = this.reservations.loadEndpointsByTrip(tripId);
|
||||
const endpointsMap = await this.reservations.loadEndpointsByTrip(tripId);
|
||||
const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s);
|
||||
|
||||
@@ -746,8 +746,8 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
/** One trip's calendar as a finished, foldable VCALENDAR document. */
|
||||
exportICS(tripId: string | number): { ics: string; filename: string } {
|
||||
const cal = this.buildTripCalendar(tripId);
|
||||
async exportICS(tripId: string | number): Promise<{ ics: string; filename: string }> {
|
||||
const cal = await this.buildTripCalendar(tripId);
|
||||
const ics =
|
||||
CALENDAR_HEADER +
|
||||
`X-WR-CALNAME:${cal.calName}\r\n` +
|
||||
|
||||
@@ -42,8 +42,8 @@ export class FeedsPublicController {
|
||||
constructor(private readonly feeds: FeedsService) {}
|
||||
|
||||
@Get('trip/:token.ics')
|
||||
tripFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildTripIcs(token);
|
||||
async tripFeed(@Param('token') token: string, @Res() res: Response): Promise<void> {
|
||||
const result = await this.feeds.buildTripIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
@@ -56,8 +56,8 @@ export class FeedsPublicController {
|
||||
}
|
||||
|
||||
@Get('user/:token.ics')
|
||||
userFeed(@Param('token') token: string, @Res() res: Response): void {
|
||||
const result = this.feeds.buildUserIcs(token);
|
||||
async userFeed(@Param('token') token: string, @Res() res: Response): Promise<void> {
|
||||
const result = await this.feeds.buildUserIcs(token);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Feed not found' });
|
||||
return;
|
||||
|
||||
@@ -108,13 +108,13 @@ export class FeedsService {
|
||||
|
||||
// ── ICS generation ───────────────────────────────────────────────────────
|
||||
|
||||
buildTripIcs(token: string): { ics: string; filename: string } | null {
|
||||
async buildTripIcs(token: string): Promise<{ ics: string; filename: string } | null> {
|
||||
const row = this.db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const cal = this.calendar.buildTripCalendar(row.id);
|
||||
const cal = await this.calendar.buildTripCalendar(row.id);
|
||||
// Same document as the one-time download, plus the subscription refresh
|
||||
// hints so clients re-fetch hourly. Assembled from the calendar's parts
|
||||
// rather than string-surgeried into the finished text.
|
||||
@@ -132,7 +132,7 @@ export class FeedsService {
|
||||
}
|
||||
}
|
||||
|
||||
buildUserIcs(token: string): { ics: string; calName: string } | null {
|
||||
async buildUserIcs(token: string): Promise<{ ics: string; calName: string } | null> {
|
||||
const user = this.db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as
|
||||
| { id: number; username: string }
|
||||
| undefined;
|
||||
@@ -168,7 +168,7 @@ export class FeedsService {
|
||||
let events = '';
|
||||
for (const { id } of trips) {
|
||||
try {
|
||||
const cal = this.calendar.buildTripCalendar(id);
|
||||
const cal = await this.calendar.buildTripCalendar(id);
|
||||
for (const [tzid, block] of cal.timezones) {
|
||||
if (!zones.has(tzid)) zones.set(tzid, block);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class AirtrailImportService {
|
||||
socketId: string | undefined,
|
||||
connections: string[][] = [],
|
||||
): Promise<AirtrailImportResult> {
|
||||
const creds = this.airtrail.getAirtrailCredentials(userId);
|
||||
const creds = await this.airtrail.getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
|
||||
const wanted = new Set(flightIds.map(String));
|
||||
|
||||
@@ -40,18 +40,18 @@ export class AirtrailLinkService {
|
||||
return row?.value !== 'false';
|
||||
}
|
||||
|
||||
broadcastUpdated(tripId: number, reservationId: number): void {
|
||||
async broadcastUpdated(tripId: number, reservationId: number): Promise<void> {
|
||||
try {
|
||||
const reservation = this.reads.getReservationWithJoins(reservationId);
|
||||
const reservation = await this.reads.getReservationWithJoins(reservationId);
|
||||
if (reservation) this.realtime.broadcast(String(tripId), 'reservation:updated', { reservation } as never, undefined);
|
||||
} catch {
|
||||
/* broadcast failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
detach(tripId: number, reservationId: number): void {
|
||||
async detach(tripId: number, reservationId: number): Promise<void> {
|
||||
this.db.run('UPDATE reservations SET sync_enabled = 0 WHERE id = ?', reservationId);
|
||||
this.broadcastUpdated(tripId, reservationId);
|
||||
await this.broadcastUpdated(tripId, reservationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ export class AirtrailLinkService {
|
||||
* side: a pull flattens the layover chain back to from→to, a push rewrites the
|
||||
* AirTrail flight to span the whole route (#1535).
|
||||
*/
|
||||
hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): boolean {
|
||||
async hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): Promise<boolean> {
|
||||
try {
|
||||
const meta = metadataJson ? JSON.parse(metadataJson) : {};
|
||||
if (Array.isArray(meta?.legs) && meta.legs.length > 1) return true;
|
||||
@@ -96,21 +96,21 @@ export class AirtrailLinkService {
|
||||
// 1:1 mapping to the AirTrail flight: pushing would rewrite that flight to the
|
||||
// full span, and the next pull would flatten the layover again. Detach — the
|
||||
// merge is a deliberate local restructuring, like a joined import (#1535).
|
||||
const reservation = this.reads.getReservationWithJoins(row.id);
|
||||
const reservation = await this.reads.getReservationWithJoins(row.id);
|
||||
if (!reservation) return;
|
||||
if (this.hasLocalMultiLegShape(row.id, (reservation as { metadata?: string | null }).metadata)) {
|
||||
this.detach(tripId, row.id);
|
||||
if (await this.hasLocalMultiLegShape(row.id, (reservation as { metadata?: string | null }).metadata)) {
|
||||
await this.detach(tripId, row.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
|
||||
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
|
||||
// inbound, AirTrail-wins pull keeps the reservation up to date.
|
||||
if (!row.external_owner_user_id || !this.airtrail.isAirtrailWriteEnabled(row.external_owner_user_id)) return;
|
||||
if (!row.external_owner_user_id || !(await this.airtrail.isAirtrailWriteEnabled(row.external_owner_user_id))) return;
|
||||
|
||||
const creds: AirtrailCreds | null = this.airtrail.getAirtrailCredentials(row.external_owner_user_id);
|
||||
const creds: AirtrailCreds | null = await this.airtrail.getAirtrailCredentials(row.external_owner_user_id);
|
||||
if (!creds) {
|
||||
this.detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
await this.detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,12 +118,12 @@ export class AirtrailLinkService {
|
||||
try {
|
||||
existing = await this.client.getFlight(creds, Number(row.external_id));
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) this.detach(tripId, row.id);
|
||||
if (err instanceof AirtrailAuthError) await this.detach(tripId, row.id);
|
||||
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
this.detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
|
||||
await this.detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class AirtrailSyncJob implements OnApplicationBootstrap {
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
const value = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'airtrail_poll_interval_minutes')?.value;
|
||||
const raw = Number.parseInt(value || '5', 10);
|
||||
|
||||
@@ -52,7 +52,7 @@ export class AirtrailSyncService {
|
||||
* flights are never auto-added to a trip. Returns how many rows changed.
|
||||
*/
|
||||
private async syncOwner(uid: number): Promise<number> {
|
||||
const creds = this.airtrail.getAirtrailCredentials(uid);
|
||||
const creds = await this.airtrail.getAirtrailCredentials(uid);
|
||||
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
|
||||
|
||||
let flights: AirtrailFlightRaw[];
|
||||
@@ -73,7 +73,7 @@ export class AirtrailSyncService {
|
||||
for (const row of linked) {
|
||||
const flight = byId.get(String(row.external_id));
|
||||
if (!flight) {
|
||||
this.link.detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
|
||||
await this.link.detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
|
||||
changed++;
|
||||
continue;
|
||||
}
|
||||
@@ -81,12 +81,12 @@ export class AirtrailSyncService {
|
||||
const hash = canonicalHash(flight);
|
||||
if (hash === row.external_hash) continue;
|
||||
|
||||
const current = this.reservations.getReservation(row.id, row.trip_id);
|
||||
const current = await this.reservations.getReservation(row.id, row.trip_id);
|
||||
if (!current) continue;
|
||||
if (this.link.hasLocalMultiLegShape(row.id, (current as any).metadata)) {
|
||||
if (await this.link.hasLocalMultiLegShape(row.id, (current as any).metadata)) {
|
||||
// The user connected this flight into a multi-leg booking; applying the
|
||||
// remote single-flight shape would flatten it. Stop syncing instead.
|
||||
this.link.detach(row.trip_id, row.id);
|
||||
await this.link.detach(row.trip_id, row.id);
|
||||
changed++;
|
||||
continue;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export class AirtrailSyncService {
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
this.link.broadcastUpdated(row.trip_id, row.id);
|
||||
await this.link.broadcastUpdated(row.trip_id, row.id);
|
||||
changed++;
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
|
||||
@@ -38,7 +38,7 @@ export class AirtrailService {
|
||||
private readonly client: AirtrailClient,
|
||||
) {}
|
||||
|
||||
private readRow(userId: number): UserConnRow | undefined {
|
||||
private async readRow(userId: number): Promise<UserConnRow | undefined> {
|
||||
return this.db.get<UserConnRow>(
|
||||
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
|
||||
userId,
|
||||
@@ -46,7 +46,7 @@ export class AirtrailService {
|
||||
}
|
||||
|
||||
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
|
||||
isAirtrailWriteEnabled(userId: number): boolean {
|
||||
async isAirtrailWriteEnabled(userId: number): Promise<boolean> {
|
||||
const row = this.db.get<{ airtrail_write_enabled?: number | null }>(
|
||||
'SELECT airtrail_write_enabled FROM users WHERE id = ?',
|
||||
userId,
|
||||
@@ -55,8 +55,8 @@ export class AirtrailService {
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = this.readRow(userId);
|
||||
async getAirtrailCredentials(userId: number): Promise<AirtrailCreds | null> {
|
||||
const row = await this.readRow(userId);
|
||||
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
|
||||
const apiKey = decrypt_api_key(row.airtrail_api_key);
|
||||
if (!apiKey) return null;
|
||||
@@ -68,8 +68,8 @@ export class AirtrailService {
|
||||
}
|
||||
|
||||
/** Settings as shown in the UI — the key is never echoed, only masked. */
|
||||
getConnectionSettings(userId: number) {
|
||||
const row = this.readRow(userId);
|
||||
async getConnectionSettings(userId: number) {
|
||||
const row = await this.readRow(userId);
|
||||
return {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
@@ -148,7 +148,7 @@ export class AirtrailService {
|
||||
async getConnectionStatus(
|
||||
userId: number,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const creds = this.getAirtrailCredentials(userId);
|
||||
const creds = await this.getAirtrailCredentials(userId);
|
||||
if (!creds) return { connected: false, error: 'Not configured' };
|
||||
return this.probe(creds);
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export class AirtrailService {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
const provided = (apiKey || '').trim();
|
||||
|
||||
const stored = this.getAirtrailCredentials(userId);
|
||||
const stored = await this.getAirtrailCredentials(userId);
|
||||
const effectiveUrl = trimmedUrl || stored?.baseUrl;
|
||||
const typedKey = provided && provided !== KEY_MASK ? provided : '';
|
||||
|
||||
@@ -199,7 +199,7 @@ export class AirtrailService {
|
||||
|
||||
/** The user's AirTrail flights, normalized for the import picker. */
|
||||
async getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
|
||||
const creds = this.getAirtrailCredentials(userId);
|
||||
const creds = await this.getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
const raw = await this.client.listFlights(creds);
|
||||
return raw.map(normalizeFlight);
|
||||
|
||||
@@ -64,8 +64,8 @@ export class DawarichSuggestionsService {
|
||||
* someone's location history, and a forgotten filter over a wider read is how
|
||||
* that leaks.
|
||||
*/
|
||||
list(userId: number, filter: { tripId?: number; state?: string }): DawarichSuggestionList {
|
||||
this.reopenOrphaned(userId);
|
||||
async list(userId: number, filter: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> {
|
||||
await this.reopenOrphaned(userId);
|
||||
|
||||
const clauses = ['s.user_id = ?'];
|
||||
const params: unknown[] = [userId];
|
||||
@@ -88,7 +88,7 @@ export class DawarichSuggestionsService {
|
||||
...params,
|
||||
);
|
||||
|
||||
const connection = this.dawarich.getConnection(userId);
|
||||
const connection = await this.dawarich.getConnection(userId);
|
||||
return {
|
||||
suggestions: rows.map(toWire),
|
||||
connected: connection.connected,
|
||||
@@ -115,7 +115,7 @@ export class DawarichSuggestionsService {
|
||||
* at read time, which would make the list say "back in review" while `accept`
|
||||
* still refused the row as already accepted.
|
||||
*/
|
||||
private reopenOrphaned(userId: number): void {
|
||||
private async reopenOrphaned(userId: number): Promise<void> {
|
||||
this.db.run(
|
||||
`UPDATE dawarich_visit_suggestions
|
||||
SET state = 'new', target = NULL, accepted_hash = NULL,
|
||||
@@ -136,7 +136,7 @@ export class DawarichSuggestionsService {
|
||||
);
|
||||
}
|
||||
|
||||
getOne(userId: number, id: number): DawarichSuggestion | null {
|
||||
async getOne(userId: number, id: number): Promise<DawarichSuggestion | null> {
|
||||
const row = this.db.get<SuggestionJoinRow>(
|
||||
`SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name
|
||||
FROM dawarich_visit_suggestions s
|
||||
@@ -157,7 +157,7 @@ export class DawarichSuggestionsService {
|
||||
* not offered — the place or entry it produced is a separate thing now, and
|
||||
* "undo" would have to guess whether to delete it.
|
||||
*/
|
||||
setState(userId: number, id: number, state: 'new' | 'dismissed'): DawarichSuggestion | null {
|
||||
async setState(userId: number, id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion | null> {
|
||||
const row = this.db.get<{ id: number; state: string }>(
|
||||
'SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
|
||||
id,
|
||||
@@ -256,7 +256,7 @@ export class DawarichSuggestionsService {
|
||||
this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id);
|
||||
|
||||
const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined;
|
||||
this.markAccepted(row.id, 'place', { placeId: id });
|
||||
await this.markAccepted(row.id, 'place', { placeId: id });
|
||||
// Re-read, so what goes out on the wire carries the source: everyone else
|
||||
// on the trip should see the Dawarich mark on it too, not only the person
|
||||
// who accepted it.
|
||||
@@ -277,7 +277,7 @@ export class DawarichSuggestionsService {
|
||||
}
|
||||
|
||||
return {
|
||||
suggestion: this.getOne(userId, row.id)!,
|
||||
suggestion: (await this.getOne(userId, row.id))!,
|
||||
createdPlaceId: placeId,
|
||||
createdJournalEntryId: null,
|
||||
bucketListItemId: null,
|
||||
@@ -331,11 +331,11 @@ export class DawarichSuggestionsService {
|
||||
sid,
|
||||
);
|
||||
if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404);
|
||||
this.markAccepted(row.id, 'journal', { journalEntryId: created.id });
|
||||
await this.markAccepted(row.id, 'journal', { journalEntryId: created.id });
|
||||
return created;
|
||||
});
|
||||
return {
|
||||
suggestion: this.getOne(userId, row.id)!,
|
||||
suggestion: (await this.getOne(userId, row.id))!,
|
||||
createdPlaceId: null,
|
||||
createdJournalEntryId: entry.id,
|
||||
bucketListItemId: null,
|
||||
@@ -343,7 +343,7 @@ export class DawarichSuggestionsService {
|
||||
}
|
||||
|
||||
/** A stay ticks off a wish. The wish must be the caller's own. */
|
||||
private acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): DawarichAcceptResult {
|
||||
private async acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): Promise<DawarichAcceptResult> {
|
||||
const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id;
|
||||
if (!itemId) {
|
||||
throw new AcceptError('bucket_required', 'A bucket-list entry is required', 400);
|
||||
@@ -355,18 +355,18 @@ export class DawarichSuggestionsService {
|
||||
);
|
||||
if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404);
|
||||
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
"UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?",
|
||||
row.started_at,
|
||||
itemId,
|
||||
userId,
|
||||
);
|
||||
this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId });
|
||||
await this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId });
|
||||
});
|
||||
|
||||
return {
|
||||
suggestion: this.getOne(userId, row.id)!,
|
||||
suggestion: (await this.getOne(userId, row.id))!,
|
||||
createdPlaceId: null,
|
||||
createdJournalEntryId: null,
|
||||
bucketListItemId: itemId,
|
||||
@@ -400,11 +400,11 @@ export class DawarichSuggestionsService {
|
||||
* answerable at all: without it there is nothing to compare the current hash
|
||||
* against, and the flag would either never fire or fire forever.
|
||||
*/
|
||||
private markAccepted(
|
||||
private async markAccepted(
|
||||
id: number,
|
||||
target: 'place' | 'journal' | 'bucket_list',
|
||||
ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
this.db.run(
|
||||
`UPDATE dawarich_visit_suggestions
|
||||
SET state = 'accepted', target = ?, accepted_place_id = ?,
|
||||
@@ -435,7 +435,7 @@ export class DawarichSuggestionsService {
|
||||
* eighty wishes must not be told their other thirty had no match.
|
||||
*/
|
||||
async scanBucketList(userId: number): Promise<DawarichBucketScan> {
|
||||
const creds = this.dawarich.getCredentials(userId);
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
|
||||
|
||||
const items = this.db.all<BucketRow>(
|
||||
@@ -515,17 +515,17 @@ export class DawarichSuggestionsService {
|
||||
}
|
||||
|
||||
/** Tick off wishes the user confirmed after reading a scan. */
|
||||
confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): number {
|
||||
async confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): Promise<number> {
|
||||
const now = new Date().toISOString();
|
||||
let updated = 0;
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const itemId of itemIds) {
|
||||
// The date is the one the wish was actually reached on. A wish somebody
|
||||
// got to in 2023 ticked off with today's date is a wrong entry in a list
|
||||
// people keep for years, so "now" is only the answer when nothing else
|
||||
// knows better: the caller's own value first, then the stay this wish
|
||||
// was matched to, then the clock.
|
||||
const stamp = visitedAt ?? this.matchedStayStart(userId, itemId) ?? now;
|
||||
const stamp = visitedAt ?? (await this.matchedStayStart(userId, itemId)) ?? now;
|
||||
const result = this.db.run(
|
||||
"UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at IS NULL",
|
||||
stamp,
|
||||
@@ -539,7 +539,7 @@ export class DawarichSuggestionsService {
|
||||
}
|
||||
|
||||
/** When the stay that claimed this wish began, if one did. */
|
||||
private matchedStayStart(userId: number, itemId: number): string | null {
|
||||
private async matchedStayStart(userId: number, itemId: number): Promise<string | null> {
|
||||
const row = this.db.get<{ started_at: string }>(
|
||||
`SELECT started_at FROM dawarich_visit_suggestions
|
||||
WHERE user_id = ? AND matched_bucket_list_item_id = ?
|
||||
@@ -551,7 +551,7 @@ export class DawarichSuggestionsService {
|
||||
}
|
||||
|
||||
/** Undo a tick. Clears the source with it, so the entry reads as untouched again. */
|
||||
clearBucketVisit(userId: number, itemId: number): boolean {
|
||||
async clearBucketVisit(userId: number, itemId: number): Promise<boolean> {
|
||||
const result = this.db.run(
|
||||
'UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?',
|
||||
itemId,
|
||||
@@ -577,7 +577,7 @@ export class DawarichSuggestionsService {
|
||||
* country the user went to, and silently losing it would be the worse bug.
|
||||
*/
|
||||
async atlasSuggestions(userId: number, from: Date, to: Date): Promise<DawarichAtlasSuggestions> {
|
||||
const creds = this.dawarich.getCredentials(userId);
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
|
||||
|
||||
const countries = await this.client.listVisitedCities(creds, from, to);
|
||||
|
||||
@@ -25,7 +25,7 @@ export class DawarichSyncJob implements OnApplicationBootstrap {
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
const value = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@trek/shared';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { logError, logInfo } from '../audit/audit-log.logger';
|
||||
import { getCountryFromCoords } from '../atlas/atlas-geo';
|
||||
@@ -70,6 +71,7 @@ export class DawarichSyncService {
|
||||
private readonly addons: AddonsService,
|
||||
private readonly client: DawarichClient,
|
||||
private readonly dawarich: DawarichService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/** The addon gate, evaluated per tick so an admin toggle lands without a restart. */
|
||||
@@ -83,7 +85,7 @@ export class DawarichSyncService {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const userIds = this.dawarich.listSyncableUserIds();
|
||||
const userIds = await this.dawarich.listSyncableUserIds();
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await this.syncUser(userId);
|
||||
@@ -133,21 +135,21 @@ export class DawarichSyncService {
|
||||
/** The body of a sync, with the guard above already held. */
|
||||
private async syncUserOnce(userId: number): Promise<DawarichSyncOutcome> {
|
||||
if (!(await this.syncGloballyEnabled())) {
|
||||
this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
|
||||
await this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
|
||||
return { state: 'failed', created: 0, updated: 0, missing: 0 };
|
||||
}
|
||||
|
||||
const creds = this.dawarich.getCredentials(userId);
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
if (!creds) {
|
||||
this.dawarich.recordSyncResult(userId, 'failed', 'not_connected');
|
||||
await this.dawarich.recordSyncResult(userId, 'failed', 'not_connected');
|
||||
return { state: 'failed', created: 0, updated: 0, missing: 0 };
|
||||
}
|
||||
|
||||
const trips = this.listTripsToSync(userId);
|
||||
const trips = await this.listTripsToSync(userId);
|
||||
if (trips.length === 0) {
|
||||
// Nothing to ask about is a successful sync, not a failure — otherwise a
|
||||
// user with no dated trips sees a permanent red badge for doing nothing wrong.
|
||||
this.dawarich.recordSyncResult(userId, 'ok', null);
|
||||
await this.dawarich.recordSyncResult(userId, 'ok', null);
|
||||
return { state: 'ok', created: 0, updated: 0, missing: 0 };
|
||||
}
|
||||
|
||||
@@ -181,14 +183,14 @@ export class DawarichSyncService {
|
||||
|
||||
const state: DawarichSyncState =
|
||||
failures === 0 ? 'ok' : failures === trips.length ? 'failed' : 'partial';
|
||||
this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError);
|
||||
await this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError);
|
||||
|
||||
// The probe is cheap next to the windows just fetched, and re-running it is
|
||||
// how a Dawarich upgrade that finally ships `updated_at` starts being used
|
||||
// without anyone reconnecting.
|
||||
if (state !== 'failed') {
|
||||
try {
|
||||
this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds));
|
||||
await this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds));
|
||||
} catch {
|
||||
// Capabilities are an optimisation; a failed probe is not a failed sync.
|
||||
}
|
||||
@@ -209,7 +211,7 @@ export class DawarichSyncService {
|
||||
* generous rather than tight, because someone who connects Dawarich for the
|
||||
* first time wants their last holiday filled in, not just today's.
|
||||
*/
|
||||
private listTripsToSync(userId: number): TripRow[] {
|
||||
private async listTripsToSync(userId: number): Promise<TripRow[]> {
|
||||
return this.db.all<TripRow>(
|
||||
`SELECT DISTINCT t.id, t.start_date, t.end_date
|
||||
FROM trips t
|
||||
@@ -301,7 +303,7 @@ export class DawarichSyncService {
|
||||
new Date().toISOString(),
|
||||
);
|
||||
created++;
|
||||
this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
|
||||
await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -336,7 +338,7 @@ export class DawarichSyncService {
|
||||
existing.id,
|
||||
);
|
||||
if (changed) updated++;
|
||||
this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
|
||||
await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -355,7 +357,7 @@ export class DawarichSyncService {
|
||||
if (changed) updated++;
|
||||
}
|
||||
|
||||
const missing = this.flagMissing(userId, tripId, from, to, seenIds);
|
||||
const missing = await this.flagMissing(userId, tripId, from, to, seenIds);
|
||||
return { created, updated, missing };
|
||||
}
|
||||
|
||||
@@ -368,13 +370,13 @@ export class DawarichSyncService {
|
||||
* say "this is gone in Dawarich" next to an entry they wrote. Deleting that
|
||||
* would delete their work.
|
||||
*/
|
||||
private flagMissing(
|
||||
private async flagMissing(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
from: Date,
|
||||
to: Date,
|
||||
seenIds: Set<string>,
|
||||
): number {
|
||||
): Promise<number> {
|
||||
// Bounded on `local_date`, not on `started_at`. Dawarich renders a visit's
|
||||
// start as local wall-clock plus an offset (`2026-09-01T14:23:00+02:00`)
|
||||
// while the window is UTC (`…Z`), and comparing those two shapes as strings
|
||||
@@ -398,7 +400,7 @@ export class DawarichSyncService {
|
||||
if (gone.length === 0) return 0;
|
||||
|
||||
const stamp = new Date().toISOString();
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const row of gone) {
|
||||
if (row.state === 'new') {
|
||||
this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id);
|
||||
@@ -423,13 +425,13 @@ export class DawarichSyncService {
|
||||
* from going past it. The link is only a hint; ticking the wish off still
|
||||
* needs the user.
|
||||
*/
|
||||
private matchBucketList(
|
||||
private async matchBucketList(
|
||||
userId: number,
|
||||
sourceVisitId: string,
|
||||
lat: number | null,
|
||||
lng: number | null,
|
||||
durationMinutes: number,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (lat === null || lng === null) return;
|
||||
if (durationMinutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) return;
|
||||
|
||||
@@ -456,7 +458,7 @@ export class DawarichSyncService {
|
||||
}
|
||||
if (!best) return;
|
||||
|
||||
this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes);
|
||||
await this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -473,14 +475,14 @@ export class DawarichSyncService {
|
||||
* Every previous claim on the same wish is cleared, so re-running a sync after
|
||||
* the recordings change cannot leave two stays holding it.
|
||||
*/
|
||||
private claimWish(
|
||||
private async claimWish(
|
||||
userId: number,
|
||||
sourceVisitId: string,
|
||||
wish: { id: number; lat: number; lng: number; distance: number },
|
||||
lat: number,
|
||||
lng: number,
|
||||
durationMinutes: number,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const holders = this.db.all<{
|
||||
id: number;
|
||||
source_visit_id: string;
|
||||
@@ -505,7 +507,7 @@ export class DawarichSyncService {
|
||||
if (holderWins) return;
|
||||
}
|
||||
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?',
|
||||
userId,
|
||||
|
||||
@@ -91,7 +91,7 @@ export class DawarichTracksService {
|
||||
toIso: string,
|
||||
offsetMinutes = offsetMinutesOf(fromIso),
|
||||
): Promise<DawarichTrack> {
|
||||
const creds = this.dawarich.getCredentials(userId);
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
if (!creds) return emptyTrack();
|
||||
|
||||
const from = new Date(fromIso);
|
||||
@@ -129,7 +129,7 @@ export class DawarichTracksService {
|
||||
to: Date,
|
||||
offsetMinutes: number,
|
||||
): Promise<DawarichTrack> {
|
||||
const capabilities = this.dawarich.getCapabilities(userId);
|
||||
const capabilities = await this.dawarich.getCapabilities(userId);
|
||||
|
||||
if (capabilities?.tracks !== false) {
|
||||
try {
|
||||
|
||||
@@ -148,12 +148,12 @@ export class DawarichController {
|
||||
|
||||
@Put('suggestions/:id/state')
|
||||
@HttpCode(200)
|
||||
setSuggestionState(
|
||||
async setSuggestionState(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() body: DawarichSuggestionStateDto,
|
||||
) {
|
||||
const updated = this.suggestions.setState(user.id, parseId(id), body.state);
|
||||
const updated = await this.suggestions.setState(user.id, parseId(id), body.state);
|
||||
if (!updated) throw new HttpException({ error: 'Suggestion not found' }, 404);
|
||||
return updated;
|
||||
}
|
||||
@@ -169,14 +169,14 @@ export class DawarichController {
|
||||
|
||||
@Post('bucket-list/confirm')
|
||||
@HttpCode(200)
|
||||
confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) {
|
||||
return { updated: this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) };
|
||||
async confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) {
|
||||
return { updated: await this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) };
|
||||
}
|
||||
|
||||
@Delete('bucket-list/:itemId/visit')
|
||||
@HttpCode(200)
|
||||
clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) {
|
||||
const cleared = this.suggestions.clearBucketVisit(user.id, parseId(itemId));
|
||||
async clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) {
|
||||
const cleared = await this.suggestions.clearBucketVisit(user.id, parseId(itemId));
|
||||
if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@ export class DawarichMcp {
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
listSuggestions(
|
||||
async listSuggestions(
|
||||
{ tripId, state, limit }: { tripId?: number; state?: 'new' | 'accepted' | 'dismissed'; limit?: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const all = this.suggestions.list(ctx.userId, { tripId, state });
|
||||
const all = await this.suggestions.list(ctx.userId, { tripId, state });
|
||||
const cap = limit ?? DEFAULT_LIMIT;
|
||||
const page = all.suggestions.slice(0, cap);
|
||||
return ok({
|
||||
@@ -178,7 +178,7 @@ export class DawarichMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
|
||||
const updated = await this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
|
||||
if (!updated) return errorResult('Suggestion not found');
|
||||
return ok({ suggestion: updated });
|
||||
}
|
||||
@@ -229,9 +229,9 @@ export class DawarichMcp {
|
||||
}
|
||||
|
||||
/** The MCP echo of the controller's error shaping: a refusal, not an exception. */
|
||||
private run<T>(action: () => T) {
|
||||
private async run<T>(action: () => Promise<T>) {
|
||||
try {
|
||||
return ok(action() as object);
|
||||
return ok((await action()) as object);
|
||||
} catch (err) {
|
||||
if (err instanceof AcceptError) return errorResult(err.message);
|
||||
throw err;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
|
||||
@@ -33,9 +34,10 @@ export class DawarichService {
|
||||
private readonly db: DatabaseService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly client: DawarichClient,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
private readRow(userId: number): ConnRow | undefined {
|
||||
private async readRow(userId: number): Promise<ConnRow | undefined> {
|
||||
return this.db.get<ConnRow>(
|
||||
`SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled,
|
||||
last_sync_at, last_sync_state, last_sync_error, capabilities
|
||||
@@ -45,8 +47,8 @@ export class DawarichService {
|
||||
}
|
||||
|
||||
/** Decrypted credentials for an outbound call, or null when nothing is connected. */
|
||||
getCredentials(userId: number): DawarichCreds | null {
|
||||
const row = this.readRow(userId);
|
||||
async getCredentials(userId: number): Promise<DawarichCreds | null> {
|
||||
const row = await this.readRow(userId);
|
||||
if (!row?.url || !row?.api_key) return null;
|
||||
const apiKey = decrypt_api_key(row.api_key);
|
||||
if (!apiKey) return null;
|
||||
@@ -54,13 +56,13 @@ export class DawarichService {
|
||||
}
|
||||
|
||||
/** True when the user has both an address and a key on file. */
|
||||
isConnected(userId: number): boolean {
|
||||
const row = this.readRow(userId);
|
||||
async isConnected(userId: number): Promise<boolean> {
|
||||
const row = await this.readRow(userId);
|
||||
return !!(row?.url && row?.api_key);
|
||||
}
|
||||
|
||||
/** Every user whose connection is usable and whose background sync is on. */
|
||||
listSyncableUserIds(): number[] {
|
||||
async listSyncableUserIds(): Promise<number[]> {
|
||||
const rows = this.db.all<{ user_id: number }>(
|
||||
`SELECT user_id FROM dawarich_connections
|
||||
WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL`,
|
||||
@@ -69,8 +71,8 @@ export class DawarichService {
|
||||
}
|
||||
|
||||
/** The connection as the settings card shows it. The key is a mask, always. */
|
||||
getConnection(userId: number): DawarichConnection {
|
||||
const row = this.readRow(userId);
|
||||
async getConnection(userId: number): Promise<DawarichConnection> {
|
||||
const row = await this.readRow(userId);
|
||||
return {
|
||||
url: row?.url || '',
|
||||
apiKeyMasked: row?.api_key ? DAWARICH_KEY_MASK : '',
|
||||
@@ -86,8 +88,8 @@ export class DawarichService {
|
||||
};
|
||||
}
|
||||
|
||||
getCapabilities(userId: number): DawarichCapabilities | null {
|
||||
return parseCapabilities(this.readRow(userId)?.capabilities);
|
||||
async getCapabilities(userId: number): Promise<DawarichCapabilities | null> {
|
||||
return parseCapabilities((await this.readRow(userId))?.capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +147,7 @@ export class DawarichService {
|
||||
// All three writes or none: a row that took the new address but not the new
|
||||
// key would point a credential minted for one instance at another one, and
|
||||
// the next sync would send it there.
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
`INSERT INTO dawarich_connections (user_id, url, allow_insecure_tls, sync_enabled, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
@@ -236,7 +238,7 @@ export class DawarichService {
|
||||
allowInsecureTls: boolean,
|
||||
): Promise<DawarichStatus> {
|
||||
const typedKey = (apiKey || '').trim();
|
||||
const stored = this.getCredentials(userId);
|
||||
const stored = await this.getCredentials(userId);
|
||||
const baseUrl = (url || '').trim() || stored?.baseUrl || '';
|
||||
const typed = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : '';
|
||||
|
||||
@@ -258,7 +260,7 @@ export class DawarichService {
|
||||
const capabilities = await this.probeCapabilities(creds);
|
||||
// Only persist against a connection that is actually stored — a test run
|
||||
// from a half-filled form must not overwrite what is on file.
|
||||
if (stored && baseUrl === stored.baseUrl) this.storeCapabilities(userId, capabilities);
|
||||
if (stored && baseUrl === stored.baseUrl) await this.storeCapabilities(userId, capabilities);
|
||||
return {
|
||||
connected: true,
|
||||
visitCount: capabilities.visits ? await this.countRecentVisits(creds) : 0,
|
||||
@@ -335,7 +337,7 @@ export class DawarichService {
|
||||
}
|
||||
}
|
||||
|
||||
storeCapabilities(userId: number, capabilities: DawarichCapabilities): void {
|
||||
async storeCapabilities(userId: number, capabilities: DawarichCapabilities): Promise<void> {
|
||||
this.db.run(
|
||||
'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?',
|
||||
JSON.stringify(capabilities),
|
||||
@@ -343,7 +345,7 @@ export class DawarichService {
|
||||
);
|
||||
}
|
||||
|
||||
recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): void {
|
||||
async recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): Promise<void> {
|
||||
this.db.run(
|
||||
`UPDATE dawarich_connections
|
||||
SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
|
||||
@@ -22,7 +22,7 @@ export function toTraveler(r: { user_id: number; username: string; avatar: strin
|
||||
export class ReservationsReadRepository {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
getReservationWithJoins(id: string | number): ReservationRow | undefined {
|
||||
async getReservationWithJoins(id: string | number): Promise<ReservationRow | undefined> {
|
||||
const row = this.db.get<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
@@ -35,22 +35,22 @@ export class ReservationsReadRepository {
|
||||
WHERE r.id = ?
|
||||
`, id);
|
||||
if (!row) return undefined;
|
||||
row.endpoints = this.loadEndpoints(row.id);
|
||||
row.travelers = this.loadTravelers(row.id);
|
||||
row.endpoints = await this.loadEndpoints(row.id);
|
||||
row.travelers = await this.loadTravelers(row.id);
|
||||
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
|
||||
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
|
||||
row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
|
||||
return row;
|
||||
}
|
||||
|
||||
loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||
async loadEndpoints(reservationId: number): Promise<ReservationEndpoint[]> {
|
||||
return this.db.all<ReservationEndpoint>(
|
||||
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence',
|
||||
reservationId
|
||||
);
|
||||
}
|
||||
|
||||
loadTravelers(reservationId: number | string): ReservationTraveler[] {
|
||||
async loadTravelers(reservationId: number | string): Promise<ReservationTraveler[]> {
|
||||
const rows = this.db.all<ReservationTraveler>(`
|
||||
SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
|
||||
@@ -55,8 +55,8 @@ export class ReservationsController {
|
||||
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { reservations: this.reservations.list(tripId) };
|
||||
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { reservations: await this.reservations.list(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@@ -75,13 +75,13 @@ export class ReservationsController {
|
||||
}
|
||||
await this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
await this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Put('positions')
|
||||
updatePositions(
|
||||
async updatePositions(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: ReservationPositionsDto,
|
||||
@@ -89,7 +89,7 @@ export class ReservationsController {
|
||||
) {
|
||||
// The legacy signature declares day_plan_position required, but the wire
|
||||
// contract tolerates absent values (bind NULL) — see the shared schema.
|
||||
this.reservations.updatePositions(tripId, body.positions as { id: number; day_plan_position: number }[], body.day_id);
|
||||
await this.reservations.updatePositions(tripId, body.positions as { id: number; day_plan_position: number }[], body.day_id);
|
||||
this.reservations.broadcast(tripId, 'reservation:positions', { positions: body.positions, day_id: body.day_id }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export class ReservationsController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const body = rawBody as ReservationBody;
|
||||
const current = this.reservations.getReservation(id, tripId);
|
||||
const current = await this.reservations.getReservation(id, tripId);
|
||||
if (!current) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
}
|
||||
@@ -121,20 +121,20 @@ export class ReservationsController {
|
||||
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
|
||||
void this.airtrailLink.pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
|
||||
}
|
||||
this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || '');
|
||||
await this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
@RequirePermission('reservation_edit')
|
||||
@Put(':id/travelers')
|
||||
updateTravelers(
|
||||
async updateTravelers(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: ReservationTravelersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const result = this.reservations.setTravelers(id, tripId, body.user_ids);
|
||||
const result = await this.reservations.setTravelers(id, tripId, body.user_ids);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Reservation not found' }, 404);
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export class ReservationsController {
|
||||
this.reservations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId);
|
||||
}
|
||||
this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId);
|
||||
this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || '');
|
||||
await this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || '');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export class ReservationsController {
|
||||
* the wrong place.
|
||||
*/
|
||||
private async rejectForeignReferences(tripId: string, body: ReservationBody): Promise<void> {
|
||||
const offenders = this.reservations.referencesOutsideTrip(tripId, body as never);
|
||||
const offenders = await this.reservations.referencesOutsideTrip(tripId, body as never);
|
||||
if (offenders.length > 0) {
|
||||
throw new HttpException({ error: `Not part of this trip: ${offenders.join(', ')}` }, 400);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
@@ -438,9 +438,9 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const existing = this.reservations.getReservation(reservationId, tripId);
|
||||
const existing = await this.reservations.getReservation(reservationId, tripId);
|
||||
if (!existing) return errorResult('Reservation not found.');
|
||||
|
||||
if (place_id != null && !(await this.assignments.placeExists(place_id, tripId)))
|
||||
@@ -469,7 +469,7 @@ export class ReservationsMcp {
|
||||
})
|
||||
async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const { deleted, accommodationDeleted } = await this.reservations.remove(reservationId, tripId);
|
||||
if (!deleted) return errorResult('Reservation not found.');
|
||||
@@ -496,12 +496,12 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
// The service filters the ids against the trip roster on its own, so an
|
||||
// off-trip id cannot be attached; a missing booking is the only failure.
|
||||
const result = this.reservations.setTravelers(String(reservationId), String(tripId), user_ids);
|
||||
const result = await this.reservations.setTravelers(String(reservationId), String(tripId), user_ids);
|
||||
if (!result) return errorResult('Reservation not found.');
|
||||
|
||||
this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers });
|
||||
@@ -534,7 +534,7 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
// The service scopes the write to the trip on its own, so a foreign id is
|
||||
@@ -543,7 +543,7 @@ export class ReservationsMcp {
|
||||
if (dayId && !(await this.days.getDay(dayId, tripId)))
|
||||
return errorResult('dayId does not belong to this trip.');
|
||||
|
||||
this.reservations.updatePositions(tripId, positions, dayId);
|
||||
await this.reservations.updatePositions(tripId, positions, dayId);
|
||||
this.guards.safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -571,9 +571,9 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const current = this.reservations.getReservation(reservationId, tripId);
|
||||
const current = await this.reservations.getReservation(reservationId, tripId);
|
||||
if (!current) return errorResult('Reservation not found.');
|
||||
if (current.type !== 'hotel') return errorResult('Reservation is not of type hotel.');
|
||||
|
||||
@@ -617,7 +617,7 @@ export class ReservationsMcp {
|
||||
access: { group: 'reservations', mode: 'read' },
|
||||
})
|
||||
async listUpcomingReservations({ limit }: { limit?: number }, ctx: McpContext) {
|
||||
return ok({ reservations: this.reservations.listUpcoming(ctx.userId, limit) });
|
||||
return ok({ reservations: await this.reservations.listUpcoming(ctx.userId, limit) });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
@@ -629,7 +629,7 @@ export class ReservationsMcp {
|
||||
})
|
||||
async tripReservationsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.reservations.verifyTripAccess(id, ctx.userId)) {
|
||||
if (id === null || !(await this.reservations.verifyTripAccess(id, ctx.userId))) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
@@ -638,7 +638,7 @@ export class ReservationsMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const reservations = this.reservations.list(id);
|
||||
const reservations = await this.reservations.list(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
@@ -695,7 +695,7 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR);
|
||||
@@ -803,12 +803,12 @@ export class ReservationsMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR);
|
||||
|
||||
const existing = this.reservations.getReservation(reservationId, tripId);
|
||||
const existing = await this.reservations.getReservation(reservationId, tripId);
|
||||
if (!existing) return errorResult('Transport not found.');
|
||||
|
||||
const resolvedType = type ?? existing.type;
|
||||
@@ -841,7 +841,7 @@ export class ReservationsMcp {
|
||||
type: resolvedType,
|
||||
// Endpoints the caller did not replace stay the geometry the legs run
|
||||
// over, so read them back (the row carries them) instead of guessing.
|
||||
endpoints: resolvedEndpoints ?? (this.reservations.getReservationWithJoins(reservationId)?.endpoints ?? []).map(e => ({
|
||||
endpoints: resolvedEndpoints ?? ((await this.reservations.getReservationWithJoins(reservationId))?.endpoints ?? []).map(e => ({
|
||||
role: e.role, sequence: e.sequence, name: e.name, code: e.code,
|
||||
lat: e.lat, lng: e.lng, timezone: e.timezone,
|
||||
local_time: e.local_time, local_date: e.local_date,
|
||||
@@ -899,7 +899,7 @@ export class ReservationsMcp {
|
||||
})
|
||||
async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const { deleted } = await this.reservations.remove(reservationId, tripId);
|
||||
if (!deleted) return errorResult('Transport not found.');
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ReservationsRpc {
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
await this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? '');
|
||||
await this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class ReservationsRpc {
|
||||
const input = parsed.data as Record<string, unknown>;
|
||||
this.requireValidEndpoints(input.endpoints);
|
||||
await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
const current = this.reservations.getReservation(String(reservationId), String(tripId));
|
||||
const current = await this.reservations.getReservation(String(reservationId), String(tripId));
|
||||
if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
|
||||
await this.requireOwnReferences(tripId, input);
|
||||
const { reservation, accommodationChanged } = await this.reservations.update(String(reservationId), String(tripId), input as never, current as never);
|
||||
@@ -67,7 +67,7 @@ export class ReservationsRpc {
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
await this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:updated', { reservation }, undefined);
|
||||
this.notifyBooking(actor, tripId, i.title || cur.title, i.type || cur.type || '');
|
||||
await this.notifyBooking(actor, tripId, i.title || cur.title, i.type || cur.type || '');
|
||||
return reservation;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class ReservationsRpc {
|
||||
if (accommodationDeleted) this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined);
|
||||
if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined);
|
||||
this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId }, undefined);
|
||||
this.notifyBooking(actor, tripId, deleted.title, deleted.type || '');
|
||||
await this.notifyBooking(actor, tripId, deleted.title, deleted.type || '');
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -110,14 +110,14 @@ export class ReservationsRpc {
|
||||
* so it is named here as well, after the ownership check.
|
||||
*/
|
||||
private async requireOwnReferences(tripId: number, input: Record<string, unknown>): Promise<void> {
|
||||
const offenders = this.reservations.referencesOutsideTrip(String(tripId), input as never);
|
||||
const offenders = await this.reservations.referencesOutsideTrip(String(tripId), input as never);
|
||||
if (offenders.length > 0) throw new ForbiddenResource(`not part of trip ${tripId}: ${offenders.join(', ')}`);
|
||||
const unknown = await this.reservations.unresolvedReferences(String(tripId), input as never);
|
||||
if (unknown.length > 0) throw new BadParams(`unknown reference: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
/** Fire-and-forget, exactly as the REST controller sends it, so it never blocks the write. */
|
||||
private notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
|
||||
this.reservations.notifyBookingChange(tripId, actingUserId, booking, type);
|
||||
private async notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): Promise<void> {
|
||||
await this.reservations.notifyBookingChange(tripId, actingUserId, booking, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export class ReservationsService {
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
|
||||
notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): void {
|
||||
async notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): Promise<void> {
|
||||
// Injected, not a lazy import of the old notifications bridge. The laziness bought
|
||||
// nothing the module graph does not already give — NotificationsModule
|
||||
// reaches nothing in this direction — and it hid the edge while handing the
|
||||
@@ -212,7 +212,7 @@ export class ReservationsService {
|
||||
}
|
||||
}
|
||||
|
||||
loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||
async loadEndpointsByTrip(tripId: string | number): Promise<Map<number, ReservationEndpoint[]>> {
|
||||
const rows = this.db.all<ReservationEndpoint>(`
|
||||
SELECT e.* FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
@@ -229,7 +229,7 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
/** Users assignable on a trip: its members (guests included) plus the owner. */
|
||||
private assignableUserIds(tripId: string | number): Set<number> {
|
||||
private async assignableUserIds(tripId: string | number): Promise<Set<number>> {
|
||||
const members = this.db.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ?', tripId);
|
||||
const ids = new Set(members.map(m => m.user_id));
|
||||
const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
|
||||
@@ -237,7 +237,7 @@ export class ReservationsService {
|
||||
return ids;
|
||||
}
|
||||
|
||||
loadTravelersByTrip(tripId: string | number): Map<number, ReservationTraveler[]> {
|
||||
async loadTravelersByTrip(tripId: string | number): Promise<Map<number, ReservationTraveler[]>> {
|
||||
const rows = this.db.all<ReservationTraveler & { reservation_id: number }>(`
|
||||
SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
|
||||
FROM reservation_travelers rt
|
||||
@@ -255,7 +255,7 @@ export class ReservationsService {
|
||||
return map;
|
||||
}
|
||||
|
||||
loadTravelers(reservationId: number | string): ReservationTraveler[] {
|
||||
async loadTravelers(reservationId: number | string): Promise<ReservationTraveler[]> {
|
||||
return this.reads.loadTravelers(reservationId);
|
||||
}
|
||||
|
||||
@@ -264,10 +264,10 @@ export class ReservationsService {
|
||||
* accepted — ids that aren't on the trip are silently dropped so a stale client
|
||||
* can't leak a cross-trip user. #1517.
|
||||
*/
|
||||
setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void {
|
||||
const allowed = this.assignableUserIds(tripId);
|
||||
async setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): Promise<void> {
|
||||
const allowed = await this.assignableUserIds(tripId);
|
||||
const ids = [...new Set(userIds)].filter(uid => allowed.has(uid));
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId);
|
||||
if (ids.length > 0) {
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
|
||||
@@ -277,10 +277,10 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
/** Assign trip members / named guests to a reservation (#1517). Null when off-trip. */
|
||||
setTravelers(id: string, tripId: string, userIds: number[]) {
|
||||
if (!this.getReservation(id, tripId)) return null;
|
||||
this.setReservationTravelers(id, tripId, userIds);
|
||||
return { travelers: this.loadTravelers(id), reservation: this.getReservationWithJoins(Number(id)) };
|
||||
async setTravelers(id: string, tripId: string, userIds: number[]) {
|
||||
if (!(await this.getReservation(id, tripId))) return null;
|
||||
await this.setReservationTravelers(id, tripId, userIds);
|
||||
return { travelers: await this.loadTravelers(id), reservation: await this.getReservationWithJoins(Number(id)) };
|
||||
}
|
||||
|
||||
// Resolve the day row whose date matches the date portion of an ISO-ish
|
||||
@@ -288,11 +288,11 @@ export class ReservationsService {
|
||||
// `reservation_time` / `reservation_end_time` so non-transport bookings
|
||||
// (tours, restaurants, events, ...) end up on the right day in the UI,
|
||||
// which now filters by day_id instead of reservation_time.
|
||||
private resolveDayIdFromTime(
|
||||
private async resolveDayIdFromTime(
|
||||
tripId: string | number,
|
||||
time: string | null | undefined,
|
||||
clampToNearest = true,
|
||||
): number | null {
|
||||
): Promise<number | null> {
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
@@ -318,7 +318,7 @@ export class ReservationsService {
|
||||
// the new range is left untouched. Hotels linked to a day_accommodation are excluded here —
|
||||
// resyncAccommodationDays re-anchors the accommodation span and its linked reservation;
|
||||
// unlinked dated hotels (e.g. imported ones) re-anchor like any other booking.
|
||||
resyncReservationDays(tripId: string | number): void {
|
||||
async resyncReservationDays(tripId: string | number): Promise<void> {
|
||||
const rows = this.db.all<{
|
||||
id: number; reservation_time: string | null; reservation_end_time: string | null;
|
||||
day_id: number | null; end_day_id: number | null;
|
||||
@@ -329,12 +329,12 @@ export class ReservationsService {
|
||||
tripId
|
||||
);
|
||||
const update = this.db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const r of rows) {
|
||||
const newDayId = this.resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
const newDayId = await this.resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
if (newDayId == null) continue;
|
||||
const newEndDayId = r.reservation_end_time
|
||||
? (this.resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
|
||||
? ((await this.resolveDayIdFromTime(tripId, r.reservation_end_time, false)) ?? r.end_day_id)
|
||||
: r.end_day_id;
|
||||
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
|
||||
update.run(newDayId, newEndDayId, r.id);
|
||||
@@ -343,7 +343,7 @@ export class ReservationsService {
|
||||
});
|
||||
}
|
||||
|
||||
private saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
private async saveEndpoints(reservationId: number, endpoints: EndpointInput[]): Promise<void> {
|
||||
// Run the transaction through DatabaseService (which re-derives the
|
||||
// statement from the injected connection on each call). The bridge
|
||||
// instance is built over the reinitialize-proof `db` Proxy, so a
|
||||
@@ -351,7 +351,7 @@ export class ReservationsService {
|
||||
// connection can't leave this holding a dead handle — the legacy module
|
||||
// bound its transaction lazily per call for the same reason ("The
|
||||
// database connection is not open").
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run('DELETE FROM reservation_endpoints WHERE reservation_id = ?', reservationId);
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
@@ -369,7 +369,7 @@ export class ReservationsService {
|
||||
});
|
||||
}
|
||||
|
||||
list(tripId: string | number) {
|
||||
async list(tripId: string | number) {
|
||||
const reservations = this.db.all<ReservationRow>(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
|
||||
@@ -396,8 +396,8 @@ export class ReservationsService {
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
|
||||
const endpointsMap = this.loadEndpointsByTrip(tripId);
|
||||
const travelersMap = this.loadTravelersByTrip(tripId);
|
||||
const endpointsMap = await this.loadEndpointsByTrip(tripId);
|
||||
const travelersMap = await this.loadTravelersByTrip(tripId);
|
||||
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
@@ -432,7 +432,7 @@ export class ReservationsService {
|
||||
* old date. Both halves of the report come from reading a hotel's date off
|
||||
* fields hotels do not use.
|
||||
*/
|
||||
listUpcoming(userId: number, limit = 6) {
|
||||
async listUpcoming(userId: number, limit = 6) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -512,7 +512,7 @@ export class ReservationsService {
|
||||
return reservations;
|
||||
}
|
||||
|
||||
getReservationWithJoins(id: string | number) {
|
||||
async getReservationWithJoins(id: string | number) {
|
||||
return this.reads.getReservationWithJoins(id);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ export class ReservationsService {
|
||||
*
|
||||
* Returns the offending field names, empty when the body is clean.
|
||||
*/
|
||||
referencesOutsideTrip(tripId: string | number, data: CreateReservationData | UpdateReservationData): string[] {
|
||||
async referencesOutsideTrip(tripId: string | number, data: CreateReservationData | UpdateReservationData): Promise<string[]> {
|
||||
const offenders: string[] = [];
|
||||
// An id that resolves to nothing is not an offender. accommodation_id in
|
||||
// particular carries no foreign key, so shortening a trip's date range
|
||||
@@ -621,7 +621,7 @@ export class ReservationsService {
|
||||
|
||||
/** Is there still a row behind this id? Existence only — which trip it sits
|
||||
* on is the guards' question, and they answer it before the write. */
|
||||
private referenceExists(table: 'days' | 'places' | 'day_assignments', id: unknown): boolean {
|
||||
private async referenceExists(table: 'days' | 'places' | 'day_assignments', id: unknown): Promise<boolean> {
|
||||
return !!this.db.get(`SELECT id FROM ${table} WHERE id = ?`, id);
|
||||
}
|
||||
|
||||
@@ -635,8 +635,8 @@ export class ReservationsService {
|
||||
* (#2355), and an update rebinds whatever the row already held, so a guard
|
||||
* on the body alone never reaches it.
|
||||
*/
|
||||
private resolvedOrNull(table: 'days' | 'places' | 'day_assignments', id: number | null): number | null {
|
||||
return id != null && this.referenceExists(table, id) ? id : null;
|
||||
private async resolvedOrNull(table: 'days' | 'places' | 'day_assignments', id: number | null): Promise<number | null> {
|
||||
return id != null && (await this.referenceExists(table, id)) ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -655,11 +655,11 @@ export class ReservationsService {
|
||||
* unplaceable class would collapse to (#2355). Its message survives, so the
|
||||
* importer that logs and moves on still names the field.
|
||||
*/
|
||||
private requireResolvableStay(acc: CreateAccommodation): void {
|
||||
private async requireResolvableStay(acc: CreateAccommodation): Promise<void> {
|
||||
const missing: string[] = [];
|
||||
if (acc.place_id && !this.referenceExists('places', acc.place_id)) missing.push('place_id');
|
||||
if (!this.referenceExists('days', acc.start_day_id)) missing.push('start_day_id');
|
||||
if (!this.referenceExists('days', acc.end_day_id)) missing.push('end_day_id');
|
||||
if (acc.place_id && !(await this.referenceExists('places', acc.place_id))) missing.push('place_id');
|
||||
if (!(await this.referenceExists('days', acc.start_day_id))) missing.push('start_day_id');
|
||||
if (!(await this.referenceExists('days', acc.end_day_id))) missing.push('end_day_id');
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Unknown reference: ${missing.map((field) => `create_accommodation.${field}`).join(', ')}`);
|
||||
}
|
||||
@@ -694,7 +694,7 @@ export class ReservationsService {
|
||||
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
this.requireResolvableStay(create_accommodation);
|
||||
await this.requireResolvableStay(create_accommodation);
|
||||
const accResult = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null
|
||||
@@ -715,17 +715,17 @@ export class ReservationsService {
|
||||
const resolvedType = type || 'other';
|
||||
let resolvedDayId: number | null = day_id ?? null;
|
||||
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
|
||||
resolvedDayId = this.resolveDayIdFromTime(tripId, reservation_time);
|
||||
resolvedDayId = await this.resolveDayIdFromTime(tripId, reservation_time);
|
||||
}
|
||||
let resolvedEndDayId: number | null = end_day_id ?? null;
|
||||
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
|
||||
resolvedEndDayId = this.resolveDayIdFromTime(tripId, reservation_end_time);
|
||||
resolvedEndDayId = await this.resolveDayIdFromTime(tripId, reservation_end_time);
|
||||
}
|
||||
|
||||
resolvedDayId = this.resolvedOrNull('days', resolvedDayId);
|
||||
resolvedEndDayId = this.resolvedOrNull('days', resolvedEndDayId);
|
||||
const resolvedPlaceId = this.resolvedOrNull('places', place_id || null);
|
||||
const resolvedAssignmentId = this.resolvedOrNull('day_assignments', assignment_id || null);
|
||||
resolvedDayId = await this.resolvedOrNull('days', resolvedDayId);
|
||||
resolvedEndDayId = await this.resolvedOrNull('days', resolvedEndDayId);
|
||||
const resolvedPlaceId = await this.resolvedOrNull('places', place_id || null);
|
||||
const resolvedAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id || null);
|
||||
|
||||
const result = this.db.run(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review)
|
||||
@@ -751,7 +751,7 @@ export class ReservationsService {
|
||||
);
|
||||
|
||||
if (endpoints && endpoints.length > 0) {
|
||||
this.saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||
await this.saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked. Keyed off the RESOLVED id
|
||||
@@ -775,11 +775,11 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
// The row was just inserted, so the re-select can't miss (legacy typed this any).
|
||||
const reservation = this.getReservationWithJoins(Number(result.lastInsertRowid))!;
|
||||
const reservation = (await this.getReservationWithJoins(Number(result.lastInsertRowid)))!;
|
||||
return { reservation, accommodationCreated, stayMirror };
|
||||
}
|
||||
|
||||
updatePositions(tripId: string | number, positions: { id: number; day_plan_position?: number }[], dayId?: number | string | null) {
|
||||
async updatePositions(tripId: string | number, positions: { id: number; day_plan_position?: number }[], dayId?: number | string | null): Promise<void> {
|
||||
if (dayId) {
|
||||
// Per-day positions for multi-day reservations, scoped the way the legacy
|
||||
// branch below already scopes its update. The table carries no trip_id and
|
||||
@@ -795,7 +795,7 @@ export class ReservationsService {
|
||||
JOIN days d ON d.trip_id = r.trip_id
|
||||
WHERE r.id = ? AND d.id = ? AND r.trip_id = ?
|
||||
`);
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const item of positions) {
|
||||
// position is NOT NULL while the wire contract leaves the value optional.
|
||||
stmt.run(item.day_plan_position ?? 0, item.id, dayId, tripId);
|
||||
@@ -804,7 +804,7 @@ export class ReservationsService {
|
||||
} else {
|
||||
// Legacy: update global position
|
||||
const stmt = this.db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const item of positions) {
|
||||
stmt.run(item.day_plan_position, item.id, tripId);
|
||||
}
|
||||
@@ -812,7 +812,7 @@ export class ReservationsService {
|
||||
}
|
||||
}
|
||||
|
||||
getReservation(id: string | number, tripId: string | number) {
|
||||
async getReservation(id: string | number, tripId: string | number) {
|
||||
return this.db.get<Reservation>('SELECT * FROM reservations WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
}
|
||||
|
||||
@@ -846,7 +846,7 @@ export class ReservationsService {
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (start_day_id && end_day_id) {
|
||||
this.requireResolvableStay(create_accommodation);
|
||||
await this.requireResolvableStay(create_accommodation);
|
||||
if (resolvedAccId) {
|
||||
const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId);
|
||||
this.db.run(
|
||||
@@ -897,7 +897,7 @@ export class ReservationsService {
|
||||
// No day set but we have a date — pin it to the matching day so the booking
|
||||
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||
// the case where an earlier edit cleared day_id).
|
||||
nextDayId = this.resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
nextDayId = await this.resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
@@ -909,15 +909,15 @@ export class ReservationsService {
|
||||
if (end_day_id !== undefined) {
|
||||
nextEndDayId = end_day_id ?? null;
|
||||
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextEndDayId = this.resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
nextEndDayId = await this.resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
} else {
|
||||
nextEndDayId = current.end_day_id ?? null;
|
||||
}
|
||||
|
||||
nextDayId = this.resolvedOrNull('days', nextDayId);
|
||||
nextEndDayId = this.resolvedOrNull('days', nextEndDayId);
|
||||
const nextPlaceId = this.resolvedOrNull('places', place_id !== undefined ? (place_id || null) : (current.place_id ?? null));
|
||||
const nextAssignmentId = this.resolvedOrNull('day_assignments', assignment_id !== undefined ? (assignment_id || null) : (current.assignment_id ?? null));
|
||||
nextDayId = await this.resolvedOrNull('days', nextDayId);
|
||||
nextEndDayId = await this.resolvedOrNull('days', nextEndDayId);
|
||||
const nextPlaceId = await this.resolvedOrNull('places', place_id !== undefined ? (place_id || null) : (current.place_id ?? null));
|
||||
const nextAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id !== undefined ? (assignment_id || null) : (current.assignment_id ?? null));
|
||||
|
||||
this.db.run(`
|
||||
UPDATE reservations SET
|
||||
@@ -959,7 +959,7 @@ export class ReservationsService {
|
||||
);
|
||||
|
||||
if (endpoints !== undefined) {
|
||||
this.saveEndpoints(Number(id), endpoints);
|
||||
await this.saveEndpoints(Number(id), endpoints);
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
@@ -983,7 +983,7 @@ export class ReservationsService {
|
||||
|
||||
// The caller passed the pre-checked `current` row, so the re-select can't
|
||||
// miss (legacy typed this any).
|
||||
const reservation = this.getReservationWithJoins(id)!;
|
||||
const reservation = (await this.getReservationWithJoins(id))!;
|
||||
return { reservation, accommodationChanged, stayMirror };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export class UpcomingReservationsController {
|
||||
constructor(private readonly reservations: ReservationsService) {}
|
||||
|
||||
@Get('upcoming')
|
||||
upcoming(@CurrentUser() user: User) {
|
||||
return { reservations: this.reservations.listUpcoming(user.id) };
|
||||
async upcoming(@CurrentUser() user: User) {
|
||||
return { reservations: await this.reservations.listUpcoming(user.id) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,22 +18,22 @@ export class DayBoundariesController {
|
||||
constructor(private readonly boundaries: DayBoundariesService, private readonly realtime: RealtimeService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('tripId') tripId: string) {
|
||||
return { boundaries: this.boundaries.list(tripId) };
|
||||
async list(@Param('tripId') tripId: string) {
|
||||
return { boundaries: await this.boundaries.list(tripId) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('day_edit')
|
||||
save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = this.boundaries.save(tripId, body);
|
||||
async save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = await this.boundaries.save(tripId, body);
|
||||
this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId);
|
||||
return { boundaries };
|
||||
}
|
||||
|
||||
@Delete(':dayNumber')
|
||||
@RequirePermission('day_edit')
|
||||
remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = this.boundaries.remove(tripId, dayNumber);
|
||||
async remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) {
|
||||
const boundaries = await this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId);
|
||||
return { boundaries };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class DayBoundariesMcp {
|
||||
})
|
||||
async list({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ boundaries: this.boundaries.list(tripId) });
|
||||
return ok({ boundaries: await this.boundaries.list(tripId) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -46,10 +46,10 @@ export class DayBoundariesMcp {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// A stop from another trip is refused by the service, with the reason.
|
||||
return answeringRefusals(() => {
|
||||
return answeringRefusals(async () => {
|
||||
const boundaries = boundary
|
||||
? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber })
|
||||
: this.boundaries.remove(tripId, dayNumber);
|
||||
? await this.boundaries.save(tripId, { ...boundary, day_number: dayNumber })
|
||||
: await this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries });
|
||||
return ok({ boundaries });
|
||||
});
|
||||
|
||||
@@ -6,13 +6,13 @@ import { DatabaseService } from '../database/database.service';
|
||||
export class DayBoundariesService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
list(tripId: string | number): RoadtripDayBoundary[] {
|
||||
async list(tripId: string | number): Promise<RoadtripDayBoundary[]> {
|
||||
return this.db.all<RoadtripDayBoundary>(
|
||||
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId,
|
||||
);
|
||||
}
|
||||
|
||||
save(tripId: string | number, boundary: RoadtripDayBoundary): RoadtripDayBoundary[] {
|
||||
async save(tripId: string | number, boundary: RoadtripDayBoundary): Promise<RoadtripDayBoundary[]> {
|
||||
const belongs = (id: number) => this.db.get(
|
||||
'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId,
|
||||
);
|
||||
@@ -26,7 +26,7 @@ export class DayBoundariesService {
|
||||
return this.list(tripId);
|
||||
}
|
||||
|
||||
remove(tripId: string | number, dayNumber: number): RoadtripDayBoundary[] {
|
||||
async remove(tripId: string | number, dayNumber: number): Promise<RoadtripDayBoundary[]> {
|
||||
this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber);
|
||||
return this.list(tripId);
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@ export class RoadtripPlanService {
|
||||
return {
|
||||
days,
|
||||
visits,
|
||||
settings: this.preferences.read(tripId),
|
||||
settings: await this.preferences.read(tripId),
|
||||
profiles: await this.router.profiles(),
|
||||
vias: this.roadtrip.listForTrip(tripId),
|
||||
tracks: this.roadtrip.tracksForTrip(tripId),
|
||||
boundaries: this.boundaries.list(tripId),
|
||||
vias: await this.roadtrip.listForTrip(tripId),
|
||||
tracks: await this.roadtrip.tracksForTrip(tripId),
|
||||
boundaries: await this.boundaries.list(tripId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ export class RoadtripPreferencesController {
|
||||
constructor(private readonly preferences: RoadtripPreferencesService) {}
|
||||
|
||||
@Get()
|
||||
read(@Param('tripId') tripId: string) {
|
||||
return { tripId: Number(tripId), preferences: this.preferences.read(Number(tripId)) };
|
||||
async read(@Param('tripId') tripId: string) {
|
||||
return { tripId: Number(tripId), preferences: await this.preferences.read(Number(tripId)) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('day_edit')
|
||||
update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto, @Headers('x-socket-id') socketId?: string) {
|
||||
return { tripId: Number(tripId), preferences: this.preferences.update(Number(tripId), patch, socketId) };
|
||||
async update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto, @Headers('x-socket-id') socketId?: string) {
|
||||
return { tripId: Number(tripId), preferences: await this.preferences.update(Number(tripId), patch, socketId) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class RoadtripPreferencesMcp {
|
||||
})
|
||||
async read({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ tripId, settings: this.preferences.read(tripId), scope: 'trip' });
|
||||
return ok({ tripId, settings: await this.preferences.read(tripId), scope: 'trip' });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -60,6 +60,6 @@ export class RoadtripPreferencesMcp {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// A day window that ends before it starts is refused by the service, with the reason.
|
||||
return answeringRefusals(() => ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' }));
|
||||
return answeringRefusals(async () => ok({ tripId, settings: await this.preferences.update(tripId, settings), scope: 'trip' }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ROADTRIP_PREFERENCE_KEYS,
|
||||
@@ -13,9 +14,10 @@ export class RoadtripPreferencesService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
read(tripId: number): RoadtripPreferences {
|
||||
async read(tripId: number): Promise<RoadtripPreferences> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const row of this.db.all<{ key: string; value: string }>(
|
||||
'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?',
|
||||
@@ -35,10 +37,10 @@ export class RoadtripPreferencesService {
|
||||
return roadtripPreferencesSchema.parse(preferences);
|
||||
}
|
||||
|
||||
update(tripId: number, patch: RoadtripPreferences, socketId?: string): RoadtripPreferences {
|
||||
async update(tripId: number, patch: RoadtripPreferences, socketId?: string): Promise<RoadtripPreferences> {
|
||||
const validated = roadtripPreferencesUpdateSchema.parse(patch);
|
||||
const saved = this.db.transaction(() => {
|
||||
const next = { ...this.read(tripId), ...validated };
|
||||
const saved = await this.uow.transactional(async () => {
|
||||
const next = { ...(await this.read(tripId)), ...validated };
|
||||
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
|
||||
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
|
||||
}
|
||||
|
||||
@@ -33,27 +33,27 @@ export class RoadtripController {
|
||||
|
||||
/** Every via of the trip, so the client can route all days without a request per day. */
|
||||
@Get('vias')
|
||||
listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] } {
|
||||
return { vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) };
|
||||
async listAll(@Param('tripId') tripId: string): Promise<{ vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }> {
|
||||
return { vias: await this.roadtrip.listForTrip(tripId), tracks: await this.roadtrip.tracksForTrip(tripId) };
|
||||
}
|
||||
|
||||
@Get('days/:dayId/vias')
|
||||
list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): { vias: RoadtripVia[] } {
|
||||
this.requireDay(dayId, tripId);
|
||||
return { vias: this.roadtrip.listForDay(dayId) };
|
||||
async list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): Promise<{ vias: RoadtripVia[] }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
return { vias: await this.roadtrip.listForDay(dayId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post('days/:dayId/vias')
|
||||
create(
|
||||
async create(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: RoadtripViaCreateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): { via: RoadtripVia } {
|
||||
this.requireDay(dayId, tripId);
|
||||
const via = this.roadtrip.create(dayId, body);
|
||||
this.announce(tripId, dayId, socketId);
|
||||
): Promise<{ via: RoadtripVia }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
const via = await this.roadtrip.create(dayId, body);
|
||||
await this.announce(tripId, dayId, socketId);
|
||||
return { via };
|
||||
}
|
||||
|
||||
@@ -68,26 +68,27 @@ export class RoadtripController {
|
||||
@RequirePermission('day_edit')
|
||||
@Post('days/:dayId/vias/batch')
|
||||
@HttpCode(200)
|
||||
createMany(
|
||||
async createMany(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: RoadtripViaBatchDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): { vias: RoadtripVia[] } {
|
||||
this.requireDay(dayId, tripId);
|
||||
): Promise<{ vias: RoadtripVia[] }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
// Permission is not enough on its own: a place id from somebody else's trip would
|
||||
// otherwise become this day's label, and a place that is not a track would become a
|
||||
// label that can never be drawn.
|
||||
if (body.track && !this.roadtrip.trackExists(body.track.place_id, tripId)) {
|
||||
if (body.track && !(await this.roadtrip.trackExists(body.track.place_id, tripId))) {
|
||||
throw new HttpException({ error: 'Track not found' }, 404);
|
||||
}
|
||||
const vias = this.roadtrip.createMany(dayId, body);
|
||||
this.announce(tripId, dayId, socketId);
|
||||
const vias = await this.roadtrip.createMany(dayId, body);
|
||||
await this.announce(tripId, dayId, socketId);
|
||||
// A batch is how a day starts following a recorded track, so the track it now follows
|
||||
// is news in its own right: the rail draws a badge for it and the map a line.
|
||||
const tracks = await this.roadtrip.tracksForTrip(tripId);
|
||||
this.roadtrip.broadcast(tripId, 'roadtripTrack:changed', {
|
||||
dayId,
|
||||
track: this.roadtrip.tracksForTrip(tripId).find(t => String(t.day_id) === String(dayId)) ?? null,
|
||||
track: tracks.find(t => String(t.day_id) === String(dayId)) ?? null,
|
||||
}, socketId);
|
||||
return { vias };
|
||||
}
|
||||
@@ -102,48 +103,48 @@ export class RoadtripController {
|
||||
@RequirePermission('day_edit')
|
||||
@Put('days/:dayId/vias')
|
||||
@HttpCode(200)
|
||||
reanchor(
|
||||
async reanchor(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Body() body: RoadtripViaReanchorDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): { vias: RoadtripVia[] } {
|
||||
this.requireDay(dayId, tripId);
|
||||
const vias = this.roadtrip.reanchor(dayId, body);
|
||||
this.announce(tripId, dayId, socketId);
|
||||
): Promise<{ vias: RoadtripVia[] }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
const vias = await this.roadtrip.reanchor(dayId, body);
|
||||
await this.announce(tripId, dayId, socketId);
|
||||
return { vias };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put('days/:dayId/vias/:id')
|
||||
@HttpCode(200)
|
||||
update(
|
||||
async update(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: RoadtripViaUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): { via: RoadtripVia } {
|
||||
this.requireDay(dayId, tripId);
|
||||
const via = this.roadtrip.move(id, dayId, body.lat, body.lng, body.after_order_index);
|
||||
): Promise<{ via: RoadtripVia }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
const via = await this.roadtrip.move(id, dayId, body.lat, body.lng, body.after_order_index);
|
||||
if (!via) throw new HttpException({ error: 'Via not found' }, 404);
|
||||
this.announce(tripId, dayId, socketId);
|
||||
await this.announce(tripId, dayId, socketId);
|
||||
return { via };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete('days/:dayId/vias/:id')
|
||||
remove(
|
||||
async remove(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): { success: true } {
|
||||
this.requireDay(dayId, tripId);
|
||||
if (!this.roadtrip.remove(id, dayId)) {
|
||||
): Promise<{ success: true }> {
|
||||
await this.requireDay(dayId, tripId);
|
||||
if (!(await this.roadtrip.remove(id, dayId))) {
|
||||
throw new HttpException({ error: 'Via not found' }, 404);
|
||||
}
|
||||
this.announce(tripId, dayId, socketId);
|
||||
await this.announce(tripId, dayId, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -155,10 +156,10 @@ export class RoadtripController {
|
||||
* routes means a client applies them all the same way. The originating socket is
|
||||
* excluded, so the person dragging does not get their own point handed back mid-drag.
|
||||
*/
|
||||
private announce(tripId: string, dayId: string, socketId: string | undefined): void {
|
||||
private async announce(tripId: string, dayId: string, socketId: string | undefined): Promise<void> {
|
||||
this.roadtrip.broadcast(tripId, 'roadtripVia:changed', {
|
||||
dayId,
|
||||
vias: this.roadtrip.listForDay(dayId),
|
||||
vias: await this.roadtrip.listForDay(dayId),
|
||||
}, socketId);
|
||||
}
|
||||
|
||||
@@ -167,8 +168,8 @@ export class RoadtripController {
|
||||
* Without it a valid day id from someone else's trip would be editable through a trip
|
||||
* the caller does have access to.
|
||||
*/
|
||||
private requireDay(dayId: string, tripId: string): void {
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) {
|
||||
private async requireDay(dayId: string, tripId: string): Promise<void> {
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@ export class RoadtripMcp {
|
||||
async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (dayId != null) {
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
return ok({ vias: this.roadtrip.listForDay(dayId) });
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
return ok({ vias: await this.roadtrip.listForDay(dayId) });
|
||||
}
|
||||
return ok({ vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) });
|
||||
return ok({ vias: await this.roadtrip.listForTrip(tripId), tracks: await this.roadtrip.tracksForTrip(tripId) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -77,9 +77,9 @@ export class RoadtripMcp {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
const via = this.roadtrip.create(dayId, { after_order_index, lat, lng });
|
||||
this.announce(tripId, dayId);
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
const via = await this.roadtrip.create(dayId, { after_order_index, lat, lng });
|
||||
await this.announce(tripId, dayId);
|
||||
return ok({ via });
|
||||
}
|
||||
|
||||
@@ -119,16 +119,17 @@ export class RoadtripMcp {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
// The same check the REST route makes, in the same change: permission alone would let
|
||||
// a place id from another trip, or a place that is not a track at all, become this
|
||||
// day's label.
|
||||
if (track && !this.roadtrip.trackExists(track.place_id, tripId)) return noAccess();
|
||||
const made = this.roadtrip.createMany(dayId, { vias, replace_legs, track });
|
||||
this.announce(tripId, dayId);
|
||||
if (track && !(await this.roadtrip.trackExists(track.place_id, tripId))) return noAccess();
|
||||
const made = await this.roadtrip.createMany(dayId, { vias, replace_legs, track });
|
||||
await this.announce(tripId, dayId);
|
||||
const tracks = await this.roadtrip.tracksForTrip(String(tripId));
|
||||
this.roadtrip.broadcast(String(tripId), 'roadtripTrack:changed', {
|
||||
dayId,
|
||||
track: this.roadtrip.tracksForTrip(String(tripId)).find(t => String(t.day_id) === String(dayId)) ?? null,
|
||||
track: tracks.find(t => String(t.day_id) === String(dayId)) ?? null,
|
||||
}, undefined);
|
||||
return ok({ vias: made });
|
||||
}
|
||||
@@ -157,9 +158,9 @@ export class RoadtripMcp {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
const next = this.roadtrip.reanchor(dayId, { vias, remove });
|
||||
this.announce(tripId, dayId);
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
const next = await this.roadtrip.reanchor(dayId, { vias, remove });
|
||||
await this.announce(tripId, dayId);
|
||||
return ok({ vias: next });
|
||||
}
|
||||
|
||||
@@ -179,9 +180,9 @@ export class RoadtripMcp {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess();
|
||||
if (!this.roadtrip.remove(viaId, dayId)) return noAccess();
|
||||
this.announce(tripId, dayId);
|
||||
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
|
||||
if (!(await this.roadtrip.remove(viaId, dayId))) return noAccess();
|
||||
await this.announce(tripId, dayId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -195,10 +196,10 @@ export class RoadtripMcp {
|
||||
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess();
|
||||
const via = this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index);
|
||||
if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess();
|
||||
const via = await this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index);
|
||||
if (!via) return noAccess();
|
||||
this.announce(input.tripId, input.dayId);
|
||||
await this.announce(input.tripId, input.dayId);
|
||||
return ok({ via });
|
||||
}
|
||||
|
||||
@@ -210,10 +211,10 @@ export class RoadtripMcp {
|
||||
* No originating socket to exclude here — a tool call has no socket of its own, so the
|
||||
* client that asked for it hears about it like everyone else.
|
||||
*/
|
||||
private announce(tripId: number, dayId: number): void {
|
||||
private async announce(tripId: number, dayId: number): Promise<void> {
|
||||
this.roadtrip.broadcast(String(tripId), 'roadtripVia:changed', {
|
||||
dayId,
|
||||
vias: this.roadtrip.listForDay(String(dayId)),
|
||||
vias: await this.roadtrip.listForDay(String(dayId)),
|
||||
}, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
|
||||
/**
|
||||
* Via points: the places a day's drive is made to pass through without stopping.
|
||||
@@ -16,6 +17,7 @@ export class RoadtripService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,7 @@ export class RoadtripService {
|
||||
}
|
||||
|
||||
/** The day exists and belongs to this trip. 404 material, checked before every write. */
|
||||
dayExists(dayId: string | number, tripId: string | number): boolean {
|
||||
async dayExists(dayId: string | number, tripId: string | number): Promise<boolean> {
|
||||
return !!this.db.get<{ id: number }>(
|
||||
'SELECT id FROM days WHERE id = ? AND trip_id = ?',
|
||||
dayId,
|
||||
@@ -42,7 +44,7 @@ export class RoadtripService {
|
||||
);
|
||||
}
|
||||
|
||||
listForDay(dayId: string | number): RoadtripVia[] {
|
||||
async listForDay(dayId: string | number): Promise<RoadtripVia[]> {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
|
||||
FROM roadtrip_vias
|
||||
@@ -53,7 +55,7 @@ export class RoadtripService {
|
||||
}
|
||||
|
||||
/** Every via of a trip, so the client can route all days without one request per day. */
|
||||
listForTrip(tripId: string | number): RoadtripVia[] {
|
||||
async listForTrip(tripId: string | number): Promise<RoadtripVia[]> {
|
||||
return this.db.all<RoadtripVia>(
|
||||
`SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
|
||||
FROM roadtrip_vias v
|
||||
@@ -64,7 +66,7 @@ export class RoadtripService {
|
||||
);
|
||||
}
|
||||
|
||||
create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): RoadtripVia {
|
||||
async create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): Promise<RoadtripVia> {
|
||||
// Appended after whatever already follows that stop, unless the caller says where.
|
||||
const sequence = input.sequence ?? (
|
||||
this.db.get<{ next: number }>(
|
||||
@@ -81,7 +83,7 @@ export class RoadtripService {
|
||||
input.lat,
|
||||
input.lng,
|
||||
);
|
||||
return this.byId(Number(result.lastInsertRowid))!;
|
||||
return (await this.byId(Number(result.lastInsertRowid)))!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,15 +96,15 @@ export class RoadtripService {
|
||||
* `replace_legs` clears by leg rather than by day, so vias the traveller placed by hand
|
||||
* on other legs survive a track being laid on this one.
|
||||
*/
|
||||
createMany(
|
||||
async createMany(
|
||||
dayId: string | number,
|
||||
input: {
|
||||
vias: { after_order_index: number; lat: number; lng: number }[];
|
||||
replace_legs?: number[];
|
||||
track?: { place_id: number; stray_km?: number | null } | null;
|
||||
},
|
||||
): RoadtripVia[] {
|
||||
return this.db.transaction(() => {
|
||||
): Promise<RoadtripVia[]> {
|
||||
return this.uow.transactional(async () => {
|
||||
// Inside the same transaction as the chain it describes. A day that says it follows
|
||||
// a road whose vias never landed is worse than a day that says nothing.
|
||||
if (input.track === null) {
|
||||
@@ -152,7 +154,7 @@ export class RoadtripService {
|
||||
* Read with the vias in one go: both are wanted on every load of road-trip mode, and a
|
||||
* second route for a handful of rows would be a second round trip for nothing.
|
||||
*/
|
||||
tracksForTrip(tripId: string | number): RoadtripDayTrack[] {
|
||||
async tracksForTrip(tripId: string | number): Promise<RoadtripDayTrack[]> {
|
||||
return this.db.all<RoadtripDayTrack>(
|
||||
`SELECT t.day_id, t.place_id, t.stray_km
|
||||
FROM roadtrip_day_tracks t
|
||||
@@ -164,7 +166,7 @@ export class RoadtripService {
|
||||
}
|
||||
|
||||
/** Whether a place is on this trip, and is a track rather than an ordinary place. */
|
||||
trackExists(placeId: number, tripId: string | number): boolean {
|
||||
async trackExists(placeId: number, tripId: string | number): Promise<boolean> {
|
||||
return !!this.db.get<{ id: number }>(
|
||||
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
|
||||
placeId,
|
||||
@@ -173,13 +175,13 @@ export class RoadtripService {
|
||||
}
|
||||
|
||||
/** Moving a via is the whole edit; where it sits in the chain does not change. */
|
||||
move(
|
||||
async move(
|
||||
id: string | number,
|
||||
dayId: string | number,
|
||||
lat: number,
|
||||
lng: number,
|
||||
afterOrderIndex?: number,
|
||||
): RoadtripVia | null {
|
||||
): Promise<RoadtripVia | null> {
|
||||
const existing = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
|
||||
id,
|
||||
@@ -216,11 +218,11 @@ export class RoadtripService {
|
||||
* the day, and a via somebody else deleted in the meantime is not a failure of the
|
||||
* re-anchoring; failing the batch over it would leave the rest of the day mis-pinned.
|
||||
*/
|
||||
reanchor(
|
||||
async reanchor(
|
||||
dayId: string | number,
|
||||
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
|
||||
): RoadtripVia[] {
|
||||
return this.db.transaction(() => {
|
||||
): Promise<RoadtripVia[]> {
|
||||
return this.uow.transactional(async () => {
|
||||
// Read before writing: the OLD anchor is what says which of two merged
|
||||
// legs came first, and after the updates that information is gone.
|
||||
const before = new Map(
|
||||
@@ -286,12 +288,12 @@ export class RoadtripService {
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string | number, dayId: string | number): boolean {
|
||||
async remove(id: string | number, dayId: string | number): Promise<boolean> {
|
||||
const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private byId(id: number): RoadtripVia | null {
|
||||
private async byId(id: number): Promise<RoadtripVia | null> {
|
||||
return this.db.get<RoadtripVia>(
|
||||
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
|
||||
id,
|
||||
|
||||
@@ -249,7 +249,7 @@ export class TransitMcp {
|
||||
needs_review: false,
|
||||
});
|
||||
this.guards.safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || '');
|
||||
await this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || '');
|
||||
return ok({ reservation });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export class TripReadModelService {
|
||||
checked: (packingItems as { checked: number }[]).filter(i => i.checked).length,
|
||||
};
|
||||
|
||||
const reservations = this.reservations.list(tripId);
|
||||
const reservations = await this.reservations.list(tripId);
|
||||
const collab_notes = await this.collab.listNotes(tripId);
|
||||
|
||||
return {
|
||||
@@ -109,7 +109,7 @@ export class TripReadModelService {
|
||||
packingItems: await this.packing.listItems(tripId, viewerId),
|
||||
todoItems: await this.todo.listItems(tripId),
|
||||
budgetItems: await this.budget.listBudgetItems(tripId),
|
||||
reservations: this.reservations.list(tripId),
|
||||
reservations: await this.reservations.list(tripId),
|
||||
files: await this.files.listFiles(tripId, false),
|
||||
accommodations: await this.accommodations.list(tripId),
|
||||
members: [owner, ...(members || [])].filter(Boolean),
|
||||
|
||||
@@ -272,12 +272,12 @@ export class TripsController {
|
||||
}
|
||||
|
||||
@Get(':id/export.ics')
|
||||
exportIcs(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response) {
|
||||
async exportIcs(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response) {
|
||||
if (!this.trips.canAccessTrip(id, user.id)) {
|
||||
throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
}
|
||||
try {
|
||||
const { ics, filename } = this.calendar.exportICS(id);
|
||||
const { ics, filename } = await this.calendar.exportICS(id);
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment'));
|
||||
res.send(ics);
|
||||
|
||||
@@ -533,7 +533,7 @@ export class TripsMcp {
|
||||
async exportTripIcs({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
try {
|
||||
const { ics, filename } = this.calendar.exportICS(tripId);
|
||||
const { ics, filename } = await this.calendar.exportICS(tripId);
|
||||
return ok({ ics, filename });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
|
||||
|
||||
@@ -67,7 +67,7 @@ export class TripsRpc {
|
||||
|
||||
@PluginMethod('trips.getReservations', { permission: 'db:read:trips' })
|
||||
getReservations(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () => this.reservations.list(String(num(params.tripId, 'tripId'))));
|
||||
return this.guards.tripRead(params, ctx, async () => this.reservations.list(String(num(params.tripId, 'tripId'))));
|
||||
}
|
||||
|
||||
@PluginMethod('trips.getDays', { permission: 'db:read:trips' })
|
||||
@@ -95,12 +95,13 @@ export class TripsRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('reservations.listMine', { permission: 'db:read:trips' })
|
||||
listMyReservations(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async listMyReservations(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
if (ctx.actingUserId === undefined) {
|
||||
throw new ForbiddenResource('reservation reads require an authenticated user context');
|
||||
}
|
||||
const trips = this.trips.list(ctx.actingUserId, null) as Array<{ id: number }>;
|
||||
return trips.flatMap((t) => this.reservations.list(String(t.id)));
|
||||
const perTrip = await Promise.all(trips.map((t) => this.reservations.list(String(t.id))));
|
||||
return perTrip.flat();
|
||||
}
|
||||
|
||||
@PluginMethod('trips.members', { permission: 'db:read:trips' })
|
||||
|
||||
@@ -440,7 +440,7 @@ export class TripsService {
|
||||
// Default: generateDays re-dates day rows positionally; re-anchor dated bookings to
|
||||
// the day matching their absolute reservation_time, and accommodations (+ their
|
||||
// linked hotel reservations) to the days now holding their pre-change dates (#1288).
|
||||
this.reservations.resyncReservationDays(tripId);
|
||||
await this.reservations.resyncReservationDays(tripId);
|
||||
await this.days.resyncAccommodationDays(tripId, prevDateByDayId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
|
||||
new DaysMcp(daysService, authService, guards),
|
||||
new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService),
|
||||
new RoadtripMcp(new RoadtripService(dbService, realtimeService, await createTestUnitOfWork(dbService.connection)), dbService, guards, authService, addonsService),
|
||||
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards),
|
||||
new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
|
||||
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
|
||||
|
||||
@@ -68,7 +68,7 @@ const geocodeMock = vi.spyOn(TransitService.prototype, 'geocode');
|
||||
const planMock = vi.spyOn(TransitService.prototype, 'plan');
|
||||
const notifyBookingChangeMock = vi
|
||||
.spyOn(ReservationsService.prototype, 'notifyBookingChange')
|
||||
.mockImplementation(() => {});
|
||||
.mockImplementation(async () => {});
|
||||
|
||||
const from = { name: 'Namba', lat: 34.667, lng: 135.501 };
|
||||
const to = { name: 'Umeda', lat: 34.702, lng: 135.496 };
|
||||
@@ -137,7 +137,7 @@ beforeEach(() => {
|
||||
planMock.mockReset();
|
||||
broadcastMock.mockReset();
|
||||
// mockReset would fall back to the real notification write — keep it stubbed.
|
||||
notifyBookingChangeMock.mockReset().mockImplementation(() => {});
|
||||
notifyBookingChangeMock.mockReset().mockImplementation(async () => {});
|
||||
delete process.env.DEMO_MODE;
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
@@ -39,28 +39,28 @@ describe('TrekPhotoCacheJob', () => {
|
||||
return { job, registrar, cache };
|
||||
}
|
||||
|
||||
it('CSJOB-001 — boot sweep runs immediately, then the 2-hourly server-local cron registers', () => {
|
||||
it('CSJOB-001 — boot sweep runs immediately, then the 2-hourly server-local cron registers', async () => {
|
||||
const { job, registrar, cache } = make();
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(cache.sweepExpired).toHaveBeenCalledTimes(1);
|
||||
expect(registrar.register).toHaveBeenCalledWith('trek-photo-cache', '0 */2 * * *', expect.any(Function), { timezone: 'none' });
|
||||
});
|
||||
|
||||
it('CSJOB-002 — a throwing boot sweep is swallowed (cache dir may not exist yet) and the cron still registers', () => {
|
||||
it('CSJOB-002 — a throwing boot sweep is swallowed (cache dir may not exist yet) and the cron still registers', async () => {
|
||||
const { job, registrar } = make(() => { throw new Error('ENOENT'); });
|
||||
expect(() => job.onApplicationBootstrap()).not.toThrow();
|
||||
expect(registrar.register).toHaveBeenCalled();
|
||||
expect(logMock.logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CSJOB-003 — the test gate skips the boot sweep entirely', () => {
|
||||
it('CSJOB-003 — the test gate skips the boot sweep entirely', async () => {
|
||||
const { job, registrar, cache } = make(vi.fn(), false);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(cache.sweepExpired).not.toHaveBeenCalled();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CSJOB-004 — a throwing tick is contained to the cleanup log line', () => {
|
||||
it('CSJOB-004 — a throwing tick is contained to the cleanup log line', async () => {
|
||||
const { job } = make(() => { throw new Error('disk gone'); });
|
||||
expect(() => job.tick()).not.toThrow();
|
||||
expect(logMock.logError).toHaveBeenCalledWith('Trek photo cache cleanup: disk gone');
|
||||
@@ -75,9 +75,9 @@ describe('PlacePhotoCacheJob', () => {
|
||||
return { job, registrar, cache };
|
||||
}
|
||||
|
||||
it('CSJOB-005 — boot sweep runs immediately, then the nightly app-tz cron registers', () => {
|
||||
it('CSJOB-005 — boot sweep runs immediately, then the nightly app-tz cron registers', async () => {
|
||||
const { job, registrar, cache } = make();
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(cache.sweepOrphans).toHaveBeenCalledTimes(1);
|
||||
expect(registrar.register).toHaveBeenCalledWith('place-photo-cache', '30 3 * * *', expect.any(Function));
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe('PlacePhotoCacheJob', () => {
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Place-photo cache cleanup: removed 3 orphaned file(s)/row(s)');
|
||||
});
|
||||
|
||||
it('CSJOB-007 — a throwing sweep is contained to the cleanup log line, and the gate skips the boot sweep', () => {
|
||||
it('CSJOB-007 — a throwing sweep is contained to the cleanup log line, and the gate skips the boot sweep', async () => {
|
||||
const { job } = make(() => { throw new Error('fs down'); });
|
||||
expect(() => job.sweep()).not.toThrow();
|
||||
expect(logMock.logError).toHaveBeenCalledWith('Place-photo cache cleanup: fs down');
|
||||
@@ -109,9 +109,9 @@ describe('JourneyThumbsJob', () => {
|
||||
return { job, registrar, thumbnails };
|
||||
}
|
||||
|
||||
it('CSJOB-010 — boot sweep runs immediately, then the daily app-tz cron registers', () => {
|
||||
it('CSJOB-010 — boot sweep runs immediately, then the daily app-tz cron registers', async () => {
|
||||
const { job, registrar, thumbnails } = make();
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(thumbnails.sweepOrphanThumbs).toHaveBeenCalledTimes(1);
|
||||
expect(registrar.register).toHaveBeenCalledWith('journey-thumbs', '0 4 * * *', expect.any(Function));
|
||||
});
|
||||
@@ -150,14 +150,14 @@ describe('AirtrailSyncJob', () => {
|
||||
return { job, registrar, airtrail };
|
||||
}
|
||||
|
||||
it('CSJOB-008 — registers */N from the app-setting and logs the banner', () => {
|
||||
it('CSJOB-008 — registers */N from the app-setting and logs the banner', async () => {
|
||||
const { job, registrar } = make('10');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('AirTrail sync: scheduled every 10m');
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/10 * * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('CSJOB-009 — clamps the interval to 1–59 and defaults to 5', () => {
|
||||
it('CSJOB-009 — clamps the interval to 1–59 and defaults to 5', async () => {
|
||||
for (const [setting, minutes] of [
|
||||
[undefined, 5],
|
||||
['0', 5],
|
||||
@@ -168,15 +168,15 @@ describe('AirtrailSyncJob', () => {
|
||||
] as const) {
|
||||
vi.clearAllMocks();
|
||||
const { job, registrar } = make(setting);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', `*/${minutes} * * * *`, expect.any(Function));
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith(`AirTrail sync: scheduled every ${minutes}m`);
|
||||
}
|
||||
});
|
||||
|
||||
it('CSJOB-010 — the gate skips registration and the banner', () => {
|
||||
it('CSJOB-010 — the gate skips registration and the banner', async () => {
|
||||
const { job, registrar } = make('5', false);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -316,21 +316,21 @@ afterAll(() => {
|
||||
// ── list / getOne ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DawarichSuggestionsService — the review list', () => {
|
||||
it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", () => {
|
||||
it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", async () => {
|
||||
const { user: mine } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' });
|
||||
const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
|
||||
const list = svc.list(mine.id, {});
|
||||
const list = await svc.list(mine.id, {});
|
||||
|
||||
expect(list.suggestions.map((s) => s.id)).toEqual([mineId]);
|
||||
expect(list.suggestions.map((s) => s.name)).not.toContain('Their Kitchen');
|
||||
// Asserted in both directions, so an id that happens to line up cannot carry it.
|
||||
expect(svc.list(stranger.id, {}).suggestions.map((s) => s.id)).toEqual([strangerId]);
|
||||
expect((await svc.list(stranger.id, {})).suggestions.map((s) => s.id)).toEqual([strangerId]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', () => {
|
||||
it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -342,24 +342,23 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
seedSuggestion({ userId: user.id, tripId: otherTrip.id });
|
||||
seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id });
|
||||
|
||||
const byTrip = svc
|
||||
.list(user.id, { tripId: trip.id })
|
||||
const byTrip = (await svc.list(user.id, { tripId: trip.id }))
|
||||
.suggestions.map((s) => s.id)
|
||||
.sort((a, b) => a - b);
|
||||
expect(byTrip).toEqual([onTrip, dismissed].sort((a, b) => a - b));
|
||||
|
||||
expect(svc.list(user.id, { tripId: trip.id, state: 'dismissed' }).suggestions.map((s) => s.id)).toEqual([
|
||||
expect((await svc.list(user.id, { tripId: trip.id, state: 'dismissed' })).suggestions.map((s) => s.id)).toEqual([
|
||||
dismissed,
|
||||
]);
|
||||
// Another user's trip id is not a way in: the user clause still applies.
|
||||
expect(svc.list(user.id, { tripId: strangerTrip.id }).suggestions).toEqual([]);
|
||||
expect((await svc.list(user.id, { tripId: strangerTrip.id })).suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', () => {
|
||||
it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedSuggestion({ userId: user.id });
|
||||
|
||||
const list = svc.list(user.id, {});
|
||||
const list = await svc.list(user.id, {});
|
||||
|
||||
expect(dawarichStub.getConnection).toHaveBeenCalledWith(user.id);
|
||||
expect(list.connected).toBe(true);
|
||||
@@ -368,13 +367,13 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
expect(list.lastSyncError).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', () => {
|
||||
it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
|
||||
expect(svc.getOne(user.id, theirs)).toBeNull();
|
||||
expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen');
|
||||
expect(await svc.getOne(user.id, theirs)).toBeNull();
|
||||
expect((await svc.getOne(stranger.id, theirs))?.name).toBe('Their Kitchen');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', async () => {
|
||||
@@ -396,7 +395,7 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
)
|
||||
.run(id);
|
||||
|
||||
const wire = svc.getOne(user.id, id)!;
|
||||
const wire = (await svc.getOne(user.id, id))!;
|
||||
expect(wire.sourceStatus).toBe('confirmed');
|
||||
expect(wire.sourceChanged).toBe(true);
|
||||
expect(wire.sourceMissing).toBe(true);
|
||||
@@ -798,29 +797,29 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
|
||||
|
||||
const back = svc.setState(user.id, id, 'new');
|
||||
const back = await svc.setState(user.id, id, 'new');
|
||||
|
||||
expect(back?.state).toBe('accepted');
|
||||
expect(rowOf(id).state).toBe('accepted');
|
||||
// Dismissing it is refused the same way — the tick it produced is its own thing now.
|
||||
expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('accepted');
|
||||
expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('accepted');
|
||||
expect(rowOf(id).target).toBe('bucket_list');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', () => {
|
||||
it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
|
||||
expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('dismissed');
|
||||
expect(svc.setState(user.id, id, 'new')?.state).toBe('new');
|
||||
expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('dismissed');
|
||||
expect((await svc.setState(user.id, id, 'new'))?.state).toBe('new');
|
||||
});
|
||||
|
||||
it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", () => {
|
||||
it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const theirs = seedSuggestion({ userId: stranger.id });
|
||||
|
||||
expect(svc.setState(user.id, theirs, 'dismissed')).toBeNull();
|
||||
expect(await svc.setState(user.id, theirs, 'dismissed')).toBeNull();
|
||||
expect(rowOf(theirs).state).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -828,7 +827,7 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
// ── bucket-list confirmation ─────────────────────────────────────────────────
|
||||
|
||||
describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', () => {
|
||||
it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const open = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const alreadyTicked = seedBucketItem(user.id, {
|
||||
@@ -837,7 +836,7 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
visitedSource: 'manual',
|
||||
});
|
||||
|
||||
const updated = svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z');
|
||||
const updated = await svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z');
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
@@ -847,20 +846,20 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
expect(bucketOf(alreadyTicked).visited_source).toBe('manual');
|
||||
});
|
||||
|
||||
it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", () => {
|
||||
it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mine = seedBucketItem(user.id);
|
||||
const theirs = seedBucketItem(stranger.id);
|
||||
|
||||
const updated = svc.confirmBucketVisits(user.id, [mine, theirs]);
|
||||
const updated = await svc.confirmBucketVisits(user.id, [mine, theirs]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(theirs).visited_at).toBeNull();
|
||||
expect(bucketOf(mine).visited_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', () => {
|
||||
it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', async () => {
|
||||
// A wish somebody reached in 2023, ticked off with today's date, is a wrong
|
||||
// entry in a list people keep for years. The clock is only the answer when
|
||||
// nothing else knows better.
|
||||
@@ -868,24 +867,24 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' });
|
||||
|
||||
const updated = svc.confirmBucketVisits(user.id, [wish]);
|
||||
const updated = await svc.confirmBucketVisits(user.id, [wish]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(wish).visited_at).toBe('2023-07-14T08:20:00Z');
|
||||
expect(bucketOf(wish).visited_source).toBe('dawarich');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', () => {
|
||||
it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
|
||||
expect(svc.clearBucketVisit(user.id, mine)).toBe(true);
|
||||
expect(await svc.clearBucketVisit(user.id, mine)).toBe(true);
|
||||
expect(bucketOf(mine).visited_at).toBeNull();
|
||||
expect(bucketOf(mine).visited_source).toBeNull();
|
||||
|
||||
expect(svc.clearBucketVisit(user.id, theirs)).toBe(false);
|
||||
expect(await svc.clearBucketVisit(user.id, theirs)).toBe(false);
|
||||
expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
});
|
||||
});
|
||||
@@ -1405,7 +1404,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
|
||||
testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
|
||||
|
||||
const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!;
|
||||
const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!;
|
||||
expect(listed.state).toBe('new');
|
||||
expect(listed.target).toBeNull();
|
||||
expect(listed.acceptedPlaceId).toBeNull();
|
||||
@@ -1419,7 +1418,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!;
|
||||
const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!;
|
||||
expect(listed.state).toBe('accepted');
|
||||
expect(listed.acceptedPlaceId).not.toBeNull();
|
||||
});
|
||||
@@ -1444,11 +1443,11 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
journeyStub.createEntry.mockReturnValue({ id: entryId });
|
||||
|
||||
await svc.accept(user.id, id, { target: 'journal', journalId: journeyId });
|
||||
expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('accepted');
|
||||
expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('accepted');
|
||||
|
||||
testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
|
||||
expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('new');
|
||||
expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', async () => {
|
||||
@@ -1459,7 +1458,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const result = await svc.accept(theirs.id, id, { target: 'place', tripId: trip.id });
|
||||
testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
|
||||
|
||||
svc.list(mine.id, {});
|
||||
await svc.list(mine.id, {});
|
||||
|
||||
expect(rowOf(id).state).toBe('accepted');
|
||||
});
|
||||
|
||||
@@ -80,23 +80,23 @@ function makeJob(intervalSetting?: string, enabled = true) {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('DawarichSyncJob bootstrap', () => {
|
||||
it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', () => {
|
||||
it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', async () => {
|
||||
const { job, registrar } = makeJob('30');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/30 * * * *', expect.any(Function));
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Dawarich sync: scheduled every 30m');
|
||||
});
|
||||
|
||||
it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', () => {
|
||||
it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', async () => {
|
||||
// Both jobs run the identical statement against app_settings; a copy-paste
|
||||
// that kept 'airtrail_poll_interval_minutes' would silently inherit the
|
||||
// other integration's cadence and nothing anywhere would fail.
|
||||
const { job, db } = makeJob('20');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
|
||||
});
|
||||
|
||||
it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', () => {
|
||||
it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', async () => {
|
||||
for (const [setting, minutes] of [
|
||||
[undefined, 15], // no row at all
|
||||
['', 15], // row present but blank
|
||||
@@ -109,24 +109,24 @@ describe('DawarichSyncJob bootstrap', () => {
|
||||
] as const) {
|
||||
vi.clearAllMocks();
|
||||
const { job, registrar } = makeJob(setting);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', `*/${minutes} * * * *`, expect.any(Function));
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith(`Dawarich sync: scheduled every ${minutes}m`);
|
||||
}
|
||||
});
|
||||
|
||||
it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', () => {
|
||||
it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', async () => {
|
||||
// parseInt is lenient and the clamp is what makes that safe: '20 minutes'
|
||||
// out of a hand-edited setting is still a usable 20, and everything the
|
||||
// clamp dislikes has already gone to the default in the case above.
|
||||
const { job, registrar } = makeJob('20 minutes');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/20 * * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', () => {
|
||||
it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', async () => {
|
||||
const { job, registrar, db } = makeJob('15', false);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
@@ -134,7 +134,7 @@ describe('DawarichSyncJob bootstrap', () => {
|
||||
|
||||
it('DAWARICH-JOB-006: the callback handed to the registrar is the tick, so a fired cron reaches runSync', async () => {
|
||||
const { job, sync, takeTick } = makeJob('15');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
const onTick = takeTick();
|
||||
expect(onTick).toBeTypeOf('function');
|
||||
await onTick?.();
|
||||
@@ -147,7 +147,7 @@ describe('DawarichSyncJob bootstrap', () => {
|
||||
// Unregistering is the registrar's own business inside register(); the job
|
||||
// calling it would leave a window where no cron exists at all.
|
||||
const { job, db, registrar, takeTick } = makeJob('15');
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
await takeTick()?.();
|
||||
expect(db.get).toHaveBeenCalledTimes(1);
|
||||
expect(registrar.register).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -47,6 +47,7 @@ import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
|
||||
import type { DawarichClient, DawarichCreds, DawarichVisitRaw } from '../../../src/nest/integrations/dawarich.client';
|
||||
import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
|
||||
@@ -112,7 +113,7 @@ const dawarich = {
|
||||
// (repo convention for DI-native service unit tests).
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const addons = new AddonsService(dbs);
|
||||
const svc = new DawarichSyncService(dbs, addons, client, dawarich);
|
||||
let svc: DawarichSyncService;
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -222,9 +223,10 @@ function withVisits(...visits: DawarichVisitRaw[]): void {
|
||||
listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
svc = new DawarichSyncService(dbs, addons, client, dawarich, await createTestUnitOfWork(dbs.connection));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -109,10 +109,10 @@ describe('DawarichController wiring', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichController connection routes', () => {
|
||||
it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', () => {
|
||||
it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', async () => {
|
||||
const connection = { url: 'https://d.example', connected: true };
|
||||
const getConnection = vi.fn().mockReturnValue(connection);
|
||||
expect(makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection);
|
||||
expect(await makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection);
|
||||
expect(getConnection).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
@@ -200,21 +200,21 @@ describe('DawarichController connection routes', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichController GET /suggestions filters', () => {
|
||||
it('DAWARICH-CTRL-020: no filters means both are undefined', () => {
|
||||
it('DAWARICH-CTRL-020: no filters means both are undefined', async () => {
|
||||
const list = vi.fn().mockReturnValue({ suggestions: [] });
|
||||
makeController({ suggestions: { list } }).listSuggestions(user);
|
||||
await makeController({ suggestions: { list } }).listSuggestions(user);
|
||||
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
|
||||
});
|
||||
|
||||
it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', () => {
|
||||
it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', async () => {
|
||||
const list = vi.fn().mockReturnValue({ suggestions: [] });
|
||||
makeController({ suggestions: { list } }).listSuggestions(user, '', '');
|
||||
await makeController({ suggestions: { list } }).listSuggestions(user, '', '');
|
||||
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
|
||||
});
|
||||
|
||||
it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', () => {
|
||||
it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', async () => {
|
||||
const list = vi.fn().mockReturnValue({ suggestions: [] });
|
||||
makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new');
|
||||
await makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new');
|
||||
expect(list).toHaveBeenCalledWith(7, { tripId: 42, state: 'new' });
|
||||
});
|
||||
|
||||
@@ -226,9 +226,9 @@ describe('DawarichController GET /suggestions filters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', (state) => {
|
||||
it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', async (state) => {
|
||||
const list = vi.fn().mockReturnValue({ suggestions: [] });
|
||||
makeController({ suggestions: { list } }).listSuggestions(user, undefined, state);
|
||||
await makeController({ suggestions: { list } }).listSuggestions(user, undefined, state);
|
||||
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state });
|
||||
});
|
||||
|
||||
@@ -269,10 +269,10 @@ describe('DawarichController parseId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', () => {
|
||||
it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', async () => {
|
||||
const setState = vi.fn().mockReturnValue({ id: 12 });
|
||||
const c = makeController({ suggestions: { setState } });
|
||||
expect(c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 });
|
||||
expect(await c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 });
|
||||
expect(setState).toHaveBeenCalledWith(7, 12, 'new');
|
||||
});
|
||||
|
||||
@@ -297,10 +297,10 @@ describe('DawarichController parseId', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichController suggestion routes', () => {
|
||||
it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', () => {
|
||||
it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', async () => {
|
||||
const updated = { id: 3, state: 'dismissed' };
|
||||
const setState = vi.fn().mockReturnValue(updated);
|
||||
expect(makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated);
|
||||
expect(await makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated);
|
||||
expect(setState).toHaveBeenCalledWith(7, 3, 'dismissed');
|
||||
});
|
||||
|
||||
@@ -329,9 +329,9 @@ describe('DawarichController suggestion routes', () => {
|
||||
expect(accept.mock.calls[0][3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', () => {
|
||||
it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', async () => {
|
||||
const confirmBucketVisits = vi.fn().mockReturnValue(3);
|
||||
expect(makeController({ suggestions: { confirmBucketVisits } })
|
||||
expect(await makeController({ suggestions: { confirmBucketVisits } })
|
||||
.confirmBucketVisits(user, { itemIds: [1, 2, 3], visitedAt: '2026-05-01' })).toEqual({ updated: 3 });
|
||||
expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01');
|
||||
});
|
||||
@@ -342,12 +342,12 @@ describe('DawarichController suggestion routes', () => {
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } }));
|
||||
});
|
||||
|
||||
it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', () => {
|
||||
it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', async () => {
|
||||
// The counterpart to 046: the service reports whether a row changed, and
|
||||
// only the "nothing changed" answer is a 404. A cleared tick must not come
|
||||
// back as one, or undoing a wish would look like it failed.
|
||||
const clearBucketVisit = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({
|
||||
expect(await makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(clearBucketVisit).toHaveBeenCalledWith(7, 4);
|
||||
|
||||
@@ -228,7 +228,7 @@ describe('DawarichMcp surface', () => {
|
||||
});
|
||||
const mcp = makeMcp({ suggestions: { list: listFn }, tracks: { forTrip }, auth: { isDemoUser } });
|
||||
|
||||
payload(mcp.listSuggestions({}, ctx));
|
||||
payload(await mcp.listSuggestions({}, ctx));
|
||||
payload(await mcp.tripTrack({ tripId: 5 }, ctx));
|
||||
expect(isDemoUser).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -354,12 +354,12 @@ describe('DawarichMcp input schemas', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichMcp list_dawarich_suggestions', () => {
|
||||
it('DAWARICH-MCP-030: the filters reach the service for the calling user, and the sync provenance rides back', () => {
|
||||
it('DAWARICH-MCP-030: the filters reach the service for the calling user, and the sync provenance rides back', async () => {
|
||||
// A disconnected instance with a failed poll is the interesting case: the
|
||||
// stays are still there, and without the provenance an assistant would
|
||||
// present a stale backlog as if it were current.
|
||||
const listFn = vi.fn().mockReturnValue(list(2, { connected: false, lastSyncState: 'failed', lastSyncAt: null }));
|
||||
const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({ tripId: 3, state: 'new' }, ctx));
|
||||
const out = payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({ tripId: 3, state: 'new' }, ctx));
|
||||
|
||||
expect(listFn).toHaveBeenCalledWith(7, { tripId: 3, state: 'new' });
|
||||
expect(out).toMatchObject({
|
||||
@@ -371,53 +371,53 @@ describe('DawarichMcp list_dawarich_suggestions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-031: no arguments means no filters, undefined rather than a filter on nothing', () => {
|
||||
it('DAWARICH-MCP-031: no arguments means no filters, undefined rather than a filter on nothing', async () => {
|
||||
const listFn = vi.fn().mockReturnValue(list(0));
|
||||
payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx));
|
||||
payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx));
|
||||
expect(listFn).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-032: a backlog longer than the default page is cut to 50 and says so, because an assistant must not pay for a year of stays', () => {
|
||||
const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }).listSuggestions({}, ctx));
|
||||
it('DAWARICH-MCP-032: a backlog longer than the default page is cut to 50 and says so, because an assistant must not pay for a year of stays', async () => {
|
||||
const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }).listSuggestions({}, ctx));
|
||||
|
||||
expect((out.suggestions as unknown[]).length).toBe(50);
|
||||
expect(out.total).toBe(60);
|
||||
expect(out.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-033: a list that fits is not flagged truncated, so a caller can tell "all of it" from "the first page"', () => {
|
||||
const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(50)) } }).listSuggestions({}, ctx));
|
||||
it('DAWARICH-MCP-033: a list that fits is not flagged truncated, so a caller can tell "all of it" from "the first page"', async () => {
|
||||
const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(50)) } }).listSuggestions({}, ctx));
|
||||
|
||||
expect((out.suggestions as unknown[]).length).toBe(50);
|
||||
expect(out.total).toBe(50);
|
||||
expect(out.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-034: an explicit limit wins over the default in both directions', () => {
|
||||
it('DAWARICH-MCP-034: an explicit limit wins over the default in both directions', async () => {
|
||||
const mcp = makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } });
|
||||
|
||||
const small = payload(mcp.listSuggestions({ limit: 3 }, ctx));
|
||||
const small = payload(await mcp.listSuggestions({ limit: 3 }, ctx));
|
||||
expect((small.suggestions as unknown[]).length).toBe(3);
|
||||
expect(small.truncated).toBe(true);
|
||||
|
||||
const large = payload(mcp.listSuggestions({ limit: 200 }, ctx));
|
||||
const large = payload(await mcp.listSuggestions({ limit: 200 }, ctx));
|
||||
expect((large.suggestions as unknown[]).length).toBe(60);
|
||||
expect(large.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-035: the stays go out as the service shaped them, because the tool pages and does not reshape', () => {
|
||||
const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(2)) } }).listSuggestions({ limit: 1 }, ctx));
|
||||
it('DAWARICH-MCP-035: the stays go out as the service shaped them, because the tool pages and does not reshape', async () => {
|
||||
const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(2)) } }).listSuggestions({ limit: 1 }, ctx));
|
||||
expect(out.suggestions).toEqual([{ id: 1, name: 'Stay 1' }]);
|
||||
});
|
||||
|
||||
it('DAWARICH-MCP-036: the reason the last poll failed is the one field the tool drops', () => {
|
||||
it('DAWARICH-MCP-036: the reason the last poll failed is the one field the tool drops', async () => {
|
||||
// lastSyncError is on DawarichSuggestionList and the REST list carries it;
|
||||
// this tool does not. Pinned so the omission stays a decision rather than an
|
||||
// oversight: an assistant can say the sync failed but not why, which is a
|
||||
// defensible privacy line (the string can name the host) and, at the same
|
||||
// time, a real gap in what it is able to explain to the person asking.
|
||||
const listFn = vi.fn().mockReturnValue(list(1, { lastSyncState: 'failed', lastSyncError: 'unauthorized' }));
|
||||
const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx));
|
||||
const out = payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx));
|
||||
|
||||
expect(out).not.toHaveProperty('lastSyncError');
|
||||
expect(out.lastSyncState).toBe('failed');
|
||||
|
||||
@@ -93,6 +93,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { DawarichService } from '../../../src/nest/integrations/dawarich.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import {
|
||||
DawarichError,
|
||||
type DawarichClient,
|
||||
@@ -114,11 +115,7 @@ const client = {
|
||||
};
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const svc = new DawarichService(
|
||||
dbs,
|
||||
audit as unknown as AuditService,
|
||||
client as unknown as DawarichClient,
|
||||
);
|
||||
let svc: DawarichService;
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -255,9 +252,15 @@ const countryCodeAbsentCases: Array<[string, Partial<DawarichVisitRaw>]> = [
|
||||
['a place with a null code', { place: { latitude: 1, longitude: 2, id: 3, country_code: null } }],
|
||||
];
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
svc = new DawarichService(
|
||||
dbs,
|
||||
audit as unknown as AuditService,
|
||||
client as unknown as DawarichClient,
|
||||
await createTestUnitOfWork(dbs.connection),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -277,30 +280,30 @@ afterAll(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichService getCredentials', () => {
|
||||
it('DAWARICH-SVC-001: a user who never connected has no credentials', () => {
|
||||
expect(svc.getCredentials(USER)).toBeNull();
|
||||
it('DAWARICH-SVC-001: a user who never connected has no credentials', async () => {
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-002: a row without an address is not a connection', () => {
|
||||
it('DAWARICH-SVC-002: a row without an address is not a connection', async () => {
|
||||
connect(USER, { url: null });
|
||||
expect(svc.getCredentials(USER)).toBeNull();
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-003: a row without a key is not a connection', () => {
|
||||
it('DAWARICH-SVC-003: a row without a key is not a connection', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
expect(svc.getCredentials(USER)).toBeNull();
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', () => {
|
||||
it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => {
|
||||
// What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind:
|
||||
// the envelope is intact, the ciphertext is not ours.
|
||||
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
expect(svc.getCredentials(USER)).toBeNull();
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', () => {
|
||||
it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => {
|
||||
connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 });
|
||||
expect(svc.getCredentials(USER)).toEqual({
|
||||
expect(await svc.getCredentials(USER)).toEqual({
|
||||
baseUrl: HOST,
|
||||
apiKey: 'plain-legacy-key',
|
||||
allowInsecureTls: false,
|
||||
@@ -309,7 +312,7 @@ describe('DawarichService getCredentials', () => {
|
||||
|
||||
it('DAWARICH-SVC-006: the self-signed-certificate flag is carried into the credentials as a boolean', async () => {
|
||||
await svc.saveSettings(USER, HOST, 'typed-secret', true, true, IP);
|
||||
expect(svc.getCredentials(USER)).toEqual({
|
||||
expect(await svc.getCredentials(USER)).toEqual({
|
||||
baseUrl: HOST,
|
||||
apiKey: 'typed-secret',
|
||||
allowInsecureTls: true,
|
||||
@@ -322,32 +325,32 @@ describe('DawarichService getCredentials', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichService isConnected', () => {
|
||||
it('DAWARICH-SVC-010: both halves present means connected', () => {
|
||||
it('DAWARICH-SVC-010: both halves present means connected', async () => {
|
||||
connect(USER);
|
||||
expect(svc.isConnected(USER)).toBe(true);
|
||||
expect(await svc.isConnected(USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-011: an address without a key is not connected', () => {
|
||||
it('DAWARICH-SVC-011: an address without a key is not connected', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
expect(svc.isConnected(USER)).toBe(false);
|
||||
expect(await svc.isConnected(USER)).toBe(false);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-012: no row at all is not connected', () => {
|
||||
expect(svc.isConnected(USER)).toBe(false);
|
||||
it('DAWARICH-SVC-012: no row at all is not connected', async () => {
|
||||
expect(await svc.isConnected(USER)).toBe(false);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', () => {
|
||||
it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', async () => {
|
||||
// Deliberate difference from getCredentials: the settings card has to keep
|
||||
// showing a connection the user can repair, and only the outbound path
|
||||
// cares that the ciphertext is unusable.
|
||||
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
expect(svc.isConnected(USER)).toBe(true);
|
||||
expect(svc.getCredentials(USER)).toBeNull();
|
||||
expect(await svc.isConnected(USER)).toBe(true);
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichService listSyncableUserIds', () => {
|
||||
it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', () => {
|
||||
it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', async () => {
|
||||
const syncable = USER;
|
||||
const pollOff = createUser(testDb).user.id;
|
||||
const noUrl = createUser(testDb).user.id;
|
||||
@@ -363,14 +366,14 @@ describe('DawarichService listSyncableUserIds', () => {
|
||||
connect(blankUrl, { url: '' });
|
||||
connect(noKey, { apiKey: null });
|
||||
|
||||
expect(svc.listSyncableUserIds()).toEqual([syncable]);
|
||||
expect(await svc.listSyncableUserIds()).toEqual([syncable]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', () => {
|
||||
expect(svc.listSyncableUserIds()).toEqual([]);
|
||||
it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', async () => {
|
||||
expect(await svc.listSyncableUserIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', () => {
|
||||
it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', async () => {
|
||||
// The cron fans out over this list. A query that answered with one id would
|
||||
// leave everyone but the earliest registration silently unsynced, and the
|
||||
// settings card would still say the poll was on for all of them.
|
||||
@@ -380,7 +383,7 @@ describe('DawarichService listSyncableUserIds', () => {
|
||||
connect(second);
|
||||
connect(pollOff, { syncEnabled: 0 });
|
||||
|
||||
const ids = svc.listSyncableUserIds();
|
||||
const ids = await svc.listSyncableUserIds();
|
||||
|
||||
// No ORDER BY upstream, so the set is the contract and the order is not.
|
||||
expect(new Set(ids)).toEqual(new Set([USER, second]));
|
||||
@@ -393,8 +396,8 @@ describe('DawarichService listSyncableUserIds', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichService getConnection', () => {
|
||||
it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', () => {
|
||||
expect(svc.getConnection(USER)).toEqual({
|
||||
it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', async () => {
|
||||
expect(await svc.getConnection(USER)).toEqual({
|
||||
url: '',
|
||||
apiKeyMasked: '',
|
||||
allowInsecureTls: false,
|
||||
@@ -407,7 +410,7 @@ describe('DawarichService getConnection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', () => {
|
||||
it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => {
|
||||
connect(USER, {
|
||||
apiKey: 'stored-key',
|
||||
allowInsecureTls: 1,
|
||||
@@ -417,7 +420,7 @@ describe('DawarichService getConnection', () => {
|
||||
lastSyncError: 'rate_limited',
|
||||
});
|
||||
|
||||
const out = svc.getConnection(USER);
|
||||
const out = await svc.getConnection(USER);
|
||||
|
||||
expect(out).toEqual({
|
||||
url: HOST,
|
||||
@@ -433,52 +436,52 @@ describe('DawarichService getConnection', () => {
|
||||
expect(JSON.stringify(out)).not.toContain('stored-key');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-032: an address without a key reports the address and no mask', () => {
|
||||
it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
expect(svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false });
|
||||
expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false });
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', () => {
|
||||
it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => {
|
||||
connect(USER, { url: null, apiKey: null });
|
||||
expect(svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true });
|
||||
expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true });
|
||||
});
|
||||
|
||||
it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', (state) => {
|
||||
it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => {
|
||||
connect(USER, { lastSyncState: state });
|
||||
expect(svc.getConnection(USER).lastSyncState).toBe(state);
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe(state);
|
||||
});
|
||||
|
||||
it.each(['syncing', 'OK', 'success', ''])(
|
||||
'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it',
|
||||
(state) => {
|
||||
async (state) => {
|
||||
connect(USER, { lastSyncState: state });
|
||||
expect(svc.getConnection(USER).lastSyncState).toBe('never');
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe('never');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['{not json', '', 'undefined'])(
|
||||
'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page',
|
||||
(raw) => {
|
||||
async (raw) => {
|
||||
connect(USER, { capabilities: raw });
|
||||
expect(svc.getConnection(USER).capabilities).toBeNull();
|
||||
expect((await svc.getConnection(USER)).capabilities).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['null', '42', '"visits"', 'true'])(
|
||||
'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed',
|
||||
(raw) => {
|
||||
async (raw) => {
|
||||
connect(USER, { capabilities: raw });
|
||||
expect(svc.getConnection(USER).capabilities).toBeNull();
|
||||
expect((await svc.getConnection(USER)).capabilities).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', () => {
|
||||
it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', async () => {
|
||||
// The column survives an upgrade, so the shape is whatever was current when
|
||||
// the last probe ran. Missing flags are off and a missing probe timestamp
|
||||
// is the epoch, which reads as "long ago" everywhere it is compared.
|
||||
connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) });
|
||||
|
||||
expect(svc.getConnection(USER).capabilities).toEqual({
|
||||
expect((await svc.getConnection(USER)).capabilities).toEqual({
|
||||
visits: true,
|
||||
tracks: false,
|
||||
points: false,
|
||||
@@ -491,25 +494,25 @@ describe('DawarichService getConnection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-039: a complete blob round-trips unchanged', () => {
|
||||
it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => {
|
||||
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
expect(svc.getConnection(USER).capabilities).toEqual(FULL_CAPS);
|
||||
expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichService getCapabilities', () => {
|
||||
it('DAWARICH-SVC-040: null when there is no connection', () => {
|
||||
expect(svc.getCapabilities(USER)).toBeNull();
|
||||
it('DAWARICH-SVC-040: null when there is no connection', async () => {
|
||||
expect(await svc.getCapabilities(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-041: null when the connection has never been probed', () => {
|
||||
it('DAWARICH-SVC-041: null when the connection has never been probed', async () => {
|
||||
connect(USER);
|
||||
expect(svc.getCapabilities(USER)).toBeNull();
|
||||
expect(await svc.getCapabilities(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-042: the stored probe otherwise', () => {
|
||||
it('DAWARICH-SVC-042: the stored probe otherwise', async () => {
|
||||
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,7 +532,7 @@ describe('DawarichService saveSettings', () => {
|
||||
expect(stored?.api_key).toMatch(/^enc:v1:/);
|
||||
expect(stored?.api_key).not.toContain('super-secret');
|
||||
// The round trip is the point: encrypted at rest, usable on the way out.
|
||||
expect(svc.getCredentials(USER)?.apiKey).toBe('super-secret');
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('super-secret');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => {
|
||||
@@ -537,8 +540,8 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
expect(row()?.sync_enabled).toBe(0);
|
||||
expect(row()?.allow_insecure_tls).toBe(0);
|
||||
expect(svc.getConnection(USER).syncEnabled).toBe(false);
|
||||
expect(svc.listSyncableUserIds()).toEqual([]);
|
||||
expect((await svc.getConnection(USER)).syncEnabled).toBe(false);
|
||||
expect(await svc.listSyncableUserIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => {
|
||||
@@ -560,7 +563,7 @@ describe('DawarichService saveSettings', () => {
|
||||
await svc.saveSettings(USER, HOST, typed, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBe(encrypted);
|
||||
expect(svc.getCredentials(USER)?.apiKey).toBe('first-key');
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -568,7 +571,7 @@ describe('DawarichService saveSettings', () => {
|
||||
await svc.saveSettings(USER, HOST, 'first-key', false, true, IP);
|
||||
await svc.saveSettings(USER, HOST, 'second-key', false, true, IP);
|
||||
|
||||
expect(svc.getCredentials(USER)?.apiKey).toBe('second-key');
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('second-key');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => {
|
||||
@@ -617,7 +620,7 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
|
||||
|
||||
expect(svc.getCredentials(USER)).toEqual({
|
||||
expect(await svc.getCredentials(USER)).toEqual({
|
||||
baseUrl: 'https://new.example',
|
||||
apiKey: 'minted-for-new',
|
||||
allowInsecureTls: false,
|
||||
@@ -815,7 +818,7 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
|
||||
|
||||
expect(svc.getCredentials(USER)?.apiKey).toBe('minted-for-new');
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new');
|
||||
const stored = row();
|
||||
expect(stored?.url).toBe('https://new.example');
|
||||
expect(stored?.capabilities).toBeNull();
|
||||
@@ -834,7 +837,7 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP);
|
||||
|
||||
expect(svc.getCredentials(USER)?.apiKey).toBe('rotated-key');
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key');
|
||||
expect(row()?.capabilities).toBe(JSON.stringify(FULL_CAPS));
|
||||
expect(row()?.last_sync_state).toBe('ok');
|
||||
});
|
||||
@@ -851,7 +854,7 @@ describe('DawarichService disconnect', () => {
|
||||
await svc.disconnect(USER, '198.51.100.7');
|
||||
|
||||
expect(row()).toBeUndefined();
|
||||
expect(svc.getConnection(USER).connected).toBe(false);
|
||||
expect((await svc.getConnection(USER)).connected).toBe(false);
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith({
|
||||
userId: USER,
|
||||
action: 'dawarich.disconnected',
|
||||
@@ -921,7 +924,7 @@ describe('DawarichService testConnection', () => {
|
||||
const out = await svc.testConnection(USER, HOST, undefined, false);
|
||||
|
||||
expect(out.connected).toBe(true);
|
||||
expect(svc.getCapabilities(USER)).toEqual(out.capabilities);
|
||||
expect(await svc.getCapabilities(USER)).toEqual(out.capabilities);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => {
|
||||
@@ -929,7 +932,7 @@ describe('DawarichService testConnection', () => {
|
||||
|
||||
await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false);
|
||||
|
||||
expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-096: a test from a form with nothing stored yet writes no capabilities row', async () => {
|
||||
@@ -1012,7 +1015,7 @@ describe('DawarichService testConnection', () => {
|
||||
|
||||
await svc.testConnection(USER, HOST, undefined, false);
|
||||
|
||||
expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-104: a stored key that no longer decrypts is never dialled with, so the form hears "not connected" instead of a ciphertext going upstream as a bearer token', async () => {
|
||||
@@ -1185,60 +1188,60 @@ describe('DawarichService probeCapabilities', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DawarichService storeCapabilities', () => {
|
||||
it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', () => {
|
||||
it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => {
|
||||
connect(USER);
|
||||
|
||||
svc.storeCapabilities(USER, FULL_CAPS);
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
|
||||
expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', () => {
|
||||
svc.storeCapabilities(USER, FULL_CAPS);
|
||||
it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => {
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
expect(row()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-132: only the named user is touched', () => {
|
||||
it('DAWARICH-SVC-132: only the named user is touched', async () => {
|
||||
const other = createUser(testDb).user.id;
|
||||
connect(USER);
|
||||
connect(other);
|
||||
|
||||
svc.storeCapabilities(USER, FULL_CAPS);
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
|
||||
expect(row(other)?.capabilities).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichService recordSyncResult', () => {
|
||||
it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', () => {
|
||||
it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => {
|
||||
connect(USER);
|
||||
const before = Date.now();
|
||||
|
||||
svc.recordSyncResult(USER, 'failed', 'unreachable');
|
||||
await svc.recordSyncResult(USER, 'failed', 'unreachable');
|
||||
|
||||
const out = svc.getConnection(USER);
|
||||
const out = await svc.getConnection(USER);
|
||||
expect(out.lastSyncState).toBe('failed');
|
||||
expect(out.lastSyncError).toBe('unreachable');
|
||||
expect(Date.parse(out.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before - 1000);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', () => {
|
||||
it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => {
|
||||
connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' });
|
||||
|
||||
svc.recordSyncResult(USER, 'ok', null);
|
||||
await svc.recordSyncResult(USER, 'ok', null);
|
||||
|
||||
expect(svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null });
|
||||
expect(await svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null });
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', () => {
|
||||
it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => {
|
||||
connect(USER);
|
||||
svc.recordSyncResult(USER, 'partial', 'too_large');
|
||||
expect(svc.getConnection(USER).lastSyncState).toBe('partial');
|
||||
await svc.recordSyncResult(USER, 'partial', 'too_large');
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe('partial');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', () => {
|
||||
svc.recordSyncResult(USER, 'ok', null);
|
||||
it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => {
|
||||
await svc.recordSyncResult(USER, 'ok', null);
|
||||
expect(row()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,32 +34,32 @@ function setup() {
|
||||
}
|
||||
const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 };
|
||||
|
||||
it('replaces one day only, persists place snapping and cascades deleted visits', () => {
|
||||
it('replaces one day only, persists place snapping and cascades deleted visits', async () => {
|
||||
const { service, raw } = setup();
|
||||
service.save(10, boundary);
|
||||
service.save(10, { ...boundary, day_number: 2, fraction: 0.8 });
|
||||
expect(service.save(10, { ...boundary, to_assignment_id: null })).toEqual([
|
||||
await service.save(10, boundary);
|
||||
await service.save(10, { ...boundary, day_number: 2, fraction: 0.8 });
|
||||
expect(await service.save(10, { ...boundary, to_assignment_id: null })).toEqual([
|
||||
{ ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 },
|
||||
]);
|
||||
expect(service.list(20)).toEqual([]);
|
||||
service.remove(20, 1);
|
||||
expect(service.list(10)).toHaveLength(2);
|
||||
expect(await service.list(20)).toEqual([]);
|
||||
await service.remove(20, 1);
|
||||
expect(await service.list(10)).toHaveLength(2);
|
||||
raw.prepare('DELETE FROM day_assignments WHERE id = 12').run();
|
||||
expect(service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
expect(await service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
});
|
||||
|
||||
it('rejects either endpoint from a different trip without writing', () => {
|
||||
it('rejects either endpoint from a different trip without writing', async () => {
|
||||
const { service } = setup();
|
||||
expect(() => service.save(10, { ...boundary, from_assignment_id: 21 })).toThrow(HttpException);
|
||||
expect(() => service.save(10, { ...boundary, to_assignment_id: 21 })).toThrow(HttpException);
|
||||
expect(service.list(10)).toEqual([]);
|
||||
await expect(service.save(10, { ...boundary, from_assignment_id: 21 })).rejects.toThrow(HttpException);
|
||||
await expect(service.save(10, { ...boundary, to_assignment_id: 21 })).rejects.toThrow(HttpException);
|
||||
expect(await service.list(10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('REST changes broadcast the complete state and exclude the saving socket', () => {
|
||||
it('REST changes broadcast the complete state and exclude the saving socket', async () => {
|
||||
const { controller, realtime } = setup();
|
||||
controller.save('10', boundary, 'self');
|
||||
await controller.save('10', boundary, 'self');
|
||||
expect(realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self');
|
||||
expect(controller.remove('10', 1)).toEqual({ boundaries: [] });
|
||||
expect(await controller.remove('10', 1)).toEqual({ boundaries: [] });
|
||||
});
|
||||
|
||||
it('MCP checks demo, trip access and edit permission before saving', async () => {
|
||||
@@ -73,10 +73,10 @@ it('MCP checks demo, trip access and edit permission before saving', async () =>
|
||||
s.db.canAccessTrip.mockReturnValue(true);
|
||||
s.guards.hasTripPermission.mockReturnValue(false);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(s.service.list(10)).toEqual([]);
|
||||
expect(await s.service.list(10)).toEqual([]);
|
||||
s.guards.hasTripPermission.mockReturnValue(true);
|
||||
await s.mcp.save(request, ctx);
|
||||
expect(s.service.list(10)).toEqual([boundary]);
|
||||
expect(await s.service.list(10)).toEqual([boundary]);
|
||||
await s.mcp.save({ ...request, boundary: null }, ctx);
|
||||
expect(s.service.list(10)).toEqual([]);
|
||||
expect(await s.service.list(10)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -118,13 +118,13 @@ function seedUserWithToken(token: string, overrides: Partial<{ username: string
|
||||
// ── Trip feed token ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('trip feed token lifecycle', () => {
|
||||
it('FEED-SVC-001: reports no URL while the trip has no token', () => {
|
||||
it('FEED-SVC-001: reports no URL while the trip has no token', async () => {
|
||||
const { user, tripId } = seedTrip();
|
||||
|
||||
expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({ feed_url: null });
|
||||
});
|
||||
|
||||
it('FEED-SVC-002: reports the absolute feed URL once a token exists', () => {
|
||||
it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => {
|
||||
const { user, tripId } = seedTrip('tok-trip');
|
||||
|
||||
expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({
|
||||
@@ -132,7 +132,7 @@ describe('trip feed token lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', () => {
|
||||
it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', async () => {
|
||||
// APP_URL is user-supplied config; pasted with a trailing slash it would
|
||||
// otherwise produce https://host//api/feed/... which some clients reject.
|
||||
const { user, tripId } = seedTrip('tok-trip');
|
||||
@@ -142,7 +142,7 @@ describe('trip feed token lifecycle', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', () => {
|
||||
it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', async () => {
|
||||
// The token is the credential for the public feed, so leaking it through the
|
||||
// authenticated GET would hand a stranger the whole trip.
|
||||
const { tripId } = seedTrip('tok-trip');
|
||||
@@ -154,7 +154,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// Membership is what the service checks, and that stays true: whether the
|
||||
// caller may manage the credential at all is decided one layer up, by
|
||||
// TripAccessGuard + @RequirePermission('share_manage') on the controller.
|
||||
it('FEED-SVC-005: a trip shared with the user as a member resolves too', () => {
|
||||
it('FEED-SVC-005: a trip shared with the user as a member resolves too', async () => {
|
||||
const { trip, tripId } = seedTrip('tok-trip');
|
||||
const { user: member } = createUser(testDb);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
@@ -164,7 +164,7 @@ describe('trip feed token lifecycle', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('FEED-SVC-006: generate mints a token once and stays idempotent', () => {
|
||||
it('FEED-SVC-006: generate mints a token once and stays idempotent', async () => {
|
||||
// Enabling twice must not invalidate a URL the user already handed to their
|
||||
// calendar client — that is what rotate is for.
|
||||
const { user, tripId } = seedTrip();
|
||||
@@ -176,7 +176,7 @@ describe('trip feed token lifecycle', () => {
|
||||
expect(second.feed_url).toBe(first.feed_url);
|
||||
});
|
||||
|
||||
it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', () => {
|
||||
it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', async () => {
|
||||
const { user, tripId } = seedTrip();
|
||||
const before = svc.generateTripToken(tripId, user.id, BASE).feed_url;
|
||||
const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
@@ -184,10 +184,10 @@ describe('trip feed token lifecycle', () => {
|
||||
const after = svc.rotateTripToken(tripId, user.id, BASE).feed_url;
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(svc.buildTripIcs(oldToken)).toBeNull();
|
||||
expect(await svc.buildTripIcs(oldToken)).toBeNull();
|
||||
});
|
||||
|
||||
it('FEED-SVC-008: disable clears the column so the public URL dies', () => {
|
||||
it('FEED-SVC-008: disable clears the column so the public URL dies', async () => {
|
||||
const { user, tripId } = seedTrip();
|
||||
const url = svc.generateTripToken(tripId, user.id, BASE).feed_url;
|
||||
const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
@@ -195,10 +195,10 @@ describe('trip feed token lifecycle', () => {
|
||||
svc.disableTripToken(tripId, user.id);
|
||||
|
||||
expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({ feed_url: null });
|
||||
expect(svc.buildTripIcs(token)).toBeNull();
|
||||
expect(await svc.buildTripIcs(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('FEED-SVC-008b: the writes refuse a trip the acting user cannot reach', () => {
|
||||
it('FEED-SVC-008b: the writes refuse a trip the acting user cannot reach', async () => {
|
||||
// The route guard is what enforces share_manage; this is the second lock, so
|
||||
// a caller reaching the service another way cannot mint or clear a token on
|
||||
// a trip id it merely guessed.
|
||||
@@ -211,14 +211,14 @@ describe('trip feed token lifecycle', () => {
|
||||
expect(svc.getTripToken(tripId, user.id, BASE).feed_url).toBe(mine);
|
||||
|
||||
svc.disableTripToken(tripId, outsider.id);
|
||||
expect(svc.buildTripIcs(myToken)).not.toBeNull();
|
||||
expect(await svc.buildTripIcs(myToken)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── User (all-trips) feed token ───────────────────────────────────────────────
|
||||
|
||||
describe('user feed token lifecycle', () => {
|
||||
it('FEED-SVC-009: reports null before and the absolute URL after generation', () => {
|
||||
it('FEED-SVC-009: reports null before and the absolute URL after generation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
expect(svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null });
|
||||
@@ -229,7 +229,7 @@ describe('user feed token lifecycle', () => {
|
||||
expect(svc.getUserToken(user.id, BASE).feed_url).toBe(generated);
|
||||
});
|
||||
|
||||
it('FEED-SVC-010: generate is idempotent — the existing URL is returned unchanged', () => {
|
||||
it('FEED-SVC-010: generate is idempotent — the existing URL is returned unchanged', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const first = svc.generateUserToken(user.id, BASE);
|
||||
@@ -238,7 +238,7 @@ describe('user feed token lifecycle', () => {
|
||||
expect(second.feed_url).toBe(first.feed_url);
|
||||
});
|
||||
|
||||
it('FEED-SVC-011: rotate issues a fresh token and the previous URL stops resolving', () => {
|
||||
it('FEED-SVC-011: rotate issues a fresh token and the previous URL stops resolving', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const before = svc.generateUserToken(user.id, BASE).feed_url;
|
||||
const oldToken = before.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
||||
@@ -246,10 +246,10 @@ describe('user feed token lifecycle', () => {
|
||||
const after = svc.rotateUserToken(user.id, BASE).feed_url;
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(svc.buildUserIcs(oldToken)).toBeNull();
|
||||
expect(await svc.buildUserIcs(oldToken)).toBeNull();
|
||||
});
|
||||
|
||||
it('FEED-SVC-012: disable clears the column so the public URL dies', () => {
|
||||
it('FEED-SVC-012: disable clears the column so the public URL dies', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const url = svc.generateUserToken(user.id, BASE).feed_url;
|
||||
const token = url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
|
||||
@@ -257,21 +257,21 @@ describe('user feed token lifecycle', () => {
|
||||
svc.disableUserToken(user.id);
|
||||
|
||||
expect(svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null });
|
||||
expect(svc.buildUserIcs(token)).toBeNull();
|
||||
expect(await svc.buildUserIcs(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildTripIcs ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildTripIcs', () => {
|
||||
it('FEED-SVC-013: an unknown token yields null without asking the calendar', () => {
|
||||
it('FEED-SVC-013: an unknown token yields null without asking the calendar', async () => {
|
||||
seedTrip('tok-trip');
|
||||
|
||||
expect(svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(await svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FEED-SVC-014: a calendar that throws yields null instead of propagating', () => {
|
||||
it('FEED-SVC-014: a calendar that throws yields null instead of propagating', async () => {
|
||||
// The public feed is unauthenticated: a trip the calendar cannot render (a row
|
||||
// deleted mid-request, unparseable data) has to come back as a 404, not a 500
|
||||
// that a subscribing client retries hourly forever.
|
||||
@@ -280,10 +280,10 @@ describe('buildTripIcs', () => {
|
||||
throw new Error('calendar exploded');
|
||||
});
|
||||
|
||||
expect(svc.buildTripIcs('tok-trip')).toBeNull();
|
||||
expect(await svc.buildTripIcs('tok-trip')).toBeNull();
|
||||
});
|
||||
|
||||
it('FEED-SVC-015: the refresh hints sit in the preamble, ahead of X-WR-CALNAME and every component', () => {
|
||||
it('FEED-SVC-015: the refresh hints sit in the preamble, ahead of X-WR-CALNAME and every component', async () => {
|
||||
// REFRESH-INTERVAL/X-PUBLISHED-TTL are calendar properties: RFC 5545 puts them
|
||||
// before the first component, and clients that scan only the preamble stop
|
||||
// re-fetching if they slip behind a VTIMEZONE. The document is concatenated from
|
||||
@@ -297,7 +297,7 @@ describe('buildTripIcs', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = svc.buildTripIcs('tok-trip');
|
||||
const result = await svc.buildTripIcs('tok-trip');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.filename).toBe('golden-trip.ics');
|
||||
@@ -314,14 +314,14 @@ describe('buildTripIcs', () => {
|
||||
// ── buildUserIcs ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildUserIcs', () => {
|
||||
it('FEED-SVC-016: an unknown token yields null without asking the calendar', () => {
|
||||
it('FEED-SVC-016: an unknown token yields null without asking the calendar', async () => {
|
||||
seedUserWithToken('tok-user');
|
||||
|
||||
expect(svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(await svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', () => {
|
||||
it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', async () => {
|
||||
// One unrenderable trip must not take the whole all-trips subscription down —
|
||||
// the user would silently lose every calendar entry because of a single bad row.
|
||||
const user = seedUserWithToken('tok-user');
|
||||
@@ -333,7 +333,7 @@ describe('buildUserIcs', () => {
|
||||
return calendarParts({ events: [vevent(`Trip${id}`)] });
|
||||
});
|
||||
|
||||
const result = svc.buildUserIcs('tok-user');
|
||||
const result = await svc.buildUserIcs('tok-user');
|
||||
|
||||
expect(buildTripCalendar).toHaveBeenCalledTimes(3);
|
||||
expect(result!.ics).toContain(`SUMMARY:Trip${good.id}\r\n`);
|
||||
@@ -342,7 +342,7 @@ describe('buildUserIcs', () => {
|
||||
expect(result!.ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('FEED-SVC-018: a TZID shared by two trips is defined exactly once, ahead of every VEVENT', () => {
|
||||
it('FEED-SVC-018: a TZID shared by two trips is defined exactly once, ahead of every VEVENT', async () => {
|
||||
// Two VTIMEZONE blocks with the same TZID make the document invalid and clients
|
||||
// drop the events referencing it; a block emitted after the VEVENT that uses it
|
||||
// does not resolve either (#1453). First definition wins.
|
||||
@@ -358,7 +358,7 @@ describe('buildUserIcs', () => {
|
||||
events: [vevent(`Trip${id}`)],
|
||||
}));
|
||||
|
||||
const { ics } = svc.buildUserIcs('tok-user')!;
|
||||
const { ics } = (await svc.buildUserIcs('tok-user'))!;
|
||||
|
||||
expect(ics.split('BEGIN:VTIMEZONE').length - 1).toBe(1);
|
||||
expect(ics).toContain('TZNAME:Asia/Tokyo\r\n');
|
||||
@@ -366,7 +366,7 @@ describe('buildUserIcs', () => {
|
||||
expect(ics.indexOf('BEGIN:VTIMEZONE')).toBeLessThan(ics.indexOf('BEGIN:VEVENT'));
|
||||
});
|
||||
|
||||
it('FEED-SVC-019: the header is never folded, the body always is', () => {
|
||||
it('FEED-SVC-019: the header is never folded, the body always is', async () => {
|
||||
// Folding is applied to the body only, on purpose: a long display name would
|
||||
// otherwise wrap X-WR-CALNAME across two physical lines, which several clients
|
||||
// render as a truncated calendar title. The body still has to fold — RFC 5545
|
||||
@@ -377,7 +377,7 @@ describe('buildUserIcs', () => {
|
||||
const longSummary = 'A'.repeat(120);
|
||||
buildTripCalendar.mockImplementation(() => calendarParts({ events: [vevent(longSummary)] }));
|
||||
|
||||
const { ics, calName } = svc.buildUserIcs('tok-user')!;
|
||||
const { ics, calName } = (await svc.buildUserIcs('tok-user'))!;
|
||||
|
||||
expect(calName).toBe(`${username} – All Trips`);
|
||||
const preamble = ics.slice(0, ics.indexOf('BEGIN:VEVENT'));
|
||||
@@ -388,14 +388,14 @@ describe('buildUserIcs', () => {
|
||||
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${longSummary}\r\n`);
|
||||
});
|
||||
|
||||
it('FEED-SVC-020: the display name is escaped for the header but returned raw', () => {
|
||||
it('FEED-SVC-020: the display name is escaped for the header but returned raw', async () => {
|
||||
// An unescaped ; or , ends the property value early, so the calendar shows up
|
||||
// under a truncated name. The returned calName feeds the HTTP layer, not ICS,
|
||||
// and must stay verbatim.
|
||||
const user = seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' });
|
||||
createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||||
|
||||
const { ics, calName } = svc.buildUserIcs('tok-user')!;
|
||||
const { ics, calName } = (await svc.buildUserIcs('tok-user'))!;
|
||||
|
||||
expect(calName).toBe('Alice; Bob, Co\\Ltd – All Trips');
|
||||
expect(ics).toContain('X-WR-CALNAME:Alice\\; Bob\\, Co\\\\Ltd – All Trips\r\n');
|
||||
@@ -403,7 +403,7 @@ describe('buildUserIcs', () => {
|
||||
});
|
||||
|
||||
describe('FeedsService wiring', () => {
|
||||
it('FEED-SVC-021: the module registers the service and all three controllers', () => {
|
||||
it('FEED-SVC-021: the module registers the service and all three controllers', async () => {
|
||||
expectRegisteredProvider(FeedsModule, FeedsService);
|
||||
expectRegisteredController(FeedsModule, FeedsPublicController);
|
||||
expectRegisteredController(FeedsModule, TripFeedTokenController);
|
||||
|
||||
@@ -36,9 +36,9 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow
|
||||
|
||||
describe('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => {
|
||||
|
||||
it('GET / returns reservations', () => {
|
||||
it('GET / returns reservations', async () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<ReservationsService>);
|
||||
expect(new ReservationsController(svc, airtrailLink).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
|
||||
expect(await new ReservationsController(svc, airtrailLink).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
@@ -99,11 +99,11 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
// The 'positions must be an array' 400 moved to the global
|
||||
// ZodValidationPipe (ReservationPositionsDto).
|
||||
|
||||
it('updates positions and broadcasts', () => {
|
||||
it('updates positions and broadcasts', async () => {
|
||||
const updatePositions = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = makeService({ updatePositions, broadcast } as Partial<ReservationsService>);
|
||||
const positions = [{ id: 1, day_plan_position: 0 }];
|
||||
expect(new ReservationsController(svc, airtrailLink).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true });
|
||||
expect(await new ReservationsController(svc, airtrailLink).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true });
|
||||
expect(updatePositions).toHaveBeenCalledWith('5', positions, 3);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:positions', { positions, day_id: 3 }, 'sock');
|
||||
});
|
||||
@@ -162,13 +162,13 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
|
||||
expect(await thrown(() => new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
||||
});
|
||||
|
||||
it('assigns travelers, broadcasts, and returns { travelers, reservation }', () => {
|
||||
it('assigns travelers, broadcasts, and returns { travelers, reservation }', async () => {
|
||||
const travelers = [{ user_id: 2, username: 'Sam', avatar: null, is_guest: 0 }];
|
||||
const reservation = { id: 9, travelers };
|
||||
const setTravelers = vi.fn().mockReturnValue({ travelers, reservation });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ setTravelers, broadcast } as Partial<ReservationsService>);
|
||||
expect(new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation });
|
||||
expect(await new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation });
|
||||
expect(setTravelers).toHaveBeenCalledWith('9', '5', [2]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:travelers-updated', { reservationId: 9, travelers }, 'sock');
|
||||
});
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Old title' });
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current);
|
||||
expect(reservation.title).toBe('Old title');
|
||||
expect(reservation.notes).toBe('updated');
|
||||
@@ -192,7 +192,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'X', type: 'tour' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-01T10:00:00', res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current);
|
||||
expect(reservation.reservation_time).toBeNull();
|
||||
expect(reservation.reservation_end_time).toBeNull();
|
||||
@@ -202,7 +202,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run('999', res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { notes: 'n' }, current);
|
||||
expect(reservation.accommodation_id).toBeNull();
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?')
|
||||
.run(JSON.stringify({ airline: 'CZ', seat: '72K', price: '2040', priceCurrency: 'CNY' }), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
// What the booking form sends back: rebuilt from its fields, price not among them.
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { airline: 'CZ', seat: '12A' } }, current);
|
||||
expect(JSON.parse(reservation.metadata as string)).toEqual({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' });
|
||||
@@ -227,13 +227,13 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '100' }), res.id);
|
||||
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
let { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: '150' } }, current);
|
||||
expect(JSON.parse(reservation.metadata as string)).toEqual({ price: '150' });
|
||||
|
||||
// Naming the key with a null is how a caller removes the price on purpose;
|
||||
// it must not be read as "the form did not mention it".
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
({ reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: null, seat: '1A' } }, current));
|
||||
expect(JSON.parse(reservation.metadata as string)).toEqual({ price: null, seat: '1A' });
|
||||
});
|
||||
@@ -247,7 +247,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const res = createReservation(testDb, trip.id, { title: 'Imported', type: 'flight' });
|
||||
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?')
|
||||
.run(JSON.stringify({ price: '999', priceCurrency: 'EUR' }), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { seat: '1A' } }, current);
|
||||
expect(JSON.parse(reservation.metadata as string)).toEqual({ seat: '1A', price: '999', priceCurrency: 'EUR' });
|
||||
@@ -259,7 +259,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 50 });
|
||||
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
||||
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '50' }), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: null }, current);
|
||||
expect(reservation.metadata).toBeNull();
|
||||
});
|
||||
@@ -268,10 +268,10 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Bus' });
|
||||
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
await svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 1 });
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
await svc.update(String(res.id), String(trip.id), { endpoints: [] }, current);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 0 });
|
||||
});
|
||||
@@ -304,28 +304,28 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
});
|
||||
|
||||
describe('updatePositions', () => {
|
||||
it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', () => {
|
||||
it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
||||
const r2 = createReservation(testDb, trip.id, { title: 'B' });
|
||||
svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]);
|
||||
await svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]);
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r2.id)).toEqual({ day_plan_position: 0 });
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: 1 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', () => {
|
||||
it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2030-05-01' });
|
||||
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
||||
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id);
|
||||
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE
|
||||
await svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id);
|
||||
await svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE
|
||||
expect(testDb.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(r1.id, day.id)).toEqual({ position: 5 });
|
||||
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('list / getReservationWithJoins', () => {
|
||||
it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', () => {
|
||||
it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', async () => {
|
||||
const { user, trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
@@ -334,26 +334,26 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
// The TEXT column quirk: the FK reads back as a numeric string like "14.0".
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(`${acc.id}.0`, res.id);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, days[0].id, 2);
|
||||
svc.setReservationTravelers(res.id, trip.id, [user.id]);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [user.id]);
|
||||
|
||||
const [row] = svc.list(String(trip.id));
|
||||
const [row] = (await svc.list(String(trip.id)));
|
||||
expect(row.accommodation_id).toBe(acc.id);
|
||||
expect(row.accommodation_name).toBe(place.name);
|
||||
expect(row.day_positions).toEqual({ [days[0].id]: 2 });
|
||||
expect(row.travelers).toHaveLength(1);
|
||||
|
||||
const single = svc.getReservationWithJoins(res.id)!;
|
||||
const single = (await svc.getReservationWithJoins(res.id))!;
|
||||
expect(single.accommodation_id).toBe(acc.id);
|
||||
expect(single.travelers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', () => {
|
||||
expect(svc.getReservationWithJoins(999)).toBeUndefined();
|
||||
it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', async () => {
|
||||
expect((await svc.getReservationWithJoins(999))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUpcoming', () => {
|
||||
it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', () => {
|
||||
it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const future = createReservation(testDb, trip.id, { title: 'Future' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', future.id);
|
||||
@@ -362,40 +362,40 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const cancelled = createReservation(testDb, trip.id, { title: 'Cancelled' });
|
||||
testDb.prepare("UPDATE reservations SET reservation_time = ?, status = 'cancelled' WHERE id = ?").run('2999-01-02T10:00:00', cancelled.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Future']);
|
||||
});
|
||||
|
||||
// #1934 — a stay covers a range, so it would hold a widget slot for its
|
||||
// whole span. Both ways a hotel can be dated are kept out.
|
||||
it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', () => {
|
||||
it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', hotel.id);
|
||||
const flight = createReservation(testDb, trip.id, { title: 'Flight' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-02T10:00:00', flight.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Flight']);
|
||||
});
|
||||
|
||||
it('RESV-SVC-017c: leaves out a hotel dated through its day', () => {
|
||||
it('RESV-SVC-017c: leaves out a hotel dated through its day', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel', day_id: day.id });
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual([]);
|
||||
});
|
||||
|
||||
// The type column defaults to 'other' but nothing stops a NULL, and a
|
||||
// NULL-unsafe `type != 'hotel'` would silently drop those rows.
|
||||
it('RESV-SVC-017d: a reservation with no type at all still shows', () => {
|
||||
it('RESV-SVC-017d: a reservation with no type at all still shows', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Untyped' });
|
||||
testDb.prepare('UPDATE reservations SET type = NULL, reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', res.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Untyped']);
|
||||
});
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
// day-anchored rows hold a bare 'HH:MM'. Compared as strings against an ISO
|
||||
// timestamp, ':' sorts above '2', so a bare time was an arbitrary filter:
|
||||
// everything from 20:00 on passed and everything before it vanished (#1934).
|
||||
it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', () => {
|
||||
it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
const morning = createReservation(testDb, trip.id, { title: 'Ferry', day_id: day.id });
|
||||
@@ -411,11 +411,11 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const evening = createReservation(testDb, trip.id, { title: 'Show', day_id: day.id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', evening.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Ferry', 'Show']);
|
||||
});
|
||||
|
||||
it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', () => {
|
||||
it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const early = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
const late = createDay(testDb, trip.id, { date: '2999-06-02' });
|
||||
@@ -428,27 +428,27 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const dayOnly = createReservation(testDb, trip.id, { title: 'No time', day_id: late.id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = NULL WHERE id = ?').run(dayOnly.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Bare morning', 'Bare evening', 'No time', 'Dated day two']);
|
||||
});
|
||||
|
||||
// The stay stays out, but arriving and leaving are moments, and they are the
|
||||
// two the traveller needs reminding of.
|
||||
it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', () => {
|
||||
it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
const end = createDay(testDb, trip.id, { date: '2999-06-05' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'The Plaza' });
|
||||
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string; type: string; day_date: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string; type: string; day_date: string }[];
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ title: 'The Plaza', type: 'checkin', day_date: '2999-06-01' }),
|
||||
expect.objectContaining({ title: 'The Plaza', type: 'checkout', day_date: '2999-06-05' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', () => {
|
||||
it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
const end = createDay(testDb, trip.id, { date: '2999-06-03' });
|
||||
@@ -459,21 +459,21 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const museum = createReservation(testDb, trip.id, { title: 'Museum' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', museum.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toEqual(['Hostel', 'Dinner', 'Museum', 'Hostel']);
|
||||
});
|
||||
|
||||
it('RESV-SVC-017i: a stay that is already over contributes nothing', () => {
|
||||
it('RESV-SVC-017i: a stay that is already over contributes nothing', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const start = createDay(testDb, trip.id, { date: '2000-06-01' });
|
||||
const end = createDay(testDb, trip.id, { date: '2000-06-05' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old Inn' });
|
||||
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
|
||||
|
||||
expect(svc.listUpcoming(user.id)).toEqual([]);
|
||||
expect((await svc.listUpcoming(user.id))).toEqual([]);
|
||||
});
|
||||
|
||||
it('RESV-SVC-017j: a nameless stay falls back to its linked booking', () => {
|
||||
it('RESV-SVC-017j: a nameless stay falls back to its linked booking', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
const end = createDay(testDb, trip.id, { date: '2999-06-02' });
|
||||
@@ -483,13 +483,13 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const booking = createReservation(testDb, trip.id, { title: 'Hotel Ibis', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.lastInsertRowid), booking.id);
|
||||
|
||||
const rows = svc.listUpcoming(user.id) as { title: string; type: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string; type: string }[];
|
||||
expect(rows.map(r => r.type + ':' + r.title)).toEqual(['checkin:Hotel Ibis', 'checkout:Hotel Ibis']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resyncReservationDays', () => {
|
||||
it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', () => {
|
||||
it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', async () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'Tour', type: 'tour', day_id: days[0].id });
|
||||
@@ -497,22 +497,22 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
const outside = createReservation(testDb, trip.id, { title: 'Outside', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2031-01-01T09:00:00', outside.id);
|
||||
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
await svc.resyncReservationDays(String(trip.id));
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[2].id });
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(outside.id)).toEqual({ day_id: days[0].id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTravelers (controller-facing composition)', () => {
|
||||
it('returns null when the reservation is not on the trip (off-trip guard)', () => {
|
||||
it('returns null when the reservation is not on the trip (off-trip guard)', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
expect(svc.setTravelers('999', String(trip.id), [2])).toBeNull();
|
||||
expect((await svc.setTravelers('999', String(trip.id), [2]))).toBeNull();
|
||||
});
|
||||
|
||||
it('assigns travelers and returns the refreshed travelers + reservation', () => {
|
||||
it('assigns travelers and returns the refreshed travelers + reservation', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const result = svc.setTravelers(String(res.id), String(trip.id), [user.id])!;
|
||||
const result = (await svc.setTravelers(String(res.id), String(trip.id), [user.id]))!;
|
||||
expect(result.travelers).toHaveLength(1);
|
||||
expect(result.travelers[0]).toMatchObject({ user_id: user.id });
|
||||
expect(result.reservation).toMatchObject({ id: res.id });
|
||||
@@ -595,7 +595,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
describe('notifyBookingChange', () => {
|
||||
it('sends the booking_change notification with the legacy payload (fire-and-forget)', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
expect(() => svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).not.toThrow();
|
||||
await expect(svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).resolves.not.toThrow();
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
expect(notif.send).toHaveBeenCalledWith({
|
||||
event: 'booking_change',
|
||||
@@ -608,7 +608,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
|
||||
|
||||
it('is a silent no-op when the actor does not exist', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
svc.notifyBookingChange(String(trip.id), 999, 'Hotel', '');
|
||||
await svc.notifyBookingChange(String(trip.id), 999, 'Hotel', '');
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(notif.send).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -624,21 +624,21 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
return user;
|
||||
}
|
||||
|
||||
it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', () => {
|
||||
it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [member.id]);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [member.id]);
|
||||
|
||||
const travelers = svc.loadTravelers(res.id);
|
||||
const travelers = (await svc.loadTravelers(res.id));
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: member.id, username: member.username, is_guest: 0 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', () => {
|
||||
it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -650,27 +650,27 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
const otherTrip = createTrip(testDb, outsider.id);
|
||||
addTripMember(testDb, otherTrip.id, outsider.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]);
|
||||
|
||||
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
||||
const ids = (await svc.loadTravelers(res.id)).map(t => t.user_id);
|
||||
expect(ids).toEqual([member.id]);
|
||||
expect(ids).not.toContain(outsider.id);
|
||||
});
|
||||
|
||||
it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', () => {
|
||||
it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const guest = addGuest(trip.id, 'Grandma');
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [guest.id]);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [guest.id]);
|
||||
|
||||
const travelers = svc.loadTravelers(res.id);
|
||||
const travelers = (await svc.loadTravelers(res.id));
|
||||
expect(travelers).toHaveLength(1);
|
||||
expect(travelers[0]).toMatchObject({ user_id: guest.id, username: 'Grandma', is_guest: 1 });
|
||||
});
|
||||
|
||||
it('RES-TRAV-004: re-setting replaces the previous set', () => {
|
||||
it('RES-TRAV-004: re-setting replaces the previous set', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
@@ -679,20 +679,20 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
addTripMember(testDb, trip.id, b.id);
|
||||
const res = createReservation(testDb, trip.id);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [a.id]);
|
||||
expect(svc.loadTravelers(res.id).map(t => t.user_id)).toEqual([a.id]);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [a.id]);
|
||||
expect((await svc.loadTravelers(res.id)).map(t => t.user_id)).toEqual([a.id]);
|
||||
|
||||
svc.setReservationTravelers(res.id, trip.id, [b.id]);
|
||||
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
||||
await svc.setReservationTravelers(res.id, trip.id, [b.id]);
|
||||
const ids = (await svc.loadTravelers(res.id)).map(t => t.user_id);
|
||||
expect(ids).toEqual([b.id]);
|
||||
expect(ids).not.toContain(a.id);
|
||||
|
||||
// Clearing removes everyone.
|
||||
svc.setReservationTravelers(res.id, trip.id, []);
|
||||
expect(svc.loadTravelers(res.id)).toHaveLength(0);
|
||||
await svc.setReservationTravelers(res.id, trip.id, []);
|
||||
expect((await svc.loadTravelers(res.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RES-TRAV-005: list attaches travelers[] per reservation', () => {
|
||||
it('RES-TRAV-005: list attaches travelers[] per reservation', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -700,9 +700,9 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
const withTravelers = createReservation(testDb, trip.id, { title: 'Flight' });
|
||||
const withoutTravelers = createReservation(testDb, trip.id, { title: 'Hotel' });
|
||||
|
||||
svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]);
|
||||
await await svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]);
|
||||
|
||||
const list = svc.list(trip.id);
|
||||
const list = await (await svc.list(trip.id));
|
||||
const assigned = list.find(r => r.id === withTravelers.id)!;
|
||||
const empty = list.find(r => r.id === withoutTravelers.id)!;
|
||||
|
||||
@@ -713,26 +713,26 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
||||
});
|
||||
|
||||
describe('ReservationsService — the surface the deleted bridge exposed', () => {
|
||||
it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', () => {
|
||||
it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
createReservation(testDb, trip.id, { title: 'A' });
|
||||
expect(bridge.listReservations(trip.id).map(r => r.title)).toEqual(['A']);
|
||||
expect((await bridge.listReservations(trip.id)).map(r => r.title)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', () => {
|
||||
it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
||||
const map = bridge.loadEndpointsByTrip(trip.id);
|
||||
const map = await bridge.loadEndpointsByTrip(trip.id);
|
||||
expect(map.get(res.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', () => {
|
||||
it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', async () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-02T09:00:00', res.id);
|
||||
bridge.resyncReservationDays(trip.id);
|
||||
await bridge.resyncReservationDays(trip.id);
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
||||
});
|
||||
|
||||
@@ -742,23 +742,23 @@ describe('ReservationsService — the surface the deleted bridge exposed', () =>
|
||||
expect(reservation).toMatchObject({ title: 'Bridge', type: 'other' });
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-005: getReservation is trip-scoped', () => {
|
||||
it('RESV-BRIDGE-005: getReservation is trip-scoped', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
expect(bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id });
|
||||
expect(bridge.getReservation(res.id, trip.id + 1)).toBeUndefined();
|
||||
expect(await bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id });
|
||||
expect(await bridge.getReservation(res.id, trip.id + 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', () => {
|
||||
it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id);
|
||||
expect(bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] });
|
||||
expect(await bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] });
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-007: updateReservation updates through the bridge', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Old' });
|
||||
const current = bridge.getReservation(res.id, trip.id)!;
|
||||
const current = (await bridge.getReservation(res.id, trip.id))!;
|
||||
const { reservation } = await bridge.updateReservation(res.id, trip.id, { title: 'New' }, current);
|
||||
expect(reservation.title).toBe('New');
|
||||
});
|
||||
@@ -768,12 +768,12 @@ describe('ReservationsService — the surface the deleted bridge exposed', () =>
|
||||
const res = createReservation(testDb, trip.id);
|
||||
const { deleted } = await bridge.deleteReservation(res.id, trip.id);
|
||||
expect(deleted).toMatchObject({ id: res.id });
|
||||
expect(bridge.getReservation(res.id, trip.id)).toBeUndefined();
|
||||
expect(await bridge.getReservation(res.id, trip.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-BRIDGE-009: notifyBookingChange fires the notification through the bridge', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other');
|
||||
await bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other');
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -807,7 +807,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { accommodationChanged } = await svc.update(String(res.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
create_accommodation: { place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00' },
|
||||
@@ -823,7 +823,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const { reservation, accommodationChanged } = await svc.update(String(res.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
||||
@@ -838,7 +838,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
||||
testDb.prepare("UPDATE reservations SET location = 'Loc', notes = 'N', url = 'U', confirmation_number = 'C' WHERE id = ?").run(res.id);
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
let { reservation } = await svc.update(String(res.id), String(trip.id), {
|
||||
location: 'NewLoc', notes: '', url: 'https://x', confirmation_number: 'NEW',
|
||||
status: 'confirmed', needs_review: true, metadata: { a: 1 }, place_id: place.id,
|
||||
@@ -847,7 +847,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
location: 'NewLoc', notes: null, url: 'https://x', confirmation_number: 'NEW',
|
||||
status: 'confirmed', needs_review: 1, metadata: JSON.stringify({ a: 1 }), place_id: place.id,
|
||||
});
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
({ reservation } = await svc.update(String(res.id), String(trip.id), { title: 'T2' }, current));
|
||||
expect(reservation).toMatchObject({ title: 'T2', location: 'NewLoc', url: 'https://x', place_id: place.id });
|
||||
});
|
||||
@@ -856,14 +856,14 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
||||
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
let current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
let { reservation } = await svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current);
|
||||
expect(reservation).toMatchObject({ day_id: days[1].id, end_day_id: days[2].id });
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
({ reservation } = await svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current));
|
||||
// explicit end_day_id null wins over the end-time derivation
|
||||
expect(reservation.end_day_id).toBeNull();
|
||||
current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
({ reservation } = await svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current));
|
||||
// no explicit end day + an end time -> derived
|
||||
expect(reservation.end_day_id).toBe(days[1].id);
|
||||
@@ -877,49 +877,49 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare("UPDATE reservations SET accommodation_id = ?, metadata = ?, confirmation_number = 'KEEP' WHERE id = ?")
|
||||
.run(String(acc.id), JSON.stringify({ check_in_time: '16:00' }), res.id);
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
await svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current);
|
||||
const row = testDb.prepare('SELECT check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
||||
expect(row).toEqual({ check_in: '16:00', confirmation: 'KEEP' });
|
||||
});
|
||||
|
||||
it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', () => {
|
||||
it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', async () => {
|
||||
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
||||
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ?, reservation_end_time = ?, end_day_id = ? WHERE id = ?')
|
||||
.run('2030-05-02T09:00:00', '2031-01-01T09:00:00', days[0].id, res.id);
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
await svc.resyncReservationDays(String(trip.id));
|
||||
// start re-anchored to day 2; out-of-range end time keeps the stored end day
|
||||
expect(testDb.prepare('SELECT day_id, end_day_id FROM reservations WHERE id = ?').get(res.id))
|
||||
.toEqual({ day_id: days[1].id, end_day_id: days[0].id });
|
||||
// A booking already on the right day is left untouched (no-change skip).
|
||||
svc.resyncReservationDays(String(trip.id));
|
||||
await svc.resyncReservationDays(String(trip.id));
|
||||
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
||||
});
|
||||
|
||||
it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', () => {
|
||||
it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', async () => {
|
||||
const { user, trip } = ownerTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
||||
createReservation(testDb, trip.id, { title: 'Timeless', day_id: day.id });
|
||||
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
||||
const rows = (await svc.listUpcoming(user.id)) as { title: string }[];
|
||||
expect(rows.map(r => r.title)).toContain('Timeless');
|
||||
});
|
||||
|
||||
it('RESV-SVC-028: list merges multiple per-day positions for one reservation', () => {
|
||||
it('RESV-SVC-028: list merges multiple per-day positions for one reservation', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const d1 = createDay(testDb, trip.id, { date: '2030-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2030-05-02' });
|
||||
const res = createReservation(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d1.id, 0);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d2.id, 3);
|
||||
const [row] = svc.list(String(trip.id));
|
||||
const [row] = (await svc.list(String(trip.id)));
|
||||
expect(row.day_positions).toEqual({ [d1.id]: 0, [d2.id]: 3 });
|
||||
});
|
||||
|
||||
it('RESV-SVC-029: notifyBookingChange falls back to "Untitled" and type "booking"', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
svc.notifyBookingChange('424242', user.id, 'B', '');
|
||||
await svc.notifyBookingChange('424242', user.id, 'B', '');
|
||||
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
||||
expect(notif.send).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: expect.objectContaining({ trip: 'Untitled', type: 'booking' }),
|
||||
@@ -942,7 +942,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', () => {
|
||||
it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
createReservation(testDb, trip.id, { title: 'Live One' });
|
||||
const staged = createReservation(testDb, trip.id, { title: 'Parked One' });
|
||||
@@ -951,7 +951,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
|
||||
// The visibility predicate belongs to the anonymous exports (ICS feed,
|
||||
// shared trip) and must never be pulled into the authenticated list, or
|
||||
// nobody can confirm what the ingest parked.
|
||||
expect(svc.list(String(trip.id)).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']);
|
||||
expect((await svc.list(String(trip.id))).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -974,7 +974,7 @@ describe('ReservationsService — quirk fixes (post-fold)', () => {
|
||||
it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', async () => {
|
||||
const { trip } = ownerTrip();
|
||||
const res = createReservation(testDb, trip.id, { title: 'Old' });
|
||||
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(trip.id)))!;
|
||||
const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }];
|
||||
await expect(svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).rejects.toThrow();
|
||||
expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' });
|
||||
@@ -991,13 +991,13 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
return { mine, theirs };
|
||||
}
|
||||
|
||||
it('RESV-SCOPE-001: names every foreign id in the body', () => {
|
||||
it('RESV-SCOPE-001: names every foreign id in the body', async () => {
|
||||
const { mine, theirs } = twoTrips();
|
||||
const foreignPlace = createPlace(testDb, theirs.id);
|
||||
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
||||
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id);
|
||||
|
||||
expect(svc.referencesOutsideTrip(String(mine.id), {
|
||||
expect(await svc.referencesOutsideTrip(String(mine.id), {
|
||||
title: 'x', day_id: foreignDay.id, place_id: foreignPlace.id, accommodation_id: foreignAcc.id,
|
||||
})).toEqual(['day_id', 'place_id', 'accommodation_id']);
|
||||
});
|
||||
@@ -1013,21 +1013,21 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
// takes the accommodation with it, and the reservation keeps the id.
|
||||
testDb.prepare('DELETE FROM day_accommodations WHERE id = ?').run(acc.id);
|
||||
|
||||
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id })).toEqual([]);
|
||||
expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id }))).toEqual([]);
|
||||
|
||||
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(mine.id)))!;
|
||||
await svc.update(String(res.id), String(mine.id), { accommodation_id: acc.id, title: 'Hotel renamed' } as never, current);
|
||||
expect(testDb.prepare('SELECT title, accommodation_id FROM reservations WHERE id = ?').get(res.id))
|
||||
.toEqual({ title: 'Hotel renamed', accommodation_id: null });
|
||||
});
|
||||
|
||||
it('RESV-SCOPE-002: passes a body whose ids all live in the trip', () => {
|
||||
it('RESV-SCOPE-002: passes a body whose ids all live in the trip', async () => {
|
||||
const { mine } = twoTrips();
|
||||
const place = createPlace(testDb, mine.id);
|
||||
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number };
|
||||
|
||||
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id })).toEqual([]);
|
||||
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x' })).toEqual([]);
|
||||
expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id }))).toEqual([]);
|
||||
expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', async () => {
|
||||
@@ -1051,7 +1051,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
||||
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id, { check_in: '14:00' });
|
||||
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
||||
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(mine.id)))!;
|
||||
|
||||
await svc.update(String(res.id), String(mine.id), {
|
||||
accommodation_id: foreignAcc.id, metadata: { check_in_time: '23:00' },
|
||||
@@ -1111,7 +1111,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
const foreignPlace = createPlace(testDb, theirs.id);
|
||||
const body = { title: 'x', place_id: foreignPlace.id };
|
||||
|
||||
expect(svc.referencesOutsideTrip(String(mine.id), body)).toEqual(['place_id']);
|
||||
expect((await svc.referencesOutsideTrip(String(mine.id), body))).toEqual(['place_id']);
|
||||
expect(await svc.unresolvedReferences(String(mine.id), body)).toEqual(['place_id']);
|
||||
});
|
||||
|
||||
@@ -1122,7 +1122,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
testDb.prepare('UPDATE reservations SET day_id = ?, end_day_id = ?, place_id = ?, assignment_id = ? WHERE id = ?')
|
||||
.run(999999, 999998, 999997, 999996, res.id);
|
||||
});
|
||||
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(mine.id)))!;
|
||||
|
||||
// The body names none of them, so the statement rebinds what the row holds.
|
||||
await svc.update(String(res.id), String(mine.id), { title: 'Dinner, later' }, current);
|
||||
@@ -1139,7 +1139,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
expect(testDb.prepare('SELECT day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(reservation.id))
|
||||
.toEqual({ day_id: null, end_day_id: null, place_id: null, assignment_id: null });
|
||||
|
||||
const current = svc.getReservation(String(reservation.id), String(mine.id))!;
|
||||
const current = (await svc.getReservation(String(reservation.id), String(mine.id)))!;
|
||||
await svc.update(String(reservation.id), String(mine.id), { place_id: 999997 }, current);
|
||||
expect(testDb.prepare('SELECT place_id FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ place_id: null });
|
||||
});
|
||||
@@ -1161,7 +1161,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => {
|
||||
const acc = createDayAccommodation(testDb, mine.id, place.id, days[0].id, days[1].id);
|
||||
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
||||
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(acc.id, res.id);
|
||||
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
||||
const current = (await svc.getReservation(String(res.id), String(mine.id)))!;
|
||||
|
||||
await expect(svc.update(String(res.id), String(mine.id), {
|
||||
type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: 999999 },
|
||||
@@ -1251,7 +1251,7 @@ describe('the day stop a hotel booking implies', () => {
|
||||
title: 'Hotel', type: 'hotel',
|
||||
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id },
|
||||
});
|
||||
const current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(reservation.id), String(trip.id)))!;
|
||||
|
||||
await svc.update(String(reservation.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
@@ -1270,7 +1270,7 @@ describe('the day stop a hotel booking implies', () => {
|
||||
const { trip, days } = tripWithDays();
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel' });
|
||||
const current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
const current = (await svc.getReservation(String(reservation.id), String(trip.id)))!;
|
||||
|
||||
await svc.update(String(reservation.id), String(trip.id), {
|
||||
type: 'hotel',
|
||||
|
||||
@@ -8,10 +8,17 @@ import { RoadtripPreferencesMcp } from '../../../src/nest/roadtrip/roadtrip-pref
|
||||
import { RoadtripPreferencesService } from '../../../src/nest/roadtrip/roadtrip-preferences.service';
|
||||
import { RoadtripMcp } from '../../../src/nest/roadtrip/roadtrip.mcp';
|
||||
import { roadtripPreferencesUpdateSchema } from '@trek/shared';
|
||||
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const ctx = { userId: 5 } as McpContext;
|
||||
/**
|
||||
* There is no database behind this suite — every statement is served by the
|
||||
* fakes below — so the UnitOfWork is a pass-through: it runs the callback as
|
||||
* MikroORM would, with no transaction to open on.
|
||||
*/
|
||||
const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown as UnitOfWork;
|
||||
function setup() {
|
||||
const settings: Record<string, unknown> = {
|
||||
roadtrip_day_start: '08:00',
|
||||
@@ -35,7 +42,7 @@ function setup() {
|
||||
),
|
||||
transaction: (fn: () => unknown) => fn(),
|
||||
};
|
||||
const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never);
|
||||
const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never, uowStub);
|
||||
const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }];
|
||||
const visits = [1, 2, 3].map((id) => ({
|
||||
id,
|
||||
@@ -92,22 +99,22 @@ function setup() {
|
||||
}
|
||||
|
||||
describe('roadtrip preferences', () => {
|
||||
it('reads only public driving preferences and never endpoint URLs or credentials', () => {
|
||||
it('reads only public driving preferences and never endpoint URLs or credentials', async () => {
|
||||
const s = setup();
|
||||
expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 });
|
||||
expect(JSON.stringify(s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/);
|
||||
expect(await s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 });
|
||||
expect(JSON.stringify(await s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/);
|
||||
});
|
||||
it('validates the complete window before an atomic write and broadcasts to every member of its trip', () => {
|
||||
it('validates the complete window before an atomic write and broadcasts to every member of its trip', async () => {
|
||||
const s = setup();
|
||||
expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
|
||||
await expect(s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).rejects.toThrow();
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
|
||||
await s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
|
||||
// The fourth argument is the socket that saved, so the tab that made the
|
||||
// change is left out of its own echo. Undefined here: no header was sent.
|
||||
expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', {
|
||||
preferences: s.preferences.read(10),
|
||||
preferences: await s.preferences.read(10),
|
||||
}, undefined);
|
||||
expect(s.preferences.read(10).roadtrip_range_km).toBe(100);
|
||||
expect((await s.preferences.read(10)).roadtrip_range_km).toBe(100);
|
||||
});
|
||||
it.each([
|
||||
{ llm_api_key: 'x' },
|
||||
@@ -121,8 +128,8 @@ describe('roadtrip preferences', () => {
|
||||
});
|
||||
it('allows clearing daily times and limits and refuses demo writes', async () => {
|
||||
const s = setup();
|
||||
s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 });
|
||||
expect(s.preferences.read(10).roadtrip_day_start).toBe('');
|
||||
await s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 });
|
||||
expect((await s.preferences.read(10)).roadtrip_day_start).toBe('');
|
||||
const mcp = new RoadtripPreferencesMcp(
|
||||
s.preferences,
|
||||
{ isDemoUser: () => true } as never,
|
||||
@@ -147,7 +154,7 @@ describe('roadtrip preferences', () => {
|
||||
);
|
||||
const res = await mcp.update({ tripId: 10, settings: { roadtrip_day_end: '06:00' } }, ctx);
|
||||
expect([res.isError, res.content[0].text]).toEqual([true, 'Day end must be later than day start.']);
|
||||
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
|
||||
expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +173,7 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
it('previews settings without saving them', async () => {
|
||||
const s = setup();
|
||||
await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' });
|
||||
expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00');
|
||||
expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00');
|
||||
expect(s.preferenceDb.run).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks trip access before reading or routing', async () => {
|
||||
|
||||
@@ -43,8 +43,8 @@ function svc(o: Partial<RoadtripService> = {}): RoadtripService {
|
||||
} as unknown as RoadtripService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
@@ -68,25 +68,25 @@ describe('RoadtripController — the guard chain', () => {
|
||||
});
|
||||
|
||||
describe('RoadtripController — reads', () => {
|
||||
it('ROADTRIP-CTL-002: the trip read hands back vias and tracks in one answer', () => {
|
||||
it('ROADTRIP-CTL-002: the trip read hands back vias and tracks in one answer', async () => {
|
||||
const s = svc();
|
||||
expect(new RoadtripController(s).listAll('7')).toEqual({ vias: [VIA], tracks: [] });
|
||||
expect(await new RoadtripController(s).listAll('7')).toEqual({ vias: [VIA], tracks: [] });
|
||||
expect(s.listForTrip).toHaveBeenCalledWith('7');
|
||||
expect(s.tracksForTrip).toHaveBeenCalledWith('7');
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-003: the day read checks the day belongs to this trip', () => {
|
||||
it('ROADTRIP-CTL-003: the day read checks the day belongs to this trip', async () => {
|
||||
const s = svc();
|
||||
expect(new RoadtripController(s).list('7', '4')).toEqual({ vias: [VIA] });
|
||||
expect(await new RoadtripController(s).list('7', '4')).toEqual({ vias: [VIA] });
|
||||
expect(s.dayExists).toHaveBeenCalledWith('4', '7');
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-004: a day from another trip is not readable through this one', () => {
|
||||
it('ROADTRIP-CTL-004: a day from another trip is not readable through this one', async () => {
|
||||
// The guard proves the caller may reach the trip; this proves the day is
|
||||
// part of it. Without the check a valid day id from somebody else's trip is
|
||||
// reachable by way of a trip the caller does have access to.
|
||||
const s = svc({ dayExists: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new RoadtripController(s).list('7', '999')))
|
||||
expect(await thrown(() => new RoadtripController(s).list('7', '999')))
|
||||
.toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
});
|
||||
});
|
||||
@@ -94,23 +94,23 @@ describe('RoadtripController — reads', () => {
|
||||
describe('RoadtripController — writes', () => {
|
||||
const body = { after_order_index: 0, lat: 53, lng: 10 };
|
||||
|
||||
it('ROADTRIP-CTL-005: creating one checks the day first', () => {
|
||||
it('ROADTRIP-CTL-005: creating one checks the day first', async () => {
|
||||
const s = svc();
|
||||
expect(new RoadtripController(s).create('7', '4', body)).toEqual({ via: VIA });
|
||||
expect(await new RoadtripController(s).create('7', '4', body)).toEqual({ via: VIA });
|
||||
expect(s.dayExists).toHaveBeenCalledWith('4', '7');
|
||||
expect(s.create).toHaveBeenCalledWith('4', body);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-006: every write refuses a day from another trip', () => {
|
||||
it('ROADTRIP-CTL-006: every write refuses a day from another trip', async () => {
|
||||
const s = svc({ dayExists: vi.fn().mockReturnValue(false) });
|
||||
const c = new RoadtripController(s);
|
||||
const notFound = { status: 404, body: { error: 'Day not found' } };
|
||||
|
||||
expect(thrown(() => c.create('7', '999', body))).toEqual(notFound);
|
||||
expect(thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound);
|
||||
expect(thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound);
|
||||
expect(thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound);
|
||||
expect(thrown(() => c.remove('7', '999', '5'))).toEqual(notFound);
|
||||
expect(await thrown(() => c.create('7', '999', body))).toEqual(notFound);
|
||||
expect(await thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound);
|
||||
expect(await thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound);
|
||||
expect(await thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound);
|
||||
expect(await thrown(() => c.remove('7', '999', '5'))).toEqual(notFound);
|
||||
|
||||
// None of them reached the service.
|
||||
expect(s.create).not.toHaveBeenCalled();
|
||||
@@ -120,74 +120,74 @@ describe('RoadtripController — writes', () => {
|
||||
expect(s.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-007: a batch lays the whole chain in one call', () => {
|
||||
it('ROADTRIP-CTL-007: a batch lays the whole chain in one call', async () => {
|
||||
const s = svc();
|
||||
const batch = { vias: [body, body], replace_legs: [0] };
|
||||
expect(new RoadtripController(s).createMany('7', '4', batch)).toEqual({ vias: [VIA] });
|
||||
expect(await new RoadtripController(s).createMany('7', '4', batch)).toEqual({ vias: [VIA] });
|
||||
expect(s.createMany).toHaveBeenCalledWith('4', batch);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-008: a track from another trip cannot become this day label', () => {
|
||||
it('ROADTRIP-CTL-008: a track from another trip cannot become this day label', async () => {
|
||||
// Permission is not enough on its own: a place id from somebody else's trip
|
||||
// would otherwise become this day's label, and a place that is not a track
|
||||
// would become a label that can never be drawn.
|
||||
const s = svc({ trackExists: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } })))
|
||||
expect(await thrown(() => new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } })))
|
||||
.toEqual({ status: 404, body: { error: 'Track not found' } });
|
||||
expect(s.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-009: the track check runs against the trip, not the day', () => {
|
||||
it('ROADTRIP-CTL-009: the track check runs against the trip, not the day', async () => {
|
||||
const s = svc();
|
||||
new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } });
|
||||
await new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } });
|
||||
expect(s.trackExists).toHaveBeenCalledWith(3, '7');
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-010: clearing a day track is not a track to look up', () => {
|
||||
it('ROADTRIP-CTL-010: clearing a day track is not a track to look up', async () => {
|
||||
// `null` says the day follows nothing any more, so there is no place id to
|
||||
// check the existence of. Treating it as one would refuse the only way to
|
||||
// detach a track.
|
||||
const s = svc({ trackExists: vi.fn().mockReturnValue(false) });
|
||||
expect(new RoadtripController(s).createMany('7', '4', { vias: [body], track: null })).toEqual({ vias: [VIA] });
|
||||
expect(await new RoadtripController(s).createMany('7', '4', { vias: [body], track: null })).toEqual({ vias: [VIA] });
|
||||
expect(s.trackExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-011: re-anchoring goes through as one batch', () => {
|
||||
it('ROADTRIP-CTL-011: re-anchoring goes through as one batch', async () => {
|
||||
const s = svc();
|
||||
const plan = { vias: [{ id: 5, after_order_index: 1 }], remove: [6] };
|
||||
expect(new RoadtripController(s).reanchor('7', '4', plan)).toEqual({ vias: [VIA] });
|
||||
expect(await new RoadtripController(s).reanchor('7', '4', plan)).toEqual({ vias: [VIA] });
|
||||
expect(s.reanchor).toHaveBeenCalledWith('4', plan);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-012: moving a via reports where it went', () => {
|
||||
it('ROADTRIP-CTL-012: moving a via reports where it went', async () => {
|
||||
const s = svc();
|
||||
expect(new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA });
|
||||
expect(await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA });
|
||||
// Undefined anchor, explicitly: a drag that stayed between the same two stops sends
|
||||
// no new one, and the service must leave the existing pin alone rather than clear it.
|
||||
expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, undefined);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', () => {
|
||||
it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', async () => {
|
||||
const s = svc();
|
||||
new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 });
|
||||
await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 });
|
||||
expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, 2);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', () => {
|
||||
it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', async () => {
|
||||
const s = svc({ move: vi.fn().mockReturnValue(null) });
|
||||
expect(thrown(() => new RoadtripController(s).update('7', '4', '999', { lat: 1, lng: 2 })))
|
||||
expect(await thrown(() => new RoadtripController(s).update('7', '4', '999', { lat: 1, lng: 2 })))
|
||||
.toEqual({ status: 404, body: { error: 'Via not found' } });
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-014: removing one reports success', () => {
|
||||
it('ROADTRIP-CTL-014: removing one reports success', async () => {
|
||||
const s = svc();
|
||||
expect(new RoadtripController(s).remove('7', '4', '5')).toEqual({ success: true });
|
||||
expect(await new RoadtripController(s).remove('7', '4', '5')).toEqual({ success: true });
|
||||
expect(s.remove).toHaveBeenCalledWith('5', '4');
|
||||
});
|
||||
|
||||
it('ROADTRIP-CTL-015: removing a via that is not there is a 404', () => {
|
||||
it('ROADTRIP-CTL-015: removing a via that is not there is a 404', async () => {
|
||||
const s = svc({ remove: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new RoadtripController(s).remove('7', '4', '999')))
|
||||
expect(await thrown(() => new RoadtripController(s).remove('7', '4', '999')))
|
||||
.toEqual({ status: 404, body: { error: 'Via not found' } });
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('RoadtripController — writes', () => {
|
||||
* silence, which left the other side reading a route nobody could see change.
|
||||
*/
|
||||
describe('telling the trip', () => {
|
||||
it('SRV-ROADTRIP-020: every write announces the new shape of the day', () => {
|
||||
it('SRV-ROADTRIP-020: every write announces the new shape of the day', async () => {
|
||||
const cases: [string, (c: RoadtripController) => unknown][] = [
|
||||
['create', c => c.create('7', '4', { after_order_index: 0, lat: 1, lng: 2 }, 'sock')],
|
||||
['createMany', c => c.createMany('7', '4', { vias: [{ after_order_index: 0, lat: 1, lng: 2 }] }, 'sock')],
|
||||
@@ -207,7 +207,7 @@ describe('RoadtripController — writes', () => {
|
||||
];
|
||||
for (const [name, run] of cases) {
|
||||
const s = svc();
|
||||
run(new RoadtripController(s));
|
||||
await run(new RoadtripController(s));
|
||||
expect(s.broadcast, name).toHaveBeenCalledWith(
|
||||
'7',
|
||||
'roadtripVia:changed',
|
||||
@@ -217,24 +217,24 @@ describe('RoadtripController — writes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', () => {
|
||||
it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', async () => {
|
||||
// A reanchor rewrites the whole set and a batch may clear legs before filling them,
|
||||
// so what the write returned is not what the day now holds.
|
||||
const s = svc({ reanchor: vi.fn().mockReturnValue([]) } as Partial<RoadtripService>);
|
||||
new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock');
|
||||
await new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock');
|
||||
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock');
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', () => {
|
||||
it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', async () => {
|
||||
const track = { day_id: 4, place_id: 3, name: 'B96' };
|
||||
const s = svc({ tracksForTrip: vi.fn().mockReturnValue([track]) } as Partial<RoadtripService>);
|
||||
new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock');
|
||||
await new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock');
|
||||
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripTrack:changed', { dayId: '4', track }, 'sock');
|
||||
});
|
||||
|
||||
it('SRV-ROADTRIP-023: a refused write announces nothing', () => {
|
||||
it('SRV-ROADTRIP-023: a refused write announces nothing', async () => {
|
||||
const s = svc({ remove: vi.fn().mockReturnValue(false) } as Partial<RoadtripService>);
|
||||
expect(() => new RoadtripController(s).remove('7', '4', '9', 'sock')).toThrow();
|
||||
await expect(new RoadtripController(s).remove('7', '4', '9', 'sock')).rejects.toThrow();
|
||||
expect(s.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
|
||||
/**
|
||||
* Via points, against a real SQLite. The table is tiny and the interesting parts are the
|
||||
* ordering and the day scoping, both of which are SQL — mocking the DB would test the mock.
|
||||
*/
|
||||
function makeService() {
|
||||
async function makeService() {
|
||||
const raw = new Database(':memory:');
|
||||
raw.exec(`
|
||||
CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL);
|
||||
@@ -45,91 +46,91 @@ function makeService() {
|
||||
};
|
||||
// The realtime side is exercised through the controller and the MCP layer; here the
|
||||
// service is under test for what it writes, so a broadcast that goes nowhere is right.
|
||||
return new RoadtripService(db as never, { broadcast: () => {} } as never);
|
||||
return new RoadtripService(db as never, { broadcast: () => {} } as never, await createTestUnitOfWork(raw));
|
||||
}
|
||||
|
||||
describe('RoadtripService', () => {
|
||||
let service: RoadtripService;
|
||||
beforeEach(() => { service = makeService(); });
|
||||
beforeEach(async () => { service = await makeService(); });
|
||||
|
||||
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', () => {
|
||||
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => {
|
||||
// The trip guard proves access to the trip; this is what stops a valid day id from
|
||||
// someone else’s trip being edited through one the caller can reach.
|
||||
expect(service.dayExists(1, 7)).toBe(true);
|
||||
expect(service.dayExists(3, 7)).toBe(false);
|
||||
expect((await service.dayExists(1, 7))).toBe(true);
|
||||
expect((await service.dayExists(3, 7))).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', () => {
|
||||
const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 });
|
||||
it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', async () => {
|
||||
const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const second = (await service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 }));
|
||||
|
||||
expect(first.sequence).toBe(0);
|
||||
expect(second.sequence).toBe(1);
|
||||
expect(service.listForDay(1).map(v => v.id)).toEqual([first.id, second.id]);
|
||||
expect((await service.listForDay(1)).map(v => v.id)).toEqual([first.id, second.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', () => {
|
||||
const late = service.create(1, { after_order_index: 2, lat: 51, lng: 12 });
|
||||
const early = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', async () => {
|
||||
const late = (await service.create(1, { after_order_index: 2, lat: 51, lng: 12 }));
|
||||
const early = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
expect(service.listForDay(1).map(v => v.id)).toEqual([early.id, late.id]);
|
||||
expect((await service.listForDay(1)).map(v => v.id)).toEqual([early.id, late.id]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', () => {
|
||||
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
service.create(2, { after_order_index: 0, lat: 52, lng: 11 });
|
||||
service.create(3, { after_order_index: 0, lat: 50, lng: 14 });
|
||||
it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
(await service.create(2, { after_order_index: 0, lat: 52, lng: 11 }));
|
||||
(await service.create(3, { after_order_index: 0, lat: 50, lng: 14 }));
|
||||
|
||||
// Day 3 belongs to trip 99 and must not appear in trip 7.
|
||||
expect(service.listForTrip(7).map(v => v.day_id)).toEqual([1, 2]);
|
||||
expect((await service.listForTrip(7)).map(v => v.day_id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', () => {
|
||||
it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', async () => {
|
||||
// The drag stayed between the same two stops, so there is nothing to re-pin. The
|
||||
// sequence is never touched by a move either way: it orders the vias that share one
|
||||
// anchor, and dragging one does not reorder its neighbours.
|
||||
const via = service.create(1, { after_order_index: 1, lat: 53, lng: 10 });
|
||||
const via = (await service.create(1, { after_order_index: 1, lat: 53, lng: 10 }));
|
||||
|
||||
const moved = service.move(via.id, 1, 52.5, 11.5);
|
||||
const moved = (await service.move(via.id, 1, 52.5, 11.5));
|
||||
|
||||
expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', () => {
|
||||
it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', async () => {
|
||||
// The bug this exists for: a drag used to carry only the coordinates, so a via pulled
|
||||
// beyond the stop it used to precede kept claiming the earlier leg. The route then ran
|
||||
// out to the point and back before carrying on, which reads as the drag doing nothing.
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 });
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 }));
|
||||
|
||||
const moved = service.move(via.id, 1, 53.87, 11.53, 1);
|
||||
const moved = (await service.move(via.id, 1, 53.87, 11.53, 1));
|
||||
|
||||
expect(moved).toMatchObject({ lat: 53.87, lng: 11.53, after_order_index: 1 });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', () => {
|
||||
it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', async () => {
|
||||
// The day check has to guard the anchor as well as the coordinates, or an id from
|
||||
// another day could be renumbered through a day the caller does happen to reach.
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
expect(service.move(via.id, 2, 51, 12, 3)).toBeNull();
|
||||
expect(service.listForDay(1)[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 });
|
||||
expect((await service.move(via.id, 2, 51, 12, 3))).toBeNull();
|
||||
expect((await service.listForDay(1))[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 });
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', () => {
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', async () => {
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
expect(service.move(via.id, 2, 0, 0)).toBeNull();
|
||||
expect(service.remove(via.id, 2)).toBe(false);
|
||||
expect((await service.move(via.id, 2, 0, 0))).toBeNull();
|
||||
expect((await service.remove(via.id, 2))).toBe(false);
|
||||
// Still where it was.
|
||||
expect(service.listForDay(1)).toHaveLength(1);
|
||||
expect((await service.listForDay(1))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', () => {
|
||||
const a = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const b = service.create(1, { after_order_index: 1, lat: 54, lng: 11 });
|
||||
const c = service.create(1, { after_order_index: 2, lat: 55, lng: 12 });
|
||||
it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', async () => {
|
||||
const a = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const b = (await service.create(1, { after_order_index: 1, lat: 54, lng: 11 }));
|
||||
const c = (await service.create(1, { after_order_index: 2, lat: 55, lng: 12 }));
|
||||
|
||||
const after = service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] });
|
||||
const after = (await service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] }));
|
||||
|
||||
const byId = new Map(after.map(v => [v.id, v.after_order_index]));
|
||||
expect(byId.get(a.id)).toBe(0);
|
||||
@@ -139,61 +140,61 @@ describe('RoadtripService', () => {
|
||||
expect(after.find(v => v.id === b.id)?.lat).toBe(54);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', () => {
|
||||
const gone = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const kept = service.create(1, { after_order_index: 2, lat: 54, lng: 11 });
|
||||
it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', async () => {
|
||||
const gone = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const kept = (await service.create(1, { after_order_index: 2, lat: 54, lng: 11 }));
|
||||
|
||||
const after = service.reanchor(1, {
|
||||
const after = (await service.reanchor(1, {
|
||||
vias: [{ id: kept.id, after_order_index: 1 }],
|
||||
remove: [gone.id],
|
||||
});
|
||||
}));
|
||||
|
||||
expect(after.map(v => v.id)).toEqual([kept.id]);
|
||||
expect(after[0].after_order_index).toBe(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', () => {
|
||||
it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', async () => {
|
||||
// The batch is trusted to name ids, so `AND day_id = ?` is the only thing standing
|
||||
// between a day the caller can reach and every via of the trip.
|
||||
const other = service.create(2, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const other = (await service.create(2, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] });
|
||||
(await service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] }));
|
||||
|
||||
const untouched = service.listForDay(2);
|
||||
const untouched = (await service.listForDay(2));
|
||||
expect(untouched.map(v => v.id)).toEqual([other.id]);
|
||||
expect(untouched[0].after_order_index).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', () => {
|
||||
it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', async () => {
|
||||
// The caller computed the batch from a snapshot; a via somebody else deleted in the
|
||||
// meantime must not leave the remaining ones mis-pinned.
|
||||
const kept = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const kept = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
const after = service.reanchor(1, {
|
||||
const after = (await service.reanchor(1, {
|
||||
vias: [{ id: 9999, after_order_index: 4 }, { id: kept.id, after_order_index: 1 }],
|
||||
});
|
||||
}));
|
||||
|
||||
expect(after.map(v => v.after_order_index)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', () => {
|
||||
it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', async () => {
|
||||
// The case none of the four above reach: they all re-anchor onto legs that
|
||||
// are empty. `sequence` is allocated per leg and starts at 0 in each, so
|
||||
// merging two populated legs put leg A's 0,1 beside leg B's 0,1 — and
|
||||
// everything that draws the route orders by sequence, so the drive ran A0,
|
||||
// B0, A1, B1: forward, back, and forward again. Persisted, surviving a
|
||||
// reload, and unrepairable except by deleting the vias.
|
||||
const a0 = service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 });
|
||||
const a1 = service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 });
|
||||
const b0 = service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 });
|
||||
const b1 = service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 });
|
||||
const a0 = (await service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 }));
|
||||
const a1 = (await service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 }));
|
||||
const b0 = (await service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 }));
|
||||
const b1 = (await service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 }));
|
||||
expect([a0.sequence, a1.sequence, b0.sequence, b1.sequence]).toEqual([0, 1, 0, 1]);
|
||||
|
||||
// The stop between the two legs is removed, so leg 2 merges into leg 1 —
|
||||
// exactly what reanchorAfterRemove computes on the client.
|
||||
const after = service.reanchor(1, {
|
||||
const after = (await service.reanchor(1, {
|
||||
vias: [{ id: b0.id, after_order_index: 1 }, { id: b1.id, after_order_index: 1 }],
|
||||
});
|
||||
}));
|
||||
|
||||
const onLeg = after.filter(v => v.after_order_index === 1).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(onLeg.map(v => v.id)).toEqual([a0.id, a1.id, b0.id, b1.id]);
|
||||
@@ -203,12 +204,12 @@ describe('RoadtripService', () => {
|
||||
expect(onLeg.map(v => v.lat)).toEqual([50.0, 50.1, 50.2, 50.3]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', () => {
|
||||
const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const second = service.create(1, { after_order_index: 0, lat: 54, lng: 11 });
|
||||
const elsewhere = service.create(1, { after_order_index: 3, lat: 55, lng: 12 });
|
||||
it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', async () => {
|
||||
const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const second = (await service.create(1, { after_order_index: 0, lat: 54, lng: 11 }));
|
||||
const elsewhere = (await service.create(1, { after_order_index: 3, lat: 55, lng: 12 }));
|
||||
|
||||
const after = service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] });
|
||||
const after = (await service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] }));
|
||||
|
||||
const leg0 = after.filter(v => v.after_order_index === 0).sort((x, y) => x.sequence - y.sequence);
|
||||
expect(leg0.map(v => v.id)).toEqual([first.id, second.id]);
|
||||
@@ -217,104 +218,104 @@ describe('RoadtripService', () => {
|
||||
expect(after.find(v => v.id === elsewhere.id)?.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', () => {
|
||||
const vias = service.createMany(1, { vias: [
|
||||
it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', async () => {
|
||||
const vias = (await service.createMany(1, { vias: [
|
||||
{ after_order_index: 0, lat: 53.0, lng: 10.0 },
|
||||
{ after_order_index: 0, lat: 53.1, lng: 10.1 },
|
||||
{ after_order_index: 1, lat: 53.2, lng: 10.2 },
|
||||
] });
|
||||
] }));
|
||||
|
||||
expect(vias.map(v => [v.after_order_index, v.sequence])).toEqual([[0, 0], [0, 1], [1, 0]]);
|
||||
expect(vias.map(v => v.lat)).toEqual([53.0, 53.1, 53.2]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-013: a chain appends to what a leg already has', () => {
|
||||
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const vias = service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] });
|
||||
it('ROADTRIP-SVC-013: a chain appends to what a leg already has', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
const vias = (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] }));
|
||||
|
||||
expect(vias.map(v => v.sequence)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', () => {
|
||||
it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', async () => {
|
||||
// The point of naming legs rather than taking a flag for the day: a hand-placed
|
||||
// detour on another leg is not something adopting a track on this one asked to lose.
|
||||
const keep = service.create(1, { after_order_index: 5, lat: 50, lng: 8 });
|
||||
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
const keep = (await service.create(1, { after_order_index: 5, lat: 50, lng: 8 }));
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
const vias = service.createMany(1, {
|
||||
const vias = (await service.createMany(1, {
|
||||
vias: [{ after_order_index: 0, lat: 54, lng: 11 }],
|
||||
replace_legs: [0],
|
||||
});
|
||||
}));
|
||||
|
||||
expect(vias.map(v => v.id)).toEqual([vias[0].id, keep.id]);
|
||||
expect(vias.find(v => v.after_order_index === 0)?.lat).toBe(54);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', () => {
|
||||
service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', async () => {
|
||||
(await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
expect(service.createMany(1, { vias: [], replace_legs: [0] })).toEqual([]);
|
||||
expect((await service.createMany(1, { vias: [], replace_legs: [0] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', () => {
|
||||
service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
|
||||
it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', async () => {
|
||||
(await service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }));
|
||||
|
||||
expect(service.listForDay(1)).toEqual([]);
|
||||
expect(service.listForDay(2)).toHaveLength(1);
|
||||
expect((await service.listForDay(1))).toEqual([]);
|
||||
expect((await service.listForDay(2))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', () => {
|
||||
it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', async () => {
|
||||
// Same transaction on purpose: a day saying it follows a road whose vias never
|
||||
// arrived is worse than a day saying nothing at all.
|
||||
service.createMany(1, {
|
||||
(await service.createMany(1, {
|
||||
vias: [{ after_order_index: 0, lat: 53, lng: 10 }],
|
||||
track: { place_id: 10, stray_km: 0.8 },
|
||||
});
|
||||
}));
|
||||
|
||||
expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]);
|
||||
expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', () => {
|
||||
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } });
|
||||
service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } });
|
||||
it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } }));
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } }));
|
||||
|
||||
expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]);
|
||||
expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', () => {
|
||||
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } });
|
||||
it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } }));
|
||||
|
||||
// Absent: a hand-dragged via on the same day must not wipe the road it follows.
|
||||
service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] });
|
||||
expect(service.tracksForTrip(7)).toHaveLength(1);
|
||||
(await service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }));
|
||||
expect((await service.tracksForTrip(7))).toHaveLength(1);
|
||||
|
||||
service.createMany(1, { vias: [], replace_legs: [0], track: null });
|
||||
expect(service.tracksForTrip(7)).toEqual([]);
|
||||
(await service.createMany(1, { vias: [], replace_legs: [0], track: null }));
|
||||
expect((await service.tracksForTrip(7))).toEqual([]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', () => {
|
||||
service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } });
|
||||
service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } });
|
||||
it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', async () => {
|
||||
(await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } }));
|
||||
(await service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } }));
|
||||
|
||||
expect(service.tracksForTrip(7).map(t => t.day_id)).toEqual([1]);
|
||||
expect(service.tracksForTrip(99).map(t => t.day_id)).toEqual([3]);
|
||||
expect((await service.tracksForTrip(7)).map(t => t.day_id)).toEqual([1]);
|
||||
expect((await service.tracksForTrip(99)).map(t => t.day_id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', () => {
|
||||
it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', async () => {
|
||||
// Permission is not enough on its own: without this a place id from another trip
|
||||
// would become this day\u2019s label, and an ordinary place would become a label that
|
||||
// can never be drawn.
|
||||
expect(service.trackExists(10, 7)).toBe(true);
|
||||
expect(service.trackExists(11, 7)).toBe(false);
|
||||
expect(service.trackExists(12, 7)).toBe(false);
|
||||
expect(service.trackExists(999, 7)).toBe(false);
|
||||
expect((await service.trackExists(10, 7))).toBe(true);
|
||||
expect((await service.trackExists(11, 7))).toBe(false);
|
||||
expect((await service.trackExists(12, 7))).toBe(false);
|
||||
expect((await service.trackExists(999, 7))).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', () => {
|
||||
const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 });
|
||||
it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', async () => {
|
||||
const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 }));
|
||||
|
||||
expect(service.remove(via.id, 1)).toBe(true);
|
||||
expect(service.remove(via.id, 1)).toBe(false);
|
||||
expect(service.listForDay(1)).toEqual([]);
|
||||
expect((await service.remove(via.id, 1))).toBe(true);
|
||||
expect((await service.remove(via.id, 1))).toBe(false);
|
||||
expect((await service.listForDay(1))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,31 +394,31 @@ describe('TripsController (parity with the legacy /api/trips route)', async () =
|
||||
|
||||
describe('GET /:id/export.ics', () => {
|
||||
function makeRes() { return { setHeader: vi.fn(), send: vi.fn() } as never; }
|
||||
it('404 without access, else sends the calendar with headers', () => {
|
||||
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
it('404 without access, else sends the calendar with headers', async () => {
|
||||
expect(await thrownAsync(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) };
|
||||
tc(svc(), cal).exportIcs(user, '9', res as never);
|
||||
await tc(svc(), cal).exportIcs(user, '9', res as never);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/calendar; charset=utf-8');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"');
|
||||
expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', () => {
|
||||
it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', async () => {
|
||||
const res = { setHeader: vi.fn(), send: vi.fn() };
|
||||
const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: '沖縄.ics' }) };
|
||||
tc(svc(), cal).exportIcs(user, '9', res as never);
|
||||
await tc(svc(), cal).exportIcs(user, '9', res as never);
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84.ics',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a NotFoundError from the export to 404 and re-throws others', () => {
|
||||
it('maps a NotFoundError from the export to 404 and re-throws others', async () => {
|
||||
const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) };
|
||||
expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
expect(await thrownAsync(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
const other = { exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) };
|
||||
expect(() => tc(svc(), other).exportIcs(user, '9', makeRes())).toThrow('boom');
|
||||
await expect(tc(svc(), other).exportIcs(user, '9', makeRes())).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,29 +20,29 @@ const svc = new AirtrailService(
|
||||
new AuditService(new DatabaseService(db)),
|
||||
new AirtrailClient(),
|
||||
);
|
||||
const getConnectionSettings = svc.getConnectionSettings.bind(svc);
|
||||
const isAirtrailWriteEnabled = svc.isAirtrailWriteEnabled.bind(svc);
|
||||
const saveSettings = svc.saveSettings.bind(svc);
|
||||
const getConnectionSettings = (...args: Parameters<AirtrailService['getConnectionSettings']>) => svc.getConnectionSettings(...args);
|
||||
const isAirtrailWriteEnabled = (...args: Parameters<AirtrailService['isAirtrailWriteEnabled']>) => svc.isAirtrailWriteEnabled(...args);
|
||||
const saveSettings = (...args: Parameters<AirtrailService['saveSettings']>) => svc.saveSettings(...args);
|
||||
|
||||
describe('airtrail writeback opt-in persistence (#1240)', () => {
|
||||
it('defaults the writeback opt-in to off for a new user', () => {
|
||||
it('defaults the writeback opt-in to off for a new user', async () => {
|
||||
const { user } = createUser(db);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect(getConnectionSettings(user.id).writeEnabled).toBe(false);
|
||||
expect(await isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect((await getConnectionSettings(user.id)).writeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('persists the opt-in and lets it be toggled back off without dropping the key', async () => {
|
||||
const { user } = createUser(db);
|
||||
|
||||
await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(true);
|
||||
const on = getConnectionSettings(user.id);
|
||||
expect(await isAirtrailWriteEnabled(user.id)).toBe(true);
|
||||
const on = await getConnectionSettings(user.id);
|
||||
expect(on.writeEnabled).toBe(true);
|
||||
expect(on.connected).toBe(true); // key stored
|
||||
|
||||
// No key supplied keeps the stored key; only the opt-in flips back off.
|
||||
await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect(getConnectionSettings(user.id).connected).toBe(true);
|
||||
expect(await isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect((await getConnectionSettings(user.id)).connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user