mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
146fe4a063
The guides of the map, bookings, costs, files, collab, lists, road trip and transports now describe the desktop as it is after the redesign: the bars with their icon buttons and filter popovers, the dialogs with the title in the head band and status and type as pills, the three booking views and the booking detail, the compact note cards, the Costs table. Two guides are new: the booking views (cards, list, timeline, the Trip and Day zoom and the detail) and the Costs table view. Every changed text is translated into every locale, and the Plan tab texts call the bookings tab by the label it shows, Book. Every picture of the thirteen trip screens is taken again against the new layout, on one picture day. Scripts follow the new markup; the Dawarich fixture uploads its recording again when the stored one belongs to another run, and the spare poll gets its vote from a member so its winner shows green. server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so several recordings on their own port pairs can share one dist.
365 lines
19 KiB
TypeScript
365 lines
19 KiB
TypeScript
import type { APIRequestContext } from '@playwright/test'
|
|
import { day, PICTURE_DAY_ISO } from '../dates'
|
|
import { DAWARICH_DEVICE, dawarichRecording, recordingWindow, type TrackFeature } from './dawarich-track'
|
|
|
|
/**
|
|
* The services outside TREK that a few guides need: an AirTrail with flights,
|
|
* a Dawarich with a recording, a Nextcloud with a folder of documents. Each is
|
|
* reached through its own API with the address and key from the environment
|
|
* (`e2e/help/media.env`, see README), and each fixture puts the service into
|
|
* the state the guide expects before TREK is connected to it. Nothing here is
|
|
* silent: a missing variable or an unreachable service fails the run rather
|
|
* than producing a picture of an empty panel.
|
|
*
|
|
* TREK's SSRF guard refuses loopback whatever the flags say, so every address
|
|
* has to be the machine's LAN address, and the media backend needs
|
|
* ALLOW_INTERNAL_NETWORK=true to reach it at all.
|
|
*/
|
|
|
|
function env(name: string): string {
|
|
const value = process.env[name]
|
|
if (!value) throw new Error(`${name} is not set; see client/e2e/help/README.md for the media.env the external guides need`)
|
|
return value.replace(/\/+$/, '')
|
|
}
|
|
|
|
async function expectOk(res: Response, what: string): Promise<Response> {
|
|
if (!res.ok) throw new Error(`${what}: HTTP ${res.status} ${await res.text()}`)
|
|
return res
|
|
}
|
|
|
|
// ── AirTrail ────────────────────────────────────────────────────────────────
|
|
|
|
interface AirtrailWanted {
|
|
flightNumber: string
|
|
from: string
|
|
to: string
|
|
offset: number
|
|
departureTime: string
|
|
arrivalTime: string
|
|
airline: string
|
|
aircraft: string
|
|
seat: 'window' | 'aisle'
|
|
seatNumber: string
|
|
}
|
|
|
|
/**
|
|
* Two flights home on the trip's last day that connect in Tokyo, so the picker
|
|
* offers to import them as one flight with a layover, and the flights of the
|
|
* Lisbon weekend in spring for its "Other flights" list. LH716 out is already
|
|
* in the seed and would only be skipped as a duplicate, so it stays out.
|
|
*/
|
|
export const AIRTRAIL_FLIGHTS: AirtrailWanted[] = [
|
|
{ flightNumber: 'NH16', from: 'ITM', to: 'HND', offset: 0, departureTime: '08:00', arrivalTime: '09:10', airline: 'ANA', aircraft: 'B789', seat: 'window', seatNumber: '12A' },
|
|
{ flightNumber: 'LH717', from: 'HND', to: 'FRA', offset: 0, departureTime: '12:35', arrivalTime: '18:25', airline: 'DLH', aircraft: 'A359', seat: 'aisle', seatNumber: '31C' },
|
|
{ flightNumber: 'TP573', from: 'FRA', to: 'LIS', offset: -158, departureTime: '10:20', arrivalTime: '12:25', airline: 'TAP', aircraft: 'A320', seat: 'window', seatNumber: '7F' },
|
|
{ flightNumber: 'TP574', from: 'LIS', to: 'FRA', offset: -156, departureTime: '13:10', arrivalTime: '17:05', airline: 'TAP', aircraft: 'A320', seat: 'aisle', seatNumber: '9C' },
|
|
]
|
|
|
|
interface AirtrailFlightRow {
|
|
id: number
|
|
flightNumber: string | null
|
|
date: string
|
|
passengers?: { id: number; userId: string | null }[]
|
|
seats?: { id: number; userId: string | null }[]
|
|
}
|
|
|
|
/** Puts the wanted flights into the AirTrail account the key belongs to, once. */
|
|
export async function ensureAirtrailFlights(): Promise<void> {
|
|
const base = env('HELP_MEDIA_AIRTRAIL_URL')
|
|
const headers = { Authorization: `Bearer ${env('HELP_MEDIA_AIRTRAIL_API_KEY')}`, 'Content-Type': 'application/json' }
|
|
const listed = await expectOk(await fetch(`${base}/api/flight/list`, { headers }), 'AirTrail flight list')
|
|
const { flights } = (await listed.json()) as { flights: AirtrailFlightRow[] }
|
|
for (const wanted of AIRTRAIL_FLIGHTS) {
|
|
const date = day(wanted.offset)
|
|
const existing = flights.find(f => f.flightNumber === wanted.flightNumber)
|
|
if (existing && existing.date === date) continue
|
|
const owner = (existing?.passengers ?? existing?.seats ?? []).find(p => p.userId)
|
|
const body = {
|
|
...(existing ? { id: existing.id } : {}),
|
|
from: wanted.from,
|
|
to: wanted.to,
|
|
departure: date,
|
|
departureTime: wanted.departureTime,
|
|
arrival: date,
|
|
arrivalTime: wanted.arrivalTime,
|
|
airline: wanted.airline,
|
|
flightNumber: wanted.flightNumber,
|
|
aircraft: wanted.aircraft,
|
|
passengers: [{ ...(owner ? { id: owner.id } : {}), userId: '<USER_ID>', seat: wanted.seat, seatNumber: wanted.seatNumber, seatClass: 'economy', flightReason: 'leisure' }],
|
|
}
|
|
await expectOk(await fetch(`${base}/api/flight/save`, { method: 'POST', headers, body: JSON.stringify(body) }), `AirTrail save ${wanted.flightNumber}`)
|
|
}
|
|
}
|
|
|
|
/** Switches the addon on and connects the signed-in user to the AirTrail, loudly. */
|
|
export async function ensureAirtrailConnection(api: APIRequestContext): Promise<void> {
|
|
await ensureAirtrailFlights()
|
|
await toggleAddon(api, 'airtrail', true)
|
|
const saved = await api.put('/api/integrations/airtrail/settings', {
|
|
data: { url: env('HELP_MEDIA_AIRTRAIL_URL'), apiKey: env('HELP_MEDIA_AIRTRAIL_API_KEY') },
|
|
})
|
|
if (!saved.ok()) throw new Error(`could not save the AirTrail connection: ${saved.status()} ${await saved.text()}`)
|
|
const status = await api.get('/api/integrations/airtrail/status')
|
|
const body = (await status.json()) as { connected: boolean; error?: string }
|
|
if (!body.connected) throw new Error(`AirTrail is not reachable from the media backend: ${body.error ?? status.status()} (is ALLOW_INTERNAL_NETWORK=true?)`)
|
|
}
|
|
|
|
export async function disconnectAirtrail(api: APIRequestContext): Promise<void> {
|
|
await api.put('/api/integrations/airtrail/settings', { data: { url: '' } })
|
|
await toggleAddon(api, 'airtrail', false)
|
|
}
|
|
|
|
// ── Dawarich ────────────────────────────────────────────────────────────────
|
|
|
|
/** The ids of the fixture's own points between two instants (ISO), every page of them. */
|
|
async function fixturePointIds(base: string, key: string, from: string, to: string): Promise<number[]> {
|
|
const ids: number[] = []
|
|
for (let page = 1; ; page++) {
|
|
const res = await expectOk(
|
|
await fetch(`${base}/api/v1/points?start_at=${from}&end_at=${to}&per_page=1000&page=${page}`, { headers: { Authorization: `Bearer ${key}` } }),
|
|
`Dawarich points ${from}..${to}`,
|
|
)
|
|
const points = (await res.json()) as { id: number; tracker_id?: string | null }[]
|
|
// Only what this fixture uploaded: whatever else the account holds is not ours to touch.
|
|
ids.push(...points.filter(p => p.tracker_id === DAWARICH_DEVICE).map(p => p.id))
|
|
if (points.length === 0 || page >= Number(res.headers.get('x-total-pages') ?? 1)) return ids
|
|
}
|
|
}
|
|
|
|
const shiftIso = (iso: string, days: number) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
|
|
const aroundInstant = (iso: string, seconds: number) => new Date(Date.parse(iso) + seconds * 1000).toISOString()
|
|
|
|
/**
|
|
* Uploads the synthetic recording, one part at a time, unless this run's copy
|
|
* of it is already there.
|
|
*
|
|
* The recording moves with the picture day, so the copy an earlier run left
|
|
* sits a few days off this run's and overlaps its window. Asking whether the
|
|
* window held any point at all found that copy and uploaded nothing, and the
|
|
* trip map then drew the earlier run's last days, in Kyoto, on this run's
|
|
* first days, which are in Tokyo. So the probe asks for this run's first and
|
|
* last point, and a part that lacks either is cleared of the fixture's own
|
|
* points, a month either side of its window, before it goes up again.
|
|
*/
|
|
export async function ensureDawarichRecording(): Promise<void> {
|
|
const base = env('HELP_MEDIA_DAWARICH_URL')
|
|
const key = env('HELP_MEDIA_DAWARICH_API_KEY')
|
|
const recording = dawarichRecording(PICTURE_DAY_ISO)
|
|
for (const part of ['world', 'trip'] as const) {
|
|
const features = recording[part]
|
|
const ends = [features[0], features[features.length - 1]].map(f => f.properties.timestamp)
|
|
let present = true
|
|
for (const at of ends) {
|
|
if ((await fixturePointIds(base, key, aroundInstant(at, -1), aroundInstant(at, 1))).length === 0) present = false
|
|
}
|
|
if (present) continue
|
|
|
|
const { from, to } = recordingWindow(PICTURE_DAY_ISO, part)
|
|
const stale = await fixturePointIds(base, key, shiftIso(from, -30), shiftIso(to, 30))
|
|
for (let i = 0; i < stale.length; i += 1000) {
|
|
await expectOk(
|
|
await fetch(`${base}/api/v1/points/bulk_destroy`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ point_ids: stale.slice(i, i + 1000) }),
|
|
}),
|
|
`Dawarich clear ${part}`,
|
|
)
|
|
}
|
|
for (let i = 0; i < features.length; i += 500) {
|
|
const batch: TrackFeature[] = features.slice(i, i + 500)
|
|
await expectOk(
|
|
await fetch(`${base}/api/v1/points?api_key=${key}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ locations: batch }),
|
|
}),
|
|
`Dawarich upload ${part} batch ${i / 500 + 1}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Two wishes the recording reaches, with coordinates so the scan can match them. */
|
|
export const DAWARICH_WISHES = [
|
|
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, country_code: 'JP' },
|
|
{ name: 'Wadi Rum', lat: 29.576, lng: 35.42, country_code: 'JO' },
|
|
]
|
|
|
|
/**
|
|
* Switches the addon on, connects the signed-in user to the Dawarich and adds
|
|
* the wishes. Automatic syncing stays off: a sync would seed suggestion rows
|
|
* that head the Places column of every later picture.
|
|
*/
|
|
export async function ensureDawarichConnection(api: APIRequestContext): Promise<void> {
|
|
await ensureDawarichRecording()
|
|
await toggleAddon(api, 'dawarich', true)
|
|
const saved = await api.put('/api/integrations/dawarich/settings', {
|
|
data: { url: env('HELP_MEDIA_DAWARICH_URL'), apiKey: env('HELP_MEDIA_DAWARICH_API_KEY'), allowInsecureTls: false, syncEnabled: false },
|
|
})
|
|
if (!saved.ok()) throw new Error(`could not save the Dawarich connection: ${saved.status()} ${await saved.text()}`)
|
|
// The probe wants the address in its body; the key it takes from the connection just saved.
|
|
const test = await api.post('/api/integrations/dawarich/test', { data: { url: env('HELP_MEDIA_DAWARICH_URL'), allowInsecureTls: false } })
|
|
const body = (await test.json()) as { connected: boolean; error?: string; errorDetail?: string }
|
|
if (!body.connected) throw new Error(`Dawarich is not reachable from the media backend: ${body.error ?? ''} ${body.errorDetail ?? ''} (is ALLOW_INTERNAL_NETWORK=true?)`)
|
|
|
|
const listRes = await api.get('/api/addons/atlas/bucket-list')
|
|
const { items } = (await listRes.json()) as { items: { name: string }[] }
|
|
for (const wish of DAWARICH_WISHES) {
|
|
if (items.some(i => i.name === wish.name)) continue
|
|
const created = await api.post('/api/addons/atlas/bucket-list', { data: wish })
|
|
if (!created.ok()) throw new Error(`could not add the ${wish.name} wish: ${created.status()} ${await created.text()}`)
|
|
}
|
|
}
|
|
|
|
export async function disconnectDawarich(api: APIRequestContext): Promise<void> {
|
|
await api.delete('/api/integrations/dawarich/settings')
|
|
await toggleAddon(api, 'dawarich', false)
|
|
}
|
|
|
|
// ── Nextcloud ───────────────────────────────────────────────────────────────
|
|
|
|
export const NEXTCLOUD_BASE_PATH = '/Reisen'
|
|
export const NEXTCLOUD_FOLDER = 'Autumn in Japan'
|
|
export const NEXTCLOUD_DOCUMENTS = ['ryokan-invoice.pdf', 'shinkansen-eticket.pdf']
|
|
|
|
/** What `drawPdf` in fixtures.ts takes; repeated here so this file needs nothing from it. */
|
|
export interface StoreDocument {
|
|
issuer: string
|
|
title: string
|
|
reference: string
|
|
fields: readonly (readonly [string, string])[]
|
|
footer: string
|
|
}
|
|
|
|
function davRoot(): { url: string; auth: string } {
|
|
const url = `${env('HELP_MEDIA_NEXTCLOUD_URL')}/remote.php/dav/files/${env('HELP_MEDIA_NEXTCLOUD_USER')}`
|
|
const auth = 'Basic ' + Buffer.from(`${env('HELP_MEDIA_NEXTCLOUD_USER')}:${env('HELP_MEDIA_NEXTCLOUD_PASSWORD')}`).toString('base64')
|
|
return { url, auth }
|
|
}
|
|
|
|
async function dav(method: string, path: string, body?: BodyInit, extra: Record<string, string> = {}): Promise<Response> {
|
|
const { url, auth } = davRoot()
|
|
const target = url + path.split('/').map(encodeURIComponent).join('/')
|
|
return fetch(target, { method, headers: { Authorization: auth, ...extra }, body })
|
|
}
|
|
|
|
/**
|
|
* The store side of the document-sync guide: a base folder with one trip
|
|
* folder holding two documents and nothing else, however the last run left it.
|
|
* `pdf` draws the documents, so this file stays free of binary fixtures.
|
|
*/
|
|
export async function ensureNextcloudFolder(pdf: (doc: StoreDocument) => Buffer): Promise<void> {
|
|
const folder = `${NEXTCLOUD_BASE_PATH}/${NEXTCLOUD_FOLDER}`
|
|
for (const dir of [NEXTCLOUD_BASE_PATH, folder]) {
|
|
const made = await dav('MKCOL', `${dir}/`)
|
|
if (![201, 405].includes(made.status)) throw new Error(`Nextcloud MKCOL ${dir}: HTTP ${made.status} ${await made.text()}`)
|
|
}
|
|
const listing = await dav('PROPFIND', `${folder}/`, undefined, { Depth: '1' })
|
|
if (listing.status !== 207) throw new Error(`Nextcloud PROPFIND ${folder}: HTTP ${listing.status}`)
|
|
const names = [...(await listing.text()).matchAll(/<d:href>([^<]+)<\/d:href>/g)]
|
|
.map(m => decodeURIComponent(m[1]).replace(/\/$/, '').split('/').pop() ?? '')
|
|
.filter(n => n && n !== NEXTCLOUD_FOLDER)
|
|
for (const name of names) {
|
|
if (NEXTCLOUD_DOCUMENTS.includes(name)) continue
|
|
const gone = await dav('DELETE', `${folder}/${name}`)
|
|
if (![204, 404].includes(gone.status)) throw new Error(`Nextcloud DELETE ${name}: HTTP ${gone.status}`)
|
|
}
|
|
const documents: Record<string, StoreDocument> = {
|
|
'ryokan-invoice.pdf': {
|
|
issuer: 'Hakone Ginyu',
|
|
title: 'Invoice',
|
|
reference: 'Invoice 2026-0918, confirmation RY-4471',
|
|
fields: [
|
|
['Guests', '2 adults, one room'],
|
|
['Nights', 'Two nights, half board'],
|
|
['Room and board', 'JPY 84,000'],
|
|
['Onsen tax', 'JPY 12,000'],
|
|
['Total, paid by card', 'JPY 96,000'],
|
|
],
|
|
footer: 'Paid in full. Thank you for staying with us.',
|
|
},
|
|
'shinkansen-eticket.pdf': {
|
|
issuer: 'JR Central',
|
|
title: 'Reserved seat ticket',
|
|
reference: 'Nozomi 21, Tokyo to Kyoto',
|
|
fields: [
|
|
['Date', 'Day 6 of the trip'],
|
|
['Departure / arrival', 'Tokyo 08:30, Kyoto 10:45'],
|
|
['Car and seats', 'Car 7, seats 12A and 12B'],
|
|
['Class', 'Ordinary, reserved, non-smoking'],
|
|
],
|
|
footer: 'Keep the ticket until you leave the station; the gate reads it twice.',
|
|
},
|
|
}
|
|
for (const name of NEXTCLOUD_DOCUMENTS) {
|
|
if (names.includes(name)) continue
|
|
const bytes = pdf(documents[name])
|
|
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
const put = await dav('PUT', `${folder}/${name}`, body, { 'Content-Type': 'application/pdf' })
|
|
if (![201, 204].includes(put.status)) throw new Error(`Nextcloud PUT ${name}: HTTP ${put.status} ${await put.text()}`)
|
|
}
|
|
}
|
|
|
|
export const NEXTCLOUD_CONNECTION = () => ({
|
|
providerId: 'nextcloud',
|
|
baseUrl: env('HELP_MEDIA_NEXTCLOUD_URL'),
|
|
credentials: { login_name: env('HELP_MEDIA_NEXTCLOUD_USER'), app_password: env('HELP_MEDIA_NEXTCLOUD_PASSWORD'), base_path: NEXTCLOUD_BASE_PATH },
|
|
allowInsecureTls: false,
|
|
})
|
|
|
|
/** Takes every binding and connection off the trip, keeping the documents. */
|
|
export async function resetDocSync(api: APIRequestContext, tripId: number): Promise<void> {
|
|
const links = await api.get(`/api/trips/${tripId}/docsync/links`)
|
|
if (links.ok()) {
|
|
const body = (await links.json()) as { links?: { id: number }[] } | { id: number }[]
|
|
for (const link of Array.isArray(body) ? body : (body.links ?? [])) await api.delete(`/api/trips/${tripId}/docsync/links/${link.id}`)
|
|
}
|
|
const connections = await api.get(`/api/trips/${tripId}/docsync/connections`)
|
|
if (connections.ok()) {
|
|
const body = (await connections.json()) as { connections?: { id: number }[] } | { id: number }[]
|
|
for (const c of Array.isArray(body) ? body : (body.connections ?? [])) await api.delete(`/api/trips/${tripId}/docsync/connections/${c.id}`)
|
|
}
|
|
}
|
|
|
|
// ── Booking extractor ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Import from file is rendered only when the server can read a confirmation.
|
|
* The media run gets that from the extractor named in media.env
|
|
* (KITINERARY_EXTRACTOR_PATH, see README). The AI Parsing addon would show
|
|
* the button too, but with no model behind it every parse ends in a red card,
|
|
* so the guides ask for the extractor and nothing else.
|
|
*/
|
|
export async function requireExtractor(api: APIRequestContext): Promise<void> {
|
|
const res = await api.get('/api/health/features')
|
|
const body = (await res.json()) as { bookingImport?: boolean }
|
|
if (!body.bookingImport) {
|
|
throw new Error('the booking extractor is not available to the media backend: set KITINERARY_EXTRACTOR_PATH in e2e/help/media.env (see README)')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The review attaches the confirmation to the booking it made through the
|
|
* ordinary file upload, and that upload honours the administrator's Allowed
|
|
* File Types, which do not include a mail out of the box: the booking is
|
|
* saved and the .eml dropped without a word. So the list gets `eml` for the
|
|
* import guides and loses it again in their cleanup. Idempotent either way.
|
|
*/
|
|
export async function allowEmlUploads(api: APIRequestContext, allowed: boolean): Promise<void> {
|
|
const current = await api.get('/api/auth/app-settings')
|
|
const { allowed_file_types = '' } = (await current.json()) as { allowed_file_types?: string }
|
|
const types = allowed_file_types.split(',').map(t => t.trim().toLowerCase()).filter(Boolean)
|
|
if (types.includes('eml') === allowed) return
|
|
const next = allowed ? [...types, 'eml'] : types.filter(t => t !== 'eml')
|
|
const res = await api.put('/api/auth/app-settings', { data: { allowed_file_types: next.join(',') } })
|
|
if (!res.ok()) throw new Error(`could not ${allowed ? 'allow' : 'disallow'} .eml uploads: ${res.status()} ${await res.text()}`)
|
|
}
|
|
|
|
// ── shared ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function toggleAddon(api: APIRequestContext, id: string, enabled: boolean): Promise<void> {
|
|
const res = await api.put(`/api/admin/addons/${id}`, { data: { enabled } })
|
|
if (!res.ok()) throw new Error(`could not switch addon ${id} ${enabled ? 'on' : 'off'}: ${res.status()} ${await res.text()}`)
|
|
}
|