Terminplaner: Portal-Ansicht, Kachel, Termine
Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet), Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze, Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit Rueckfragen in der Seite, Termin eintragen und Zeit sperren. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70940e02c8
commit
43e4826ae3
10 files changed
+2552
-8
No files matched your search
@@ -0,0 +1,888 @@
|
||||
// Playwright-Checks der Portal-Ansicht Terminplaner (public/termine.js, termine-view.js, termine.css, kachel in funktionen.js) – Aufgabe 21a:
|
||||
// Kachel, Vorlagenauswahl, Reiter-Geruest und Reiter "Termine". Nicht Teil von npm test.
|
||||
// Aufruf: nice node test/ui/termine-portal.ui.mjs (vorher free -m; es laeuft nur ein Browser)
|
||||
// Screenshots: /root/reports/terminplaner-ui/portal-*.png (bei Fehlern fehler-portal-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
|
||||
//
|
||||
// Zwei Arten von Pruefungen auf demselben UI-Testserver (test/ui/termineTestServer.mjs):
|
||||
// - "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten; der Server liefert nur dashboard.html und die Dateien aus public/.
|
||||
// - "echt_*": die echten Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite; nur die uebrigen Portal-Schnittstellen
|
||||
// (/api/auth/me usw.) mockt page.route. Portal-Sitzung per Cookie (srv.portalCookie()).
|
||||
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln).
|
||||
// Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
startTermineUiServer, salonEingabe,
|
||||
R_ANNA, R_BEN, S_HERREN, S_DAMEN, S_ANFRAGE,
|
||||
} from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const SHOTS = '/root/reports/terminplaner-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
|
||||
|
||||
const srv = await startTermineUiServer()
|
||||
const KEY = srv.SITE_KEY
|
||||
const API = `${srv.url}/api/public/termine/v1`
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
|
||||
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
|
||||
async function shot(page, name) {
|
||||
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
|
||||
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
|
||||
const fenster = page.viewportSize()
|
||||
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
|
||||
await page.setViewportSize({ width: fenster.width, height: hoehe })
|
||||
await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
|
||||
await page.setViewportSize(fenster)
|
||||
}
|
||||
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label
|
||||
const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text })
|
||||
const meldung = (page) => page.locator('.tm-meldung:not([hidden])')
|
||||
const pillen = (page) => page.locator('.tm-pille').allInnerTexts()
|
||||
const textVon = (loc) => loc.innerText()
|
||||
/** Kein Streifen rechts: die Seite passt in die Breite. */
|
||||
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
// Berliner Wanduhr im Oktober 2026 (MESZ, bis 25.10.) als ISO-Zeit
|
||||
const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00+02:00`).toISOString()
|
||||
|
||||
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
|
||||
function portalHilfe(p, json) {
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
|
||||
if (p === '/api/chat/unread-count') return json({ unread: 0 })
|
||||
return json({})
|
||||
}
|
||||
|
||||
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
|
||||
function neueFixture() {
|
||||
const fx = {
|
||||
verfuegbar: true,
|
||||
projekte: [{ projectId: 'p1', projectName: 'Salon Müller', liveUrl: 'https://salon-mueller.example', features: { news: { state: 'off' }, termine: { state: 'active' } } }],
|
||||
einstellung: srv.einstellung(),
|
||||
vorlagen: [
|
||||
{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben, Styling.' },
|
||||
{ key: 'kosmetik', name: 'Kosmetik und Wellness', beschreibung: 'Behandlungen und Massagen.' },
|
||||
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
|
||||
],
|
||||
fehlt: [],
|
||||
anfragenOffen: 0,
|
||||
buchungen: [],
|
||||
aufrufe: [],
|
||||
}
|
||||
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
|
||||
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
|
||||
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
/** Buchung in der Form von GET /api/termine/buchungen. */
|
||||
function buchung(id, tag, zeit, dauer, o = {}) {
|
||||
const startsAt = utc(tag, zeit)
|
||||
const b = {
|
||||
id, nummer: id.toUpperCase().padEnd(8, 'X').slice(0, 8), status: 'bestaetigt', quelle: 'online', startsAt, endsAt: new Date(new Date(startsAt).getTime() + dauer * 60000).toISOString(),
|
||||
datum: tag, zeit, leistungId: S_DAMEN, leistung: 'Damenschnitt', dauer, titel: '', person: 'Anna', ressourceId: R_ANNA, gastName: 'Max Mustermann', gastEmail: 'max@example.com',
|
||||
gastTelefon: '06301 555', gastNachricht: '', erinnerung: false, zustellFehler: false, angenommenAt: null, widerrufenAt: null, abgesagtVon: '', absageGrund: '', ...o,
|
||||
}
|
||||
b.titel = b.quelle === 'sperre' ? (o.titel || 'Gesperrt') : b.gastName ? `${b.leistung}: ${b.gastName}` : b.leistung
|
||||
return b
|
||||
}
|
||||
|
||||
/** Alle Status in einer Woche (Mo 2026-10-05 bis So 2026-10-11) plus eine Sperre. */
|
||||
function alleStatus() {
|
||||
return [
|
||||
buchung('bnichter', '2026-10-05', '07:00', 30, { status: 'nichterschienen', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Jonas Weber' }),
|
||||
buchung('berledigt', '2026-10-05', '08:00', 45, { status: 'erledigt', gastName: 'Petra Klein' }),
|
||||
buchung('bbegonnen', '2026-10-05', '09:00', 30, { leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Karl Ohr' }),
|
||||
buchung('bsperre', '2026-10-05', '12:00', 60, { quelle: 'sperre', status: 'bestaetigt', leistung: '', person: 'Ben', gastName: '', gastEmail: '', gastTelefon: '', titel: 'Mittagspause' }),
|
||||
buchung('bkunft', '2026-10-05', '14:00', 45, { zustellFehler: true, gastName: 'Hans Gast' }),
|
||||
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun', gastNachricht: 'Hochzeit am 24.10., bitte mit Probetermin.' }),
|
||||
buchung('bgehalten', '2026-10-05', '17:00', 30, { status: 'gehalten', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Tim Berg' }),
|
||||
buchung('babgesagt', '2026-10-06', '10:00', 30, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krankheit', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Fritz Alt' }),
|
||||
buchung('bwiderruf', '2026-10-06', '11:00', 45, { status: 'abgesagt', abgesagtVon: 'gast', widerrufenAt: utc('2026-10-04', '12:00'), absageGrund: 'Widerruf erklärt von: Eva Rot (eingegangen 04.10.2026 12:00)', gastName: 'Eva Rot' }),
|
||||
buchung('babgelehnt', '2026-10-06', '14:00', 90, { status: 'abgelehnt', leistung: 'Brautfrisur', gastName: 'Uta Neu' }),
|
||||
buchung('bverfallen', '2026-10-06', '15:00', 30, { status: 'verfallen', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Nils Spät' }),
|
||||
buchung('bvermerk', '2026-10-06', '16:00', 45, { absageGrund: 'Widerruf nach Ablauf der Frist erklärt von: Anke Zu', gastName: 'Anke Zu' }),
|
||||
buchung('bmanuell', '2026-10-08', '09:00', 30, { quelle: 'manuell', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Walter Laufkunde', gastEmail: '', gastTelefon: '' }),
|
||||
]
|
||||
}
|
||||
|
||||
/** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */
|
||||
async function fixtureAntwort(fx, route, req, url, json) {
|
||||
const p = url.pathname
|
||||
const q = url.searchParams
|
||||
const m = req.method()
|
||||
if (p === '/api/funktionen') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } })
|
||||
if (p === '/api/termine/einstellungen' && m === 'GET') return json(fx.daten())
|
||||
if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') {
|
||||
fx.aufrufe.push(['vorlage', req.postDataJSON()])
|
||||
fx.einstellung = srv.einstellung()
|
||||
fx.fehlt = ['betrieb.telefon', 'betrieb.adresse']
|
||||
return json({ einstellung: fx.einstellung, version: 1, fehlt: fx.fehlt }, 201)
|
||||
}
|
||||
if (p === '/api/termine/buchungen' && m === 'GET') {
|
||||
fx.aufrufe.push(['liste', Object.fromEntries(q)])
|
||||
const status = q.get('status') ? q.get('status').split(',') : null
|
||||
const liste = fx.buchungen.filter((b) => b.datum >= q.get('von') && b.datum <= q.get('bis') && (!status || status.includes(b.status)))
|
||||
return json({ buchungen: liste.sort((a, b) => (a.startsAt < b.startsAt ? -1 : 1)) })
|
||||
}
|
||||
const aktion = /^\/api\/termine\/buchungen\/([^/]+)\/aktion$/.exec(p)
|
||||
if (aktion && m === 'POST') {
|
||||
const body = req.postDataJSON()
|
||||
fx.aufrufe.push(['aktion', aktion[1], body])
|
||||
const b = fx.buchungen.find((x) => x.id === aktion[1])
|
||||
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
|
||||
if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund })
|
||||
return json({ status: b.status })
|
||||
}
|
||||
return json({})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf der Pruefungen
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
/**
|
||||
* Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle
|
||||
* Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen).
|
||||
*/
|
||||
async function check(name, fn, { context = {}, fixture = null, reset = {} } = {}) {
|
||||
if (process.env.NUR && !name.includes(process.env.NUR)) return
|
||||
await srv.zuruecksetzen(reset)
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
|
||||
await ctx.clock.setFixedTime(JETZT)
|
||||
if (!fixture) await ctx.addCookies([await srv.portalCookie()])
|
||||
const page = await ctx.newPage()
|
||||
page.fx = fixture
|
||||
page.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
page.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const req = route.request()
|
||||
const url = new URL(req.url())
|
||||
const p = url.pathname
|
||||
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
|
||||
return portalHilfe(p, json)
|
||||
})
|
||||
try {
|
||||
await fn(page, ctx)
|
||||
assert(fehler.length === 0, fehler.join(' | '))
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-portal-${name}.png` }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
const oeffne = async (page, hash) => {
|
||||
await page.goto(`${srv.url}/dashboard.html${hash}`)
|
||||
}
|
||||
const warteAnsicht = async (page) => {
|
||||
await page.locator('#view-termine.active').waitFor()
|
||||
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
|
||||
}
|
||||
|
||||
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
|
||||
const JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
|
||||
async function buche(o = {}) {
|
||||
const v = { leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o }
|
||||
srv.limitsZuruecksetzen()
|
||||
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
|
||||
const res = await fetch(`${API}/${KEY}/buchungen`, {
|
||||
method: 'POST',
|
||||
headers: JSON_KOPF,
|
||||
body: JSON.stringify({
|
||||
leistung: v.leistung, ressource: v.ressource, tag: v.tag, zeit: v.zeit, name: v.name, email: v.email, telefon: '', nachricht: '', zusatz: {},
|
||||
erinnerung: false, zustimmungen: { storno: true, vorzeitig: true }, kontakt_url: '', formToken: conf.formToken,
|
||||
}),
|
||||
})
|
||||
eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
|
||||
await srv.warteAufVersand()
|
||||
const mails = srv.mails().filter((m) => m.to.some((t) => t.address === v.email)).reverse()
|
||||
let token = null
|
||||
for (const mail of mails) {
|
||||
const treffer = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(`${mail.text}\n${mail.html}`)
|
||||
if (treffer) { token = treffer[1]; break }
|
||||
}
|
||||
assert(token, `kein Bestaetigungslink fuer ${v.email}`)
|
||||
const best = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token }) })
|
||||
eq(best.status, 200, 'Bestaetigung')
|
||||
await srv.warteAufVersand()
|
||||
return srv.buchungen().find((b) => b.gastEmail === v.email && gleichZeit(b.startsAt, utc(v.tag, v.zeit)))
|
||||
}
|
||||
|
||||
const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00
|
||||
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
|
||||
const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id)
|
||||
|
||||
async function pruefungen() {
|
||||
// ============================================================ Fixture
|
||||
// ------------------------------------------------------------ Kachel in Funktionen
|
||||
await check('fx_kachel_zustaende', async (page) => {
|
||||
// Schalter aus ⇒ „Kommt bald“
|
||||
await oeffne(page, '#funktionen')
|
||||
await page.locator('.fn-tiles').waitFor()
|
||||
const soon = page.locator('.fn-tile-soon', { hasText: 'Terminplaner' })
|
||||
eq(await soon.count(), 1, 'Kommt-bald-Kachel')
|
||||
assert((await textVon(soon)).includes('Kommt bald'), 'Pille Kommt bald')
|
||||
eq(await page.locator('[data-fn-tile="termine"]').count(), 0, 'keine echte Kachel')
|
||||
eq(await page.locator('[data-view="termine"]').count(), 0, 'kein Knopf zur Ansicht')
|
||||
}, { fixture: Object.assign(neueFixture(), { verfuegbar: false }) })
|
||||
|
||||
await check('fx_kachel_aus_aktiv_abgeschaltet', async (page) => {
|
||||
const fx = page.fx
|
||||
// Aus
|
||||
fx.projekte[0].features.termine = { state: 'off' }
|
||||
await oeffne(page, '#funktionen')
|
||||
let kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
let t = await textVon(kachel)
|
||||
assert(t.includes('Aus') && t.includes('Wir schalten den Terminplaner für dich frei.'), `Aus: ${t}`)
|
||||
await knopf(kachel, 'Chat öffnen').waitFor()
|
||||
eq(await kachel.locator('[data-view="termine"]').count(), 0, 'Aus: kein Weg in die Ansicht')
|
||||
// Aktiv mit zwei Anfragen
|
||||
fx.projekte[0].features.termine = { state: 'active' }
|
||||
fx.buchungen = alleStatus().map((b) => (b.id === 'bkunft' ? { ...b, status: 'angefragt' } : b))
|
||||
await page.reload()
|
||||
kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
await kachel.getByText('2 Anfragen warten').waitFor()
|
||||
t = await textVon(kachel)
|
||||
assert(t.includes('Aktiv') && t.includes('Termine öffnen'), `Aktiv: ${t}`)
|
||||
await shot(page, 'kachel-aktiv')
|
||||
// eine Anfrage ⇒ Einzahl
|
||||
fx.buchungen = alleStatus()
|
||||
await page.reload()
|
||||
await page.locator('[data-fn-tile="termine"]').getByText('1 Anfrage wartet').waitFor()
|
||||
// keine Anfrage ⇒ keine Zeile
|
||||
fx.buchungen = []
|
||||
await page.reload()
|
||||
await page.locator('[data-fn-tile="termine"]').waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
eq(await page.locator('[data-fn-tile="termine"] [data-termine-count]:not([hidden])').count(), 0, 'ohne Anfragen keine Zahl')
|
||||
// Abgeschaltet
|
||||
fx.projekte[0].features.termine = { state: 'disabled' }
|
||||
await page.reload()
|
||||
kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
t = await textVon(kachel)
|
||||
assert(t.includes('Abgeschaltet') && t.includes('abgeschaltet') && t.includes('Termine ansehen'), `Abgeschaltet: ${t}`)
|
||||
await shot(page, 'kachel-abgeschaltet')
|
||||
}, { fixture: neueFixture() })
|
||||
|
||||
// ------------------------------------------------------------ Vorlage waehlen
|
||||
await check('fx_vorlage_waehlen', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
// erstes Oeffnen ohne Einstellung: Auswahl statt Reiter
|
||||
eq(await page.locator('.tm-reiterleiste').count(), 0, 'noch keine Reiter')
|
||||
eq(await page.locator('input[name="termine-vorlage"]').count(), 3, 'Vorlagen aus der Antwort')
|
||||
const start = knopf(page, 'Mit dieser Vorlage starten')
|
||||
eq(await start.isDisabled(), true, 'Start erst nach der Wahl')
|
||||
await shot(page, 'vorlage-waehlen')
|
||||
await page.getByLabel('Friseur und Salon').check()
|
||||
eq(await start.isDisabled(), false, 'Start nach der Wahl')
|
||||
await start.click()
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'vorlage'), [['vorlage', { projectId: 'p1', vorlage: 'salon' }]], 'POST der Vorlage')
|
||||
assert((await textVon(meldung(page))).includes('„Friseur und Salon“'), 'Meldung nennt die Vorlage')
|
||||
// Pflichtangaben fehlen ⇒ Hinweis mit Weg zu den Einstellungen
|
||||
const hinweis = await textVon(page.locator('.tm-box-warn'))
|
||||
assert(hinweis.includes('Noch nicht buchbar') && hinweis.includes('deine Telefonnummer und deine Anschrift'), `Hinweis: ${hinweis}`)
|
||||
await knopf(page.locator('.tm-box-warn'), 'Zu den Einstellungen').click()
|
||||
eq(await page.locator('#tm-tab-einstellungen').getAttribute('aria-selected'), 'true', 'Reiter Einstellungen gewaehlt')
|
||||
await shot(page, 'vorlage-angelegt')
|
||||
}, { fixture: Object.assign(neueFixture(), { einstellung: null }) })
|
||||
|
||||
// ------------------------------------------------------------ Projektauswahl
|
||||
await check('fx_projektwahl', async (page) => {
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
assert((await textVon(kachel)).includes('aktiv für 1 von 2 Websites'), 'Kachel zaehlt die Websites')
|
||||
eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p2', 'Kachel zeigt auf die aktive Website')
|
||||
await knopf(kachel, 'Termine öffnen').click()
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
|
||||
// andere Website ohne Terminplaner: Hinweis statt Reiter
|
||||
await page.locator('#termine-project').selectOption('p1')
|
||||
await page.getByText('noch nicht freigeschaltet').waitFor()
|
||||
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
|
||||
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
|
||||
await shot(page, 'projektwahl')
|
||||
await page.locator('#termine-project').selectOption('p2')
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
// Auswahl bleibt beim erneuten Oeffnen
|
||||
await page.evaluate(() => { window.location.hash = '#funktionen' })
|
||||
await page.locator('#view-funktionen.active').waitFor()
|
||||
await page.evaluate(() => { window.location.hash = '#termine' })
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl bleibt')
|
||||
}, {
|
||||
fixture: (() => {
|
||||
const fx = neueFixture()
|
||||
fx.projekte = [
|
||||
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'off' } } },
|
||||
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||||
]
|
||||
return fx
|
||||
})(),
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Reiter-Geruest
|
||||
await check('fx_reiter_geruest', async (page) => {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
const tabs = page.getByRole('tab')
|
||||
eq(await tabs.allInnerTexts(), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'], 'vier Reiter')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Termine zuerst')
|
||||
eq(await page.getByRole('tabpanel').getAttribute('aria-labelledby'), 'tm-tab-termine', 'Panel gehoert zum Reiter')
|
||||
// Tastatur: Pfeil rechts wechselt (Platzhalter), Pos1 zurueck
|
||||
await page.locator('#tm-tab-termine').focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Pfeil rechts')
|
||||
assert((await textVon(page.getByRole('tabpanel'))).includes('Leistungen'), 'Platzhalter-Panel')
|
||||
await page.keyboard.press('End')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Einstellungen', 'Ende')
|
||||
await page.keyboard.press('ArrowRight')
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Pfeil rechts vom letzten Reiter springt zum ersten')
|
||||
// Auswahl bleibt beim Verlassen der Ansicht erhalten
|
||||
await page.getByRole('tab', { name: 'Team & Plätze' }).click()
|
||||
await page.evaluate(() => { window.location.hash = '#funktionen' })
|
||||
await page.locator('#view-funktionen.active').waitFor()
|
||||
await page.evaluate(() => { window.location.hash = '#termine' })
|
||||
await page.locator('#view-termine.active').waitFor()
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Team & Plätze', 'Reiter bleibt')
|
||||
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
|
||||
|
||||
// ------------------------------------------------------------ Liste mit allen Status (Fixture)
|
||||
await check('fx_liste_alle_status', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
// Tag: heute, Montag 5. Oktober, in Zeitfolge
|
||||
eq((await textVon(page.locator('.tm-zeitraum'))), 'Montag, 5. Oktober 2026', 'Titel Tag')
|
||||
await page.locator('li.tm-eintrag').first().waitFor()
|
||||
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['bnichter', 'berledigt', 'bbegonnen', 'bsperre', 'bkunft', 'banfrage', 'bgehalten'], 'Tagesliste in Zeitfolge')
|
||||
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-05' }, 'Abfrage nur fuer heute')
|
||||
// Woche: alle acht Status und die Sperre
|
||||
await page.getByRole('button', { name: 'Woche', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
|
||||
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-11' }, 'Abfrage fuer die Woche')
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), '5.–11. Oktober 2026', 'Titel Woche')
|
||||
const alle = await pillen(page)
|
||||
for (const status of ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen', 'Gesperrt']) assert(alle.includes(status), `Pille ${status} fehlt: ${alle.join(', ')}`)
|
||||
eq(await page.locator('.tm-tag').count(), 7, 'sieben Tage, auch leere')
|
||||
const leer = await textVon(page.locator('.tm-tag[data-tag="2026-10-07"]'))
|
||||
assert(leer.includes('Mittwoch, 7. Oktober') && leer.includes('Keine Termine'), `leerer Tag: ${leer}`)
|
||||
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-05"] .tm-tag-kopf'))).startsWith('Heute · Montag, 5. Oktober'), 'Heute-Kopf')
|
||||
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-06"] .tm-tag-kopf'))).startsWith('Morgen · Dienstag, 6. Oktober'), 'Morgen-Kopf')
|
||||
// Sperre: Block „Gesperrt“ mit Titel, ohne Gastaktionen und ohne Details
|
||||
const sperre = reihe(page, 'Mittagspause')
|
||||
const st = await textVon(sperre)
|
||||
assert(st.includes('Gesperrt') && st.includes('Ben') && st.includes('12:00–13:00'), `Sperre: ${st}`)
|
||||
eq(await sperre.locator('[data-aktion]').allInnerTexts(), ['Sperre aufheben'], 'Sperre: nur aufheben')
|
||||
eq(await sperre.locator('details').count(), 0, 'Sperre ohne Gastdetails')
|
||||
// Zeilen: Uhrzeit mit Ende, Leistung, Person, Gast
|
||||
const kunft = await textVon(reihe(page, 'Hans Gast'))
|
||||
assert(kunft.includes('14:00–14:45') && kunft.includes('Damenschnitt') && kunft.includes('Anna · Hans Gast') && kunft.includes('Bestätigt'), `Zeile: ${kunft}`)
|
||||
assert(kunft.includes('Bestätigung nicht zugestellt'), 'Hinweis Bestätigung nicht zugestellt')
|
||||
eq(await page.locator('li.tm-eintrag:not([data-id="bkunft"]) .tm-warn').count(), 0, 'Hinweis nur an der betroffenen Zeile')
|
||||
// absageGrund nur bei Status abgesagt; Vermerk an einem bestehenden Termin bleibt unsichtbar
|
||||
const abgesagt = await textVon(reihe(page, 'Fritz Alt'))
|
||||
assert(abgesagt.includes('Von dir abgesagt') && abgesagt.includes('Grund: Krankheit'), `Absage: ${abgesagt}`)
|
||||
const widerruf = await textVon(reihe(page, 'Eva Rot'))
|
||||
assert(widerruf.includes('Vom Gast widerrufen'), `Widerruf: ${widerruf}`)
|
||||
const vermerk = await textVon(reihe(page, 'Anke Zu'))
|
||||
assert(!vermerk.includes('Widerruf nach Ablauf') && !vermerk.includes('Grund'), `Vermerk darf nicht erscheinen: ${vermerk}`)
|
||||
assert((await textVon(reihe(page, 'Walter Laufkunde'))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
|
||||
// Knoepfe je Status
|
||||
const aktionen = async (id) => page.locator(`li.tm-eintrag[data-id="${id}"] [data-aktion]`).evaluateAll((l) => l.map((e) => e.dataset.aktion))
|
||||
eq(await aktionen('banfrage'), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'], 'Anfrage')
|
||||
eq(await aktionen('bkunft'), ['verschieben', 'absagen', 'anonymisieren'], 'bestaetigt in der Zukunft')
|
||||
eq(await aktionen('bbegonnen'), ['nichterschienen', 'anonymisieren'], 'bestaetigt, begonnen')
|
||||
eq(await aktionen('bgehalten'), [], 'Reservierung')
|
||||
eq(await aktionen('bverfallen'), ['anonymisieren'], 'verfallen')
|
||||
await shot(page, 'liste-woche-desktop')
|
||||
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
|
||||
|
||||
// ------------------------------------------------------------ Filter, Navigation, Monat
|
||||
await check('fx_filter_navigation', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.getByRole('button', { name: 'Monat', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag[data-id="bmanuell"]').waitFor()
|
||||
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-01', bis: '2026-10-31' }, 'Abfrage Monat')
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Titel Monat')
|
||||
eq(await page.locator('.tm-tag').count(), 3, 'Monat zeigt nur Tage mit Terminen')
|
||||
// Filter ⇒ Statusliste an der Schnittstelle
|
||||
const filter = async (name, status, erwartet) => {
|
||||
await page.getByRole('button', { name, exact: true }).click()
|
||||
await page.waitForFunction((n) => document.querySelector('.tm-chip[aria-pressed="true"]')?.textContent === n, name)
|
||||
await page.waitForTimeout(100)
|
||||
eq(fx.aufrufe.at(-1)[1].status, status, `Filter ${name}`)
|
||||
eq((await pillen(page)).filter((p) => p !== 'Gesperrt').every((p) => erwartet.includes(p)), true, `Pillen unter ${name}: ${(await pillen(page)).join(', ')}`)
|
||||
}
|
||||
await filter('Angefragt', 'angefragt', ['Angefragt'])
|
||||
await filter('Bestätigt', 'bestaetigt', ['Bestätigt'])
|
||||
await filter('Abgesagt', 'abgesagt,abgelehnt,verfallen', ['Abgesagt', 'Abgelehnt', 'Verfallen'])
|
||||
await filter('Offen', 'angefragt,bestaetigt', ['Angefragt', 'Bestätigt'])
|
||||
await page.getByRole('button', { name: 'Alle', exact: true }).click()
|
||||
await page.waitForTimeout(100)
|
||||
assert(fx.aufrufe.at(-1)[1].status === undefined, 'Alle: keine Statusliste')
|
||||
// Navigation: Monat vor, Heute, Datumssprung
|
||||
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
|
||||
await page.locator('.tm-leer').waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'November 2026', 'naechster Monat')
|
||||
eq(await textVon(page.locator('.tm-leer')), 'Keine Termine in diesem Monat.', 'Leermeldung Monat')
|
||||
await page.getByRole('button', { name: 'Heute', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag').first().waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Heute')
|
||||
await page.getByRole('button', { name: 'Tag', exact: true }).click()
|
||||
await page.locator('#tm-sprung').fill('2026-10-06')
|
||||
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Datumssprung')
|
||||
await page.getByRole('button', { name: 'Zurück', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag[data-id="bkunft"]').waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurueck')
|
||||
// Ansicht und Filter bleiben beim Wechsel der Ansicht gesetzt
|
||||
eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Alle gedrueckt')
|
||||
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
|
||||
|
||||
// ------------------------------------------------------------ Anfrage annehmen (Fixture)
|
||||
await check('fx_anfrage_annehmen', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
// Banner „1 Anfrage wartet“ und Weg zu allen Anfragen
|
||||
const banner = page.locator('.tm-box-info')
|
||||
await banner.getByText('1 Anfrage wartet').waitFor()
|
||||
await knopf(banner, 'Anfragen ansehen').click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
|
||||
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-06', status: 'angefragt' }, 'Abfrage: ab heute, 62 Tage, nur Anfragen')
|
||||
eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
|
||||
const zeile = reihe(page, 'Lea Braun')
|
||||
// Details zeigen Kontakt und Nachricht als Text
|
||||
await zeile.locator('summary').click()
|
||||
const details = await textVon(zeile.locator('dl'))
|
||||
assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`)
|
||||
await shot(page, 'anfrage-offen')
|
||||
await knopf(zeile, 'Annehmen').click()
|
||||
await meldung(page).waitFor()
|
||||
assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen')
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen')
|
||||
// Liste neu geladen: keine Anfrage mehr, Banner weg
|
||||
await page.getByText('Es warten keine Anfragen.').waitFor()
|
||||
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
|
||||
await knopf(page, 'Zurück zur Übersicht').click()
|
||||
await page.locator('li.tm-eintrag[data-id="banfrage"][data-status="bestaetigt"]').waitFor()
|
||||
await shot(page, 'anfrage-angenommen')
|
||||
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
|
||||
|
||||
// ------------------------------------------------------------ Rueckfrage in der Seite, Fremdtext nur als Text
|
||||
await check('fx_rueckfrage_und_text', async (page) => {
|
||||
const fx = page.fx
|
||||
fx.buchungen = [
|
||||
buchung('bxss', '2026-10-05', '14:00', 45, { gastName: '<img src=x onerror=window.__xss=1>Max', gastNachricht: '<b>fett</b> & <script>window.__xss=2</script>', person: '<i>Anna</i>', leistung: 'Schnitt <u>1</u>' }),
|
||||
buchung('bziel', '2026-10-05', '16:00', 30, { gastName: 'Zoe Ziel' }),
|
||||
]
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('li.tm-eintrag[data-id="bxss"]').waitFor()
|
||||
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
|
||||
eq(await page.locator('#termine-body img, #termine-body b, #termine-body i, #termine-body u, #termine-body script').count(), 0, 'Fremdtext erzeugt keine Elemente')
|
||||
assert((await textVon(page.locator('li.tm-eintrag[data-id="bxss"]'))).includes('<img src=x onerror=window.__xss=1>Max'), 'Text sichtbar als Text')
|
||||
// Absagen: Rueckfrage in der Seite (kein Browserdialog), Grund ist Pflicht
|
||||
const zeile = page.locator('li.tm-eintrag[data-id="bziel"]')
|
||||
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
|
||||
const frage = zeile.locator('.tm-frage-box')
|
||||
await frage.waitFor()
|
||||
assert((await textVon(frage)).includes('Termin absagen?'), 'Rueckfrage')
|
||||
eq(await page.evaluate(() => document.activeElement.tagName), 'SELECT', 'Fokus im Grund-Feld')
|
||||
await knopf(frage, 'Termin absagen').click()
|
||||
assert((await textVon(frage)).includes('Bitte wähle einen Grund aus.'), 'Grund ist Pflicht')
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'noch nichts gesendet')
|
||||
await frage.getByLabel('Grund').selectOption('Krankheit')
|
||||
await shot(page, 'rueckfrage-absagen')
|
||||
// Abbrechen: Rueckfrage weg, Fokus zurueck am Knopf
|
||||
await knopf(frage, 'Abbrechen').click()
|
||||
eq(await zeile.locator('.tm-frage-box').count(), 0, 'Rueckfrage zu')
|
||||
eq(await page.evaluate(() => document.activeElement.dataset.aktion), 'absagen', 'Fokus zurueck am Knopf')
|
||||
// Nur eine Rueckfrage zugleich
|
||||
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
|
||||
await knopf(page.locator('li.tm-eintrag[data-id="bxss"]'), 'Absagen: Schnitt <u>1</u>, 05.10.2026 um 14:00 Uhr').click()
|
||||
eq(await page.locator('.tm-frage-box').count(), 1, 'eine Rueckfrage')
|
||||
// Absagen mit Grund
|
||||
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Grund').selectOption('Betriebliche Gründe')
|
||||
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Nachricht an den Gast (optional)').fill('Tut uns leid.')
|
||||
await knopf(page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box'), 'Termin absagen').click()
|
||||
await meldung(page).waitFor()
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'bxss', { projectId: 'p1', aktion: 'absagen', grund: 'Betriebliche Gründe', nachricht: 'Tut uns leid.' }]], 'POST absagen')
|
||||
}, { fixture: neueFixture() })
|
||||
|
||||
// ------------------------------------------------------------ App-Ansicht 390 px
|
||||
await check('fx_app_390', async (page) => {
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
await kachel.getByText('1 Anfrage wartet').waitFor()
|
||||
eq(await passtInBreite(page), true, 'Funktionen: kein Streifen rechts')
|
||||
await shot(page, '390-1-funktionen')
|
||||
await knopf(kachel, 'Termine öffnen').click()
|
||||
await warteAnsicht(page)
|
||||
await page.locator('li.tm-eintrag').first().waitFor()
|
||||
// Unterseite von Funktionen: Titel „Terminplaner“, Zurueck-Pfeil „Funktionen“, Tab Funktionen markiert
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse #termine')
|
||||
eq(await textVon(page.locator('#view-termine .kb-head h1')), 'Terminplaner', 'Ueberschrift')
|
||||
eq(await textVon(page.locator('#kb-app-title')), 'Terminplaner', 'Titel der App-Leiste')
|
||||
eq(await page.locator('#kb-back').isVisible(), true, 'Zurueck-Pfeil sichtbar')
|
||||
eq(await textVon(page.locator('#kb-back span')), 'Funktionen', 'Zurueck zu Funktionen')
|
||||
eq(await page.locator('.kb-nav-item.active').getAttribute('data-view'), 'funktionen', 'Tab Funktionen markiert')
|
||||
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'Ansicht termine')
|
||||
eq(await passtInBreite(page), true, 'Termine: kein Streifen rechts')
|
||||
// Reiter als 2×2-Raster, grosse Touch-Ziele
|
||||
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button, #view-termine summary')]
|
||||
.filter((e) => e.offsetParent !== null && !e.classList.contains('tm-link-knopf') && e.getBoundingClientRect().height < 43.5)
|
||||
.map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
|
||||
eq(kleine, [], 'Knoepfe mindestens 44 px hoch')
|
||||
const tabs = await page.locator('.tm-reiter').evaluateAll((l) => l.map((e) => Math.round(e.getBoundingClientRect().top)))
|
||||
eq(tabs[0] === tabs[1] && tabs[2] === tabs[3] && tabs[2] > tabs[0], true, 'Reiter in zwei Zeilen')
|
||||
await shot(page, '390-2-termine')
|
||||
// Woche, Anfrage mit Rueckfrage
|
||||
await page.getByRole('button', { name: 'Woche', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
|
||||
eq(await passtInBreite(page), true, 'Woche: kein Streifen rechts')
|
||||
await shot(page, '390-3-woche')
|
||||
const zeile = page.locator('li.tm-eintrag[data-id="banfrage"]')
|
||||
await knopf(zeile, 'Ablehnen: Brautfrisur, 05.10.2026 um 15:00 Uhr').click()
|
||||
await zeile.locator('.tm-frage-box').waitFor()
|
||||
eq(await passtInBreite(page), true, 'Rueckfrage: kein Streifen rechts')
|
||||
await zeile.scrollIntoViewIfNeeded()
|
||||
await shot(page, '390-4-ablehnen')
|
||||
// Zurueck-Pfeil fuehrt zu Funktionen
|
||||
await page.locator('#kb-back').click()
|
||||
await page.locator('#view-funktionen.active').waitFor()
|
||||
eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen')
|
||||
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
|
||||
// ============================================================ Echter Server
|
||||
await check('echt_kachel_vorlage_und_liste', async (page) => {
|
||||
// aktiv, aber noch ohne Einstellung
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
assert((await textVon(kachel)).includes('Aktiv') && (await textVon(kachel)).includes('Termine öffnen'), 'Kachel aktiv')
|
||||
await knopf(kachel, 'Termine öffnen').click()
|
||||
await warteAnsicht(page)
|
||||
// neun Vorlagen des Servers
|
||||
eq(await page.locator('input[name="termine-vorlage"]').count(), 9, 'neun Vorlagen')
|
||||
await shot(page, 'echt-vorlage')
|
||||
await page.locator('input[name="termine-vorlage"][value="salon"]').check()
|
||||
await knopf(page, 'Mit dieser Vorlage starten').click()
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
const e = srv.einstellung()
|
||||
eq(e.vorlage, 'salon', 'Einstellung aus der Vorlage gespeichert')
|
||||
assert(e.betrieb.name.includes('Silke') || e.betrieb.name !== '', 'Betrieb aus dem Kundenkonto')
|
||||
const hinweis = await textVon(page.locator('.tm-box-warn'))
|
||||
assert(hinweis.includes('Noch nicht buchbar'), `Hinweis fehlende Angaben: ${hinweis}`)
|
||||
await shot(page, 'echt-nach-vorlage')
|
||||
// zweites Oeffnen: keine Auswahl mehr
|
||||
await page.reload()
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(await page.locator('.tm-vorlagen').count(), 0, 'Vorlage nur beim ersten Oeffnen')
|
||||
}, { reset: { ohneEinstellung: true } })
|
||||
|
||||
await check('echt_anfragen_annehmen_ablehnen_absagen_verschieben', async (page) => {
|
||||
// Gaeste buchen ueber die oeffentliche Schnittstelle (echte Daten, echte Mails)
|
||||
const bestaetigt = await buche({ name: 'Max Mustermann', email: 'max@example.com', tag: '2026-10-12', zeit: '10:00' })
|
||||
const zweiter = await buche({ name: 'Maria Muster', email: 'maria@example.com', tag: '2026-10-12', zeit: '11:00', leistung: S_HERREN, ressource: R_BEN })
|
||||
const anfrage1 = await buche({ name: 'Lea Braun', email: 'lea@example.com', tag: '2026-10-13', zeit: '14:00', leistung: S_ANFRAGE })
|
||||
const anfrage2 = await buche({ name: 'Uta Neu', email: 'uta@example.com', tag: '2026-10-14', zeit: '09:00', leistung: S_ANFRAGE })
|
||||
eq([anfrage1.status, anfrage2.status, bestaetigt.status], ['angefragt', 'angefragt', 'bestaetigt'], 'Status nach dem Bestaetigen')
|
||||
// eine Zeile mit gescheiterter Mail
|
||||
srv.aw.seed('termineOutbox', { $id: 'o_fehler01', featureId: srv.FEATURE, bookingId: bestaetigt.$id, art: 'G02', an: 'max@example.com', status: 'fehler', versuche: 3, naechsterVersuch: JETZT.toISOString(), createdAt: JETZT.toISOString() })
|
||||
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
// Banner mit der Zahl; alle Anfragen ab heute
|
||||
await page.locator('.tm-box-info').getByText('2 Anfragen warten').waitFor()
|
||||
await knopf(page, 'Anfragen ansehen').click()
|
||||
await page.locator('li.tm-eintrag[data-status="angefragt"]').first().waitFor()
|
||||
eq(await page.locator('li.tm-eintrag').count(), 2, 'zwei Anfragen')
|
||||
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), [anfrage1.$id, anfrage2.$id], 'Anfragen in Zeitfolge')
|
||||
await shot(page, 'echt-anfragen')
|
||||
|
||||
// annehmen: G02 an die Anfragende
|
||||
const mailsVorher = mailsAn('lea@example.com').length
|
||||
await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click()
|
||||
await meldung(page).waitFor()
|
||||
assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen')
|
||||
await srv.warteAufVersand()
|
||||
eq(buchungMit(anfrage1.$id).status, 'bestaetigt', 'Server: angenommen')
|
||||
assert(buchungMit(anfrage1.$id).angenommenAt, 'angenommenAt gesetzt')
|
||||
eq(mailsAn('lea@example.com').length, mailsVorher + 1, 'G02 an den Gast')
|
||||
|
||||
// ablehnen mit Nachricht: G04
|
||||
const zeile2 = reihe(page, 'Uta Neu')
|
||||
await knopf(zeile2, 'Ablehnen: Brautfrisur, 14.10.2026 um 09:00 Uhr').click()
|
||||
await zeile2.getByLabel('Nachricht an den Gast (optional)').fill('Leider ausgebucht.')
|
||||
await knopf(zeile2, 'Anfrage ablehnen').click()
|
||||
await page.getByText('Es warten keine Anfragen.').waitFor()
|
||||
await srv.warteAufVersand()
|
||||
eq(buchungMit(anfrage2.$id).status, 'abgelehnt', 'Server: abgelehnt')
|
||||
const g04 = mailsAn('uta@example.com').at(-1)
|
||||
assert(`${g04.text}${g04.html}`.includes('Leider ausgebucht.'), 'G04 mit Nachricht des Betriebs')
|
||||
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
|
||||
|
||||
// zurueck zur Uebersicht, Woche des 12. Oktober
|
||||
await knopf(page, 'Zurück zur Übersicht').click()
|
||||
await page.getByRole('button', { name: 'Woche', exact: true }).click()
|
||||
await page.locator('#tm-sprung').fill('2026-10-12')
|
||||
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), '12.–18. Oktober 2026', 'Woche ab 12.10.')
|
||||
const t1 = await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))
|
||||
assert(t1.includes('10:00–10:45') && t1.includes('Damenschnitt') && t1.includes('Anna · Max Mustermann') && t1.includes('Bestätigt') && t1.includes('Bestätigung nicht zugestellt'), `Zeile Server: ${t1}`)
|
||||
await shot(page, 'echt-woche')
|
||||
|
||||
// absagen mit Pflichtgrund: G06, Status, Grund in der Liste
|
||||
const zeile3 = page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`)
|
||||
await knopf(zeile3, 'Absagen: Herrenschnitt, 12.10.2026 um 11:00 Uhr').click()
|
||||
await zeile3.getByLabel('Grund').selectOption('Krankheit')
|
||||
await knopf(zeile3, 'Termin absagen').click()
|
||||
await meldung(page).waitFor()
|
||||
await srv.warteAufVersand()
|
||||
const abgesagt = buchungMit(zweiter.$id)
|
||||
eq([abgesagt.status, abgesagt.abgesagtVon, abgesagt.absageGrund], ['abgesagt', 'betrieb', 'Krankheit'], 'Server: abgesagt')
|
||||
const abTxt = await textVon(page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`))
|
||||
assert(abTxt.includes('Abgesagt') && abTxt.includes('Von dir abgesagt') && abTxt.includes('Grund: Krankheit'), `Zeile abgesagt: ${JSON.stringify(abTxt)}`)
|
||||
assert(mailsAn('maria@example.com').some((m) => /Krankheit/.test(`${m.text}${m.html}`)), 'G06 nennt den Grund')
|
||||
eq((await page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"] [data-aktion]`).allTextContents()), ['Gastdaten löschen'], 'nach der Absage nur noch Gastdaten loeschen')
|
||||
|
||||
// verschieben: neuer Tag, freie Zeit der Schnittstelle
|
||||
const zeile4 = page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`)
|
||||
await knopf(zeile4, 'Verschieben: Damenschnitt, 12.10.2026 um 10:00 Uhr').click()
|
||||
const frage = zeile4.locator('.tm-frage-box')
|
||||
await frage.getByLabel('Neuer Tag').fill('2026-10-14')
|
||||
await frage.locator('.tm-zeit-knopf').first().waitFor()
|
||||
const zeiten = await frage.locator('.tm-zeit-knopf').allInnerTexts()
|
||||
assert(zeiten.includes('09:00') && zeiten.includes('16:45') && !zeiten.includes('12:30'), `freie Zeiten: ${zeiten.join(' ')}`)
|
||||
await shot(page, 'echt-verschieben')
|
||||
await knopf(frage, 'Verschieben').click() // ohne Zeit: Hinweis
|
||||
assert((await textVon(frage)).includes('Bitte wähle eine Uhrzeit aus.'), 'Zeit ist Pflicht')
|
||||
await frage.locator('.tm-zeit-knopf', { hasText: '15:00' }).click()
|
||||
eq(await frage.locator('.tm-zeit-knopf[aria-pressed="true"]').allInnerTexts(), ['15:00'], 'eine Zeit gewaehlt')
|
||||
await knopf(frage, 'Verschieben').click()
|
||||
await meldung(page).waitFor()
|
||||
assert((await textVon(meldung(page))).includes('Termin verschoben auf 14.10.2026, 15:00 Uhr.'), 'Meldung verschieben')
|
||||
await srv.warteAufVersand()
|
||||
assert(gleichZeit(buchungMit(bestaetigt.$id).startsAt, utc('2026-10-14', '15:00')), `Server: neue Zeit ${buchungMit(bestaetigt.$id).startsAt}`)
|
||||
assert(mailsAn('max@example.com').length >= 3, 'G07 an den Gast')
|
||||
// der verschobene Termin steht in der Woche
|
||||
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
|
||||
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))).includes('15:00–15:45'), 'verschobene Zeit in der Liste')
|
||||
})
|
||||
|
||||
await check('echt_termin_eintragen_sperre_nichterschienen', async (page) => {
|
||||
// begonnener Termin (09:00, jetzt ist 10:00) und eine Anfrage direkt im Nachbau
|
||||
const basis = (id, o) => ({
|
||||
$id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
|
||||
startsAt: utc('2026-10-05', '09:00'), endsAt: utc('2026-10-05', '09:30'), blockEndsAt: utc('2026-10-05', '09:35'), status: 'bestaetigt', quelle: 'online',
|
||||
gastName: 'Karl Ohr', gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
|
||||
preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, ...o,
|
||||
})
|
||||
srv.aw.seed('termineBookings', basis('b_begonnen', {}))
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
const begonnen = page.locator('li.tm-eintrag[data-id="b_begonnen"]')
|
||||
await begonnen.waitFor()
|
||||
eq(await begonnen.locator('[data-aktion]').evaluateAll((l) => l.map((e) => e.dataset.aktion)), ['nichterschienen', 'anonymisieren'], 'begonnener Termin')
|
||||
await knopf(begonnen, 'Nicht erschienen: Herrenschnitt, 05.10.2026 um 09:00 Uhr').click()
|
||||
await knopf(begonnen.locator('.tm-frage-box'), 'Ja, nicht erschienen').click()
|
||||
await meldung(page).waitFor()
|
||||
await page.locator('li.tm-eintrag[data-id="b_begonnen"][data-status="nichterschienen"]').waitFor()
|
||||
eq(buchungMit('b_begonnen').status, 'nichterschienen', 'Server: nicht erschienen')
|
||||
// Gastdaten loeschen
|
||||
await begonnen.locator('summary').click()
|
||||
await knopf(begonnen, 'Gastdaten löschen').click()
|
||||
assert((await textVon(begonnen.locator('.tm-frage-box'))).includes('lässt sich nicht rückgängig machen'), 'Warnung')
|
||||
await knopf(begonnen.locator('.tm-frage-box'), 'Gastdaten löschen').click()
|
||||
await page.locator('li.tm-eintrag[data-id="b_begonnen"] .tm-meta', { hasText: 'Gelöscht' }).waitFor()
|
||||
eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht')
|
||||
|
||||
// + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg
|
||||
await knopf(page, '+ Termin eintragen').click()
|
||||
const form = page.getByRole('form', { name: 'Termin eintragen' })
|
||||
await form.waitFor()
|
||||
await shot(page, 'echt-termin-eintragen')
|
||||
await form.getByLabel('Leistung', { exact: true }).selectOption(S_HERREN)
|
||||
await form.getByLabel('Tag', { exact: true }).fill('2026-10-06')
|
||||
await form.locator('.tm-zeit-knopf').first().waitFor()
|
||||
await form.locator('.tm-zeit-knopf', { hasText: '09:30' }).click()
|
||||
await knopf(form, 'Termin eintragen').click() // ohne Namen
|
||||
await form.locator('[data-fehler="name"]:not(:empty)').waitFor()
|
||||
eq(await textVon(form.locator('[data-fehler="name"]')), 'Bitte einen Namen angeben.', 'Feldfehler am Namen')
|
||||
eq(await form.getByLabel('Name des Gastes').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
|
||||
eq(srv.buchungen().filter((b) => b.quelle === 'manuell').length, 0, 'nichts gespeichert')
|
||||
await form.getByLabel('Name des Gastes').fill('Walter Laufkunde')
|
||||
eq(await form.getByLabel('Bestätigung an den Gast senden').isDisabled(), true, 'Haken nur mit E-Mail')
|
||||
await form.getByLabel('Telefon (optional)').fill('06301 999')
|
||||
await knopf(form, 'Termin eintragen').click()
|
||||
await meldung(page).waitFor()
|
||||
eq(await textVon(meldung(page)), 'Termin eingetragen.', 'Meldung eintragen')
|
||||
const manuell = srv.buchungen().find((b) => b.quelle === 'manuell')
|
||||
eq([manuell.gastName, manuell.status, manuell.leistungName], ['Walter Laufkunde', 'bestaetigt', 'Herrenschnitt'], 'Server: Handeintrag')
|
||||
assert(gleichZeit(manuell.startsAt, utc('2026-10-06', '09:30')), `Server: Zeit des Handeintrags ${manuell.startsAt}`)
|
||||
// Ansicht springt auf den Tag des neuen Termins
|
||||
await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor()
|
||||
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags')
|
||||
eq(await page.locator('.tm-formular').count(), 0, 'Formular zu')
|
||||
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
|
||||
|
||||
// Zeit sperren: Hinweis 500 Minuten, zu lang ⇒ Fehler am Feld, dann Erfolg
|
||||
await knopf(page, 'Zeit sperren').click()
|
||||
const sperre = page.getByRole('form', { name: 'Zeit sperren' })
|
||||
await sperre.waitFor()
|
||||
assert((await textVon(sperre)).includes('höchstens 8 Stunden 20 Minuten (500 Minuten)'), 'Hinweis 500 Minuten')
|
||||
await sperre.getByLabel('Person', { exact: true }).selectOption(R_BEN)
|
||||
await sperre.getByLabel('Tag', { exact: true }).fill('2026-10-06')
|
||||
await sperre.getByLabel('Von (Uhrzeit)').fill('8:00')
|
||||
await sperre.getByLabel('Bis (Uhrzeit)').fill('18:00')
|
||||
await knopf(sperre, 'Zeit sperren').click()
|
||||
await sperre.locator('[data-fehler="bis"]:not(:empty)').waitFor()
|
||||
assert((await textVon(sperre.locator('[data-fehler="bis"]'))).includes('höchstens 8 Stunden 20 Minuten'), 'Fehler vom Server am Feld Bis')
|
||||
await sperre.getByLabel('Von (Uhrzeit)').fill('abc')
|
||||
await knopf(sperre, 'Zeit sperren').click()
|
||||
await sperre.locator('[data-fehler="von"]:not(:empty)').waitFor()
|
||||
await sperre.getByLabel('Von (Uhrzeit)').fill('12:00')
|
||||
await sperre.getByLabel('Bis (Uhrzeit)').fill('13:00')
|
||||
await sperre.getByLabel('Titel (optional)').fill('Mittagspause')
|
||||
await knopf(sperre, 'Zeit sperren').click()
|
||||
await meldung(page).waitFor()
|
||||
eq(await textVon(meldung(page)), 'Zeit gesperrt.', 'Meldung sperren')
|
||||
const sperreDoc = srv.buchungen().find((b) => b.quelle === 'sperre')
|
||||
assert(sperreDoc.leistungName === 'Mittagspause' && gleichZeit(sperreDoc.startsAt, utc('2026-10-06', '12:00')) && gleichZeit(sperreDoc.endsAt, utc('2026-10-06', '13:00')), 'Server: Sperre')
|
||||
const sperreZeile = page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`)
|
||||
await sperreZeile.waitFor()
|
||||
assert((await textVon(sperreZeile)).includes('Gesperrt') && (await textVon(sperreZeile)).includes('Mittagspause'), 'Sperre in der Liste')
|
||||
// ueberschneidende Sperre ⇒ belegt
|
||||
await knopf(page, 'Zeit sperren').click()
|
||||
const zweite = page.getByRole('form', { name: 'Zeit sperren' })
|
||||
await zweite.getByLabel('Person', { exact: true }).selectOption(R_BEN)
|
||||
await zweite.getByLabel('Tag', { exact: true }).fill('2026-10-06')
|
||||
await zweite.getByLabel('Von (Uhrzeit)').fill('12:30')
|
||||
await zweite.getByLabel('Bis (Uhrzeit)').fill('14:00')
|
||||
await knopf(zweite, 'Zeit sperren').click()
|
||||
await zweite.locator('[data-fehler="von"]:not(:empty)').waitFor()
|
||||
assert((await textVon(zweite.locator('[data-fehler="von"]'))).includes('schon einen Termin oder eine Sperre'), 'belegte Zeit am Feld Von')
|
||||
await knopf(zweite, 'Abbrechen').click()
|
||||
// Sperre aufheben (Rueckfrage)
|
||||
await knopf(sperreZeile, 'Sperre aufheben: Sperre, 06.10.2026 um 12:00 Uhr').click()
|
||||
await knopf(sperreZeile.locator('.tm-frage-box'), 'Sperre aufheben').click()
|
||||
await page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`).waitFor({ state: 'detached' })
|
||||
eq(srv.buchungen().filter((b) => b.quelle === 'sperre').length, 0, 'Server: Sperre geloescht')
|
||||
await shot(page, 'echt-nach-sperre')
|
||||
})
|
||||
|
||||
await check('echt_abgeschaltet_nur_lesen', async (page) => {
|
||||
srv.aw.seed('termineBookings', {
|
||||
$id: 'b_alt', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
|
||||
startsAt: utc('2026-10-06', '09:00'), endsAt: utc('2026-10-06', '09:30'), blockEndsAt: utc('2026-10-06', '09:35'), status: 'angefragt', quelle: 'online', gastName: 'Karl Ohr',
|
||||
gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
|
||||
preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
|
||||
})
|
||||
await srv.setzeFunktion('disabled')
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
assert((await textVon(kachel)).includes('Abgeschaltet'), 'Kachel abgeschaltet')
|
||||
await knopf(kachel, 'Termine ansehen').click()
|
||||
await warteAnsicht(page)
|
||||
const hinweis = await textVon(page.locator('.tm-box-warn'))
|
||||
assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`)
|
||||
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
|
||||
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben')
|
||||
await page.getByRole('button', { name: 'Tag', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
|
||||
await page.locator('li.tm-eintrag[data-id="b_alt"]').waitFor()
|
||||
eq(await page.locator('li.tm-eintrag [data-aktion]').count(), 0, 'keine Aktionen an den Zeilen')
|
||||
await shot(page, 'echt-abgeschaltet')
|
||||
// die Schnittstelle verweigert Schreiben auch direkt
|
||||
const r = await page.evaluate(async () => (await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })).status)
|
||||
eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion')
|
||||
})
|
||||
|
||||
await check('echt_390_liste_und_formular', async (page) => {
|
||||
srv.aw.seed('termineBookings', {
|
||||
$id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna',
|
||||
startsAt: utc('2026-10-05', '14:00'), endsAt: utc('2026-10-05', '14:45'), blockEndsAt: utc('2026-10-05', '14:50'), status: 'angefragt', quelle: 'online', gastName: 'Katharina von und zu Hohenlohe-Langenburg',
|
||||
gastEmail: 'katharina@example.com', gastTelefon: '+49 6301 123456', gastNachricht: 'Bitte nach Möglichkeit mit der Stylistin vom letzten Mal, Frau Schneider.', gastZusatz: '', erinnerung: true,
|
||||
createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
|
||||
})
|
||||
await oeffne(page, '#funktionen')
|
||||
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
|
||||
await warteAnsicht(page)
|
||||
await page.locator('li.tm-eintrag').waitFor()
|
||||
eq(await passtInBreite(page), true, 'echt: Liste ohne Streifen')
|
||||
await shot(page, '390-5-echt-liste')
|
||||
await knopf(page, '+ Termin eintragen').click()
|
||||
const form = page.getByRole('form', { name: 'Termin eintragen' })
|
||||
await form.locator('.tm-zeit-knopf').first().waitFor()
|
||||
eq(await passtInBreite(page), true, 'echt: Formular ohne Streifen')
|
||||
await shot(page, '390-6-echt-termin-eintragen')
|
||||
await knopf(form, 'Abbrechen').click()
|
||||
await knopf(page, 'Zeit sperren').click()
|
||||
await page.getByRole('form', { name: 'Zeit sperren' }).waitFor()
|
||||
eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen')
|
||||
await shot(page, '390-7-echt-zeit-sperren')
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
let abbruch = null
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
await pruefungen()
|
||||
const fremd = [...hosts].filter((h) => h !== new URL(srv.url).host)
|
||||
results.push(['nur_eigene_herkunft', fremd.length === 0])
|
||||
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
|
||||
} catch (e) {
|
||||
abbruch = e
|
||||
console.log(`ABBRUCH: ${e.stack || e.message}`)
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {})
|
||||
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
|
||||
}
|
||||
|
||||
const fehlgeschlagen = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
|
||||
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
|
||||
@@ -17,6 +17,10 @@
|
||||
// const srv = await startTermineUiServer()
|
||||
// const { sessionCookie } = await import('../support/testApp.js')
|
||||
//
|
||||
// Portal (Aufgaben 21a/21b): srv.portalCookie() liefert { name, value, url } fuer addCookies; srv.zuruecksetzen({ ohneEinstellung: true })
|
||||
// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
|
||||
// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
||||
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
||||
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
||||
@@ -221,8 +225,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
resetKontextForTests()
|
||||
},
|
||||
|
||||
/** Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. */
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true } = {}) {
|
||||
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. Das Portal (dashboard.html) liegt unter srv.url. */
|
||||
async portalCookie({ email = 'silke@salon-mueller.example', name = 'Silke Müller' } = {}) {
|
||||
const { sessionCookie } = await import('../support/testApp.js')
|
||||
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name, email })
|
||||
const i = roh.indexOf('=')
|
||||
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
|
||||
* ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
|
||||
*/
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) {
|
||||
await warteAufVersand()
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
@@ -246,7 +261,7 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
|
||||
aw.seed('portalFeatures', srv.featureDoc())
|
||||
await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
||||
if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
||||
},
|
||||
|
||||
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
|
||||
|
||||
Reference in new issue
Block a user