Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten
Team-Route für zeiten: Aktivieren läuft über den Gratis-Pfad (einschaltenZeiten, setup, unlockedBy team, Neubau, Einbau-Ticket, keine K24), Abschalten setzt disabled mit Grund team ohne Löschfrist und legt über nachAbschaltung bei geändertem Wochenplan das Ticket "Ersatz-Tabelle aktualisieren" an. PATCH /:customerId/zeiten/team speichert die Team-Einstellungen mit teamVersion. Die Team-Ansicht liefert für zeiten zusätzlich plaene, team, teamVersion und hindernis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11dc4b2c9f
commit
97e6971276
3 files changed
+780
-13
No files matched your search
@@ -12,8 +12,12 @@ import {
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
} from '../../services/siteFeatures.js'
|
||||
import { featureHaken } from '../../services/featureRegistry.js'
|
||||
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
|
||||
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
|
||||
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
|
||||
import { ladeZeiten } from '../../services/zeiten/speicher.js'
|
||||
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
|
||||
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
|
||||
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
|
||||
|
||||
/**
|
||||
@@ -49,6 +53,29 @@ async function ticketExists(ticketId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusatzfelder der Zeiten fuer das WOMS-Panel (Spec 8): plaene [{ kurz, name, haupt }] (Hauptplan zuerst, sonst in gespeicherter
|
||||
* Reihenfolge; Kurznamen fuer data-plan und "Ersatz-HTML kopieren"), team und teamVersion (Team-Einstellungen mit ihrer Version fuer
|
||||
* PATCH .../zeiten/team) und hindernis: 'zeiten_fehlen', solange die Einrichtung (setup) auf Zeiten des Kunden wartet, sonst null.
|
||||
* Gelesen wird nur, wo es Daten geben kann (setup, active, disabled); sonst Vorgaben. hindernis fragt den Register-Haken freischaltbar
|
||||
* (dieselbe Regel wie der 5-Minuten-Job, fail-closed). Wirft bei Lesefehlern, wie der Rest der Ansicht.
|
||||
*/
|
||||
async function zeitenZusatz(doc) {
|
||||
const mitDaten = doc && (doc.state === 'setup' || doc.state === 'active' || doc.state === 'disabled')
|
||||
const gespeichert = mitDaten ? await ladeZeiten(doc.$id) : null
|
||||
const plaene = (Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : [])
|
||||
.filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '')
|
||||
.map((p) => ({ kurz: p.kurz, name: typeof p.name === 'string' ? p.name : '', haupt: p.haupt === true }))
|
||||
const { freischaltbar } = featureHaken('zeiten')
|
||||
const wartet = doc?.state === 'setup' && typeof freischaltbar === 'function' && (await freischaltbar(doc)) !== true
|
||||
return {
|
||||
plaene: [...plaene.filter((p) => p.haupt), ...plaene.filter((p) => !p.haupt)],
|
||||
team: gespeichert?.team ?? normalisiereTeam({}).team,
|
||||
teamVersion: gespeichert?.teamVersion ?? 0,
|
||||
hindernis: wartet ? 'zeiten_fehlen' : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
|
||||
async function adminView(customerId) {
|
||||
const docs = await listCustomerFeatureDocs(customerId)
|
||||
@@ -59,6 +86,7 @@ async function adminView(customerId) {
|
||||
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
|
||||
view.adminNote = doc?.adminNote || ''
|
||||
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
|
||||
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
|
||||
}
|
||||
}
|
||||
return projects
|
||||
@@ -101,7 +129,40 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
|
||||
/**
|
||||
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
|
||||
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
|
||||
* entsteht wie bei den anderen Funktionen (Zeit, Name, Aktion, Text).
|
||||
* Sperren: einschaltenZeiten nimmt fuer die Zustandsentscheidung selbst `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout)
|
||||
* und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` nicht
|
||||
* gehalten: sie wuerde dem Team (und dem Abo-Abgleich) fuer die Dauer von Neubau und Ticket jede Aktion an diesem Projekt sperren,
|
||||
* ohne etwas zu schuetzen, denn holeZeitenNach liest den Zustand frisch und laesst ein inzwischen abgeschaltetes Dokument unberuehrt.
|
||||
*/
|
||||
async function zeitenAktivieren({ customerId, projectId, by, note }) {
|
||||
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
const adminNote = composeAdminNote(bisher?.adminNote, { by, action: 'activate', note })
|
||||
await einschaltenZeiten({ customerId, projectId, by: 'team', staff: { name: by, adminNote } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnungszeiten live abschalten: disabled mit Grund team, ohne Loeschfrist (loeschungAm bleibt null, die Daten bleiben).
|
||||
* Zustand und Notiz unter `feature-admin:<projekt>`; das Ersatz-Ticket (nachAbschaltung, Netzwerk, wirft nie) danach ausserhalb der
|
||||
* Sperre. Es laeuft bei jedem Aufruf, auch bei einem Doppelklick: ein schon angelegtes Ticket merkt sich der Ersatz-Hash.
|
||||
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>}
|
||||
*/
|
||||
async function zeitenAbschalten({ customerId, projectId, by, note }) {
|
||||
const doc = await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!bisher) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}`), { status: 409 })
|
||||
}
|
||||
const adminNote = composeAdminNote(bisher.adminNote, { by, action: 'disable', note })
|
||||
return disable(bisher.$id, { by, adminNote, reason: 'team' })
|
||||
})
|
||||
return nachAbschaltung(doc)
|
||||
}
|
||||
|
||||
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404
|
||||
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
try {
|
||||
const { customerId, key } = req.params
|
||||
@@ -116,20 +177,71 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
} else {
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
|
||||
let ersatzTicket
|
||||
if (key === 'zeiten') {
|
||||
if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note })
|
||||
else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
|
||||
} else {
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
} else {
|
||||
if (!doc) {
|
||||
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
|
||||
}
|
||||
await disable(doc.$id, { by, adminNote })
|
||||
}
|
||||
await disable(doc.$id, { by, adminNote })
|
||||
}
|
||||
})
|
||||
}
|
||||
res.json({
|
||||
projects: (await adminView(customerId)).filter((p) => p.projectId === projectId),
|
||||
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
|
||||
...(ersatzTicket ? { ersatzTicket } : {}),
|
||||
})
|
||||
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
/** Antwort der Team-Einstellungen: Pruefergebnis (400 mit fehler je Feld) und Versionskonflikt (409 mit code) wie die Kundenroute. */
|
||||
function sendEinstellungenError(res, err) {
|
||||
if (err?.status === 400 && err.fehler && typeof err.fehler === 'object') {
|
||||
return res.status(400).json({ error: err.message, fehler: err.fehler })
|
||||
}
|
||||
if (err?.status === 409 && err.code === 'ZEITEN_VERALTET') {
|
||||
return res.status(409).json({ error: err.message, code: err.code })
|
||||
}
|
||||
return sendError(res, err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-Einstellungen von Oeffnungszeiten live (Spec 5 und 8): { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }
|
||||
* -> { team, teamVersion }. PATCH, weil das Ticketsystem nur GET/POST/PATCH/DELETE durchlaesst. Die Version ist die des Teams
|
||||
* (teamVersion aus der Ansicht); eine andere oder fehlende ergibt 409 ZEITEN_VERALTET, ungueltige Werte 400 mit fehler.
|
||||
* Die Sperre `zeiten:<projekt>` nimmt speichereTeam selbst; der Zustand wird nicht veraendert, darum keine feature-admin-Sperre.
|
||||
* Nicht fuer eine ausgeschaltete Funktion (off, z. B. nach der Loeschung der Daten): 409, damit kein verwaistes Dokument entsteht.
|
||||
*/
|
||||
funktionenAdminRouter.patch('/:customerId/zeiten/team', async (req, res) => {
|
||||
try {
|
||||
const { customerId } = req.params
|
||||
const { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus } = req.body || {}
|
||||
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'zeiten')
|
||||
if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` })
|
||||
if (doc.state === 'off') {
|
||||
return res.status(409).json({ error: `${FEATURE_LABELS.zeiten.name} ist für dieses Projekt nicht eingeschaltet.` })
|
||||
}
|
||||
const gespeichert = await speichereTeam({
|
||||
feature: doc,
|
||||
eingabe: { navSelektor, kontaktleisteAus, hinweisleisteAus },
|
||||
teamVersion,
|
||||
by: `team:${req.staff?.appwriteUserId ?? 'unbekannt'}`,
|
||||
})
|
||||
res.json(gespeichert)
|
||||
} catch (err) {
|
||||
sendEinstellungenError(res, err)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { config } from '../../config.js'
|
||||
import { berlinDateTime } from '../berlinTime.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { createFeatureTicket } from '../ticketAdmin.js'
|
||||
import { ersatzHtml, wochenplanHash } from './ersatz.js'
|
||||
import { ladeZeiten, setzeErsatzHash } from './speicher.js'
|
||||
import { hauptplan } from './zeitlogik.js'
|
||||
|
||||
/**
|
||||
* Nach dem Abschalten von Oeffnungszeiten live (Spec 4.5): steht in den Platzhaltern der Kundenseite noch eine Ersatz-Tabelle, die
|
||||
* nicht mehr zum aktuellen Wochenplan passt, bekommt das Team ein WOMS-Ticket "Ersatz-Tabelle aktualisieren - <Projekt>" mit dem
|
||||
* aktuellen Ersatz-HTML (gehoert zum enthaltenen Einbau). Ohne Skript zeigt die Seite ab jetzt nur noch diese Tabelle.
|
||||
*
|
||||
* Aufrufer: die Team-Route (disable) und spaeter der Abo-Abgleich (Abo-Ende). Aufruf NACH dem Speichern des Zustands und ausserhalb
|
||||
* jeder Sperre `feature-admin:<projekt>`: das Ticket legt eine Netzwerkanfrage an, und Sperren sind nicht wiederbetretbar.
|
||||
*/
|
||||
|
||||
const THEMA = 'Ersatz-Tabelle aktualisieren'
|
||||
|
||||
/** Text des Tickets: Kopfzeilen wie bei den Einbau-Tickets, Anlass, Aufgabe und das aktuelle Ersatz-HTML des Hauptplans. */
|
||||
function ticketDetails({ doc, project, daten, haupt, now }) {
|
||||
const projektName = project?.projectName || project?.subdomain || ''
|
||||
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
|
||||
return [
|
||||
'Funktion: zeiten',
|
||||
`Projekt: ${projektName}`,
|
||||
`Domain: ${domain}`,
|
||||
`siteKey: ${doc.siteKey ?? ''}`,
|
||||
'',
|
||||
`Öffnungszeiten live wurde am ${berlinDateTime(now).date} abgeschaltet. Der Wochenplan weicht von der Tabelle ab, die beim Einbau auf die Seite gesetzt wurde (oder der damalige Stand ist nicht bekannt).`,
|
||||
'Aufgabe: Auf der Kundenseite in jedem Platzhalter <div data-webklar-zeiten ...> die Tabelle durch die aktuelle ersetzen. Ohne Skript zeigt die Seite nur noch diese Tabelle.',
|
||||
'Weitere Pläne: WOMS → Funktionen → Ersatz-HTML kopieren.',
|
||||
'',
|
||||
haupt.kurz ? `Ersatz-HTML (Hauptplan ${haupt.kurz}):` : 'Ersatz-HTML (Hauptplan):',
|
||||
ersatzHtml(daten, haupt),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Das Projekt des Dokuments; ein geloeschtes Projekt (404) ist kein Fehler, der Name fehlt dann im Ticket. */
|
||||
async function ladeProjekt(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefen, ob die Ersatz-Tabelle der Seite noch zum Wochenplan passt, und sonst das Ticket anlegen.
|
||||
* Ruling R23: ein leerer oder fehlender gemerkter Hash (ersatzHash) gilt als "unbekannt", also wie geaendert, sobald Zeiten da sind
|
||||
* (schlimmstenfalls ein unnoetiges Ticket). Nach dem Ticket wird der Hash auf den aktuellen Stand gesetzt: er gehoert dann zur Tabelle,
|
||||
* die das Team einsetzt, und ein zweiter Aufruf (Doppelklick, spaeterer Lauf) legt kein zweites Ticket an. Scheitert das Ticket,
|
||||
* bleibt der Hash, und der naechste Aufruf versucht es erneut.
|
||||
* @param {{ $id: string, customerId?: string, projectId?: string, siteKey?: string }} doc portalFeatures-Dokument der Zeiten
|
||||
* (aus disable(): der Stand nach dem Abschalten)
|
||||
* @param {{ now?: Date }} [options]
|
||||
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>} angelegt: Ticket entstanden; aktuell: nichts zu tun (keine Zeiten oder Hash gleich);
|
||||
* fehler: nicht pruefbar oder Ticket nicht angelegt (im Log ohne Kundendaten). Wirft nie.
|
||||
*/
|
||||
export async function nachAbschaltung(doc, { now = new Date() } = {}) {
|
||||
try {
|
||||
if (typeof doc?.$id !== 'string' || doc.$id === '') throw new TypeError('Dokument ohne $id')
|
||||
const gespeichert = await ladeZeiten(doc.$id)
|
||||
const daten = gespeichert?.daten ?? null
|
||||
const haupt = hauptplan(daten)
|
||||
// ohne Zeiten wurde nie eine Tabelle zum Ersetzen vorbereitet
|
||||
if (!haupt) return 'aktuell'
|
||||
const hash = wochenplanHash(daten)
|
||||
if (gespeichert.ersatzHash !== '' && gespeichert.ersatzHash === hash) return 'aktuell'
|
||||
|
||||
const project = await ladeProjekt(doc.projectId)
|
||||
const projektName = project?.projectName || project?.subdomain || ''
|
||||
await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: 'zeiten',
|
||||
siteKey: doc.siteKey,
|
||||
topic: projektName ? `${THEMA} – ${projektName}` : THEMA,
|
||||
details: ticketDetails({ doc, project, daten, haupt, now }),
|
||||
})
|
||||
|
||||
// Das Ticket steht; scheitert nur das Merken, entsteht beim naechsten Abschalten ein zweites (Log, kein Fehler)
|
||||
try {
|
||||
await setzeErsatzHash(doc, hash)
|
||||
} catch (err) {
|
||||
console.error(`[zeiten] Ersatz-Hash für Projekt ${doc.projectId} nach dem Ticket nicht gemerkt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
return 'angelegt'
|
||||
} catch (err) {
|
||||
// nie Kundendaten ins Log: Art und Statuscode, keine Meldung (Appwrite-Meldungen koennen Feldinhalte nennen)
|
||||
console.error(`[zeiten] Ersatz-Ticket nach dem Abschalten (Projekt ${doc?.projectId ?? '?'}): ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.replace(/\s+/g, ' ').trim())
|
||||
return 'fehler'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// Team-Aktionen fuer Oeffnungszeiten live (Aufgabe 19, Spec 4.5, 5 und 8): POST /api/team/funktionen/:customerId/zeiten
|
||||
// (activate ueber den Gratis-Pfad einschaltenZeiten, disable + nachAbschaltung), PATCH /:customerId/zeiten/team und die
|
||||
// erweiterte Ansicht (plaene, team, teamVersion, hindernis) hinter requireStaffJwt.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
|
||||
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
|
||||
},
|
||||
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
|
||||
datetimes: {
|
||||
...ZEITEN_DATETIMES,
|
||||
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
|
||||
},
|
||||
defaultLimit: 25,
|
||||
})
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { startTestApp, send, post } = await import('./support/testApp.js')
|
||||
const { seedFixtures } = await import('./support/fixtures.js')
|
||||
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
|
||||
const { speichereZeiten, speichereTeam, setzeErsatzHash, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||
const { ersatzHtml, wochenplanHash } = await import('../server/services/zeiten/ersatz.js')
|
||||
const { hauptplan } = await import('../server/services/zeiten/zeitlogik.js')
|
||||
const { nachAbschaltung } = await import('../server/services/zeiten/abschalten.js')
|
||||
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
|
||||
const app = await startTestApp({ '/api/team': teamRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close()]))
|
||||
after(() => setMailTransportForTests(null))
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
|
||||
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const P1 = 'p_aaaaaaa1'
|
||||
const P2 = 'p_aaaaaaa2'
|
||||
|
||||
const projekt = (id, over = {}) => ({ $id: id, customerId: 'c1', projectName: id === 'p1' ? 'Salon Haarlinie' : `Projekt ${id}`, subdomain: `sub-${id}`, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
|
||||
const featureDoc = (projectId, over = {}) => ({
|
||||
$id: `f_${projectId}_zeiten`,
|
||||
customerId: 'c1',
|
||||
projectId,
|
||||
featureKey: 'zeiten',
|
||||
state: 'setup',
|
||||
enabled: false,
|
||||
unlockedBy: 'team',
|
||||
unlockedByPurchase: false,
|
||||
siteKey: SITE_KEY,
|
||||
...over,
|
||||
})
|
||||
const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`)
|
||||
const tickets = () => [...aw.docs('workorders').values()]
|
||||
|
||||
function tage(woche = {}) {
|
||||
const t = {}
|
||||
for (let d = 1; d <= 7; d += 1) {
|
||||
const w = woche[d]
|
||||
t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] }
|
||||
}
|
||||
return t
|
||||
}
|
||||
const planDaten = (id, name, extra = {}) => ({ id, name, haupt: false, tage: tage({ 1: ['09:00', '18:00'] }), ...extra })
|
||||
const eingabe = (plaene) => ({ land: 'RP', plaene, feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
|
||||
const EIN_PLAN = () => eingabe([planDaten(P1, 'Standard', { haupt: true })])
|
||||
const ZWEI_PLAENE = () => eingabe([planDaten(P1, 'Standard', { haupt: true }), planDaten(P2, 'Weihnachten', { tage: tage({ 2: ['10:00', '14:00'] }) })])
|
||||
/** Wie EIN_PLAN, aber mit anderen Oeffnungszeiten des Hauptplans (neue Version 1 noetig, wenn EIN_PLAN schon gespeichert ist). */
|
||||
const GEAENDERT = () => eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage({ 1: ['08:00', '12:00'], 5: ['08:00', '12:00'] }) })])
|
||||
|
||||
/** Speichert Zeiten wie der Kunde (die Normalisierung vergibt Kurznamen). */
|
||||
const mitZeiten = (doc, daten, version = 0) => speichereZeiten({ feature: doc, eingabe: daten, version, now: JETZT })
|
||||
|
||||
const aktion = (action, extra = {}, { key = 'zeiten', projectId = 'p1', opts = STAFF } = {}) =>
|
||||
post(`/api/team/funktionen/c1/${key}`, { projectId, action, ...extra }, opts)
|
||||
const abschalten = (extra, opts) => aktion('disable', extra, opts)
|
||||
const einstellungen = (body, opts = STAFF) => send('PATCH', '/api/team/funktionen/c1/zeiten/team', { ...opts, body })
|
||||
const ansicht = async () => (await send('GET', '/api/team/funktionen/c1', STAFF)).body.projects
|
||||
const zeitenSicht = async (projectId = 'p1') => (await ansicht()).find((p) => p.projectId === projectId).features.zeiten
|
||||
|
||||
let sink
|
||||
let rebuilds
|
||||
let logs
|
||||
const originalRebuild = zeitenDeps.rebuild
|
||||
beforeEach(async () => {
|
||||
for (const col of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'workorders', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||||
}
|
||||
resetZeitenCacheForTests()
|
||||
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
|
||||
aw.seed('websiteProjects', projekt('p1'))
|
||||
aw.seed('websiteProjects', projekt('p2'))
|
||||
aw.seed('websiteProjects', projekt('p3', { customerId: 'c2' }))
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
rebuilds = []
|
||||
zeitenDeps.rebuild = async (p) => {
|
||||
rebuilds.push({ projektId: p.$id, state: zeitenDoc(p.$id)?.state })
|
||||
return { queued: true }
|
||||
}
|
||||
logs = []
|
||||
const merke = (...args) => logs.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '))
|
||||
mock.method(console, 'error', merke)
|
||||
mock.method(console, 'warn', merke)
|
||||
mock.method(console, 'log', () => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
mock.restoreAll()
|
||||
zeitenDeps.rebuild = originalRebuild
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Aktivieren
|
||||
|
||||
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', async () => {
|
||||
const res = await aktion('activate', { note: 'Pilot' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1'])
|
||||
const { zeiten, news, termine } = res.body.projects[0].features
|
||||
assert.equal(zeiten.state, 'setup', 'nicht direkt active (das Einbau-Ticket gibt frei)')
|
||||
assert.equal(zeiten.unlockedBy, 'team')
|
||||
assert.equal(zeiten.einbau, 'angestossen')
|
||||
assert.equal(typeof zeiten.ticketWoid, 'string')
|
||||
assert.equal(zeiten.ticketMissing, false)
|
||||
assert.match(zeiten.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
|
||||
assert.equal(news.state, 'off')
|
||||
assert.equal(termine.state, 'off')
|
||||
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'team', false])
|
||||
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.ok(doc.ticketId)
|
||||
assert.ok(!doc.k24At, 'keine K24 an den Kunden')
|
||||
assert.equal(aw.docs('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner')
|
||||
assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team')
|
||||
|
||||
// genau ein Neubau (nach dem Speichern des Zustands) und genau ein Einbau-Ticket
|
||||
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }])
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(tickets()[0].type, 'Webpage')
|
||||
assert.equal(tickets()[0].customerId, 'c1')
|
||||
assert.match(tickets()[0].details, /data-wk-zeiten/)
|
||||
assert.equal(sink.sent.length, 0, 'keine Mail')
|
||||
})
|
||||
|
||||
test('Team aktiviert zeiten zweimal: idempotent, kein zweiter Neubau, kein zweites Ticket, keine zweite Notiz', async () => {
|
||||
const erste = await aktion('activate', { note: 'Pilot' })
|
||||
const notiz = erste.body.projects[0].features.zeiten.adminNote
|
||||
const zweite = await aktion('activate', { note: 'nochmal' })
|
||||
assert.equal(zweite.status, 200, JSON.stringify(zweite.body))
|
||||
assert.equal(zweite.body.projects[0].features.zeiten.state, 'setup')
|
||||
assert.equal(zweite.body.projects[0].features.zeiten.adminNote, notiz)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Team aktiviert eine abgeschaltete Funktion wieder: setup, Abschaltgrund geräumt, vorhandenes Ticket bleibt', async () => {
|
||||
aw.seed('workorders', { $id: 't_alt', woid: '10001', topic: 'alt', status: 'Closed', customerId: 'c1' })
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'disabled', disabledAt: '2026-09-01T10:00:00.000Z', disabledReason: 'team', ticketId: 't_alt', einbau: 'angestossen', adminNote: 'alte Notiz' }))
|
||||
const res = await aktion('activate')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = zeitenDoc()
|
||||
assert.deepEqual([doc.state, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.unlockedBy], ['setup', null, null, 'team'])
|
||||
assert.equal(doc.ticketId, 't_alt', 'kein zweites Ticket')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }], 'der Neubau setzt einbau neu')
|
||||
assert.match(doc.adminNote, / Andrej: activate \| alte Notiz$/)
|
||||
})
|
||||
|
||||
test('Aktivieren: fremdes oder unbekanntes Projekt 403, unbekannte Aktion 400, ohne Anmeldung 401, nichts entsteht', async () => {
|
||||
assert.equal((await aktion('activate', {}, { projectId: 'p3' })).status, 403)
|
||||
assert.equal((await aktion('activate', {}, { projectId: 'gibtsnicht' })).status, 403)
|
||||
assert.equal((await aktion('activate', {}, { opts: {} })).status, 401)
|
||||
assert.equal((await aktion('einschalten')).status, 400)
|
||||
assert.equal((await post('/api/team/funktionen/c1/zeiten', { action: 'activate' }, STAFF)).status, 400)
|
||||
assert.equal(aw.docs('portalFeatures').size, 0)
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
})
|
||||
|
||||
test('Aktivieren: Ticket nicht anlegbar ⇒ 503, Zustand setup gespeichert, ein zweiter Klick holt nach', async () => {
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
|
||||
const kaputt = await aktion('activate')
|
||||
assert.equal(kaputt.status, 503, JSON.stringify(kaputt.body))
|
||||
assert.equal(zeitenDoc().state, 'setup')
|
||||
assert.ok(!zeitenDoc().ticketId)
|
||||
assert.equal(tickets().length, 0)
|
||||
|
||||
const heil = await aktion('activate')
|
||||
assert.equal(heil.status, 200, JSON.stringify(heil.body))
|
||||
assert.ok(zeitenDoc().ticketId)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(rebuilds.length, 1, 'der Neubau war schon angestossen')
|
||||
})
|
||||
|
||||
test('Aktivieren: Neubau und Ticket laufen ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
|
||||
let frei = null
|
||||
zeitenDeps.rebuild = async () => {
|
||||
const start = Date.now()
|
||||
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
|
||||
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
|
||||
frei = Date.now() - start < 250
|
||||
return { queued: true }
|
||||
}
|
||||
assert.equal((await aktion('activate')).status, 200)
|
||||
assert.equal(frei, true)
|
||||
})
|
||||
|
||||
test('News und Terminplaner laufen weiter über den alten Pfad (active, kein Ticket)', async () => {
|
||||
const res = await aktion('activate', { note: 'Kulanz' }, { key: 'news' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.projects[0].features.news.state, 'active')
|
||||
assert.equal(res.body.projects[0].features.zeiten.state, 'off')
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.deepEqual(rebuilds, [])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Einstellungen
|
||||
|
||||
test('Team-Einstellungen mit Version: gespeichert, Kundendaten unberührt, zweiter Versuch mit alter Version 409', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
const gespeichert = await mitZeiten(doc, EIN_PLAN())
|
||||
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }
|
||||
|
||||
const res = await einstellungen(body)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body, { team: { navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 1 })
|
||||
|
||||
const nachher = await ladeZeiten(doc.$id)
|
||||
assert.deepEqual(nachher.team, res.body.team)
|
||||
assert.equal(nachher.teamVersion, 1)
|
||||
assert.deepEqual([nachher.version, nachher.daten], [gespeichert.version, gespeichert.daten], 'Daten und Version des Kunden bleiben')
|
||||
assert.equal(aw.docs('oeffnungszeiten').get(doc.$id).updatedBy, 'team:u_staff2')
|
||||
|
||||
// mit der alten Version: 409, nichts geaendert
|
||||
const veraltet = await einstellungen({ ...body, navSelektor: 'nav', kontaktleisteAus: false })
|
||||
assert.equal(veraltet.status, 409)
|
||||
assert.equal(veraltet.body.code, 'ZEITEN_VERALTET')
|
||||
assert.equal(typeof veraltet.body.error, 'string')
|
||||
assert.deepEqual((await ladeZeiten(doc.$id)).team, res.body.team)
|
||||
// ohne Version gilt dasselbe (nie blind schreiben)
|
||||
const { teamVersion: _weg, ...ohneVersion } = body
|
||||
assert.equal((await einstellungen(ohneVersion)).status, 409)
|
||||
|
||||
// mit der neuen Version geht es weiter; leerer Selektor loescht ihn
|
||||
const weiter = await einstellungen({ ...body, teamVersion: 1, navSelektor: '' })
|
||||
assert.equal(weiter.status, 200)
|
||||
assert.deepEqual(weiter.body, { team: { navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 2 })
|
||||
})
|
||||
|
||||
test('Team-Einstellungen: ungültiger Selektor ⇒ 400 mit Feldfehler, nichts gespeichert', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
for (const navSelektor of ['<script>alert(1)</script>', 'a'.repeat(121), 'div{color:red}', 42]) {
|
||||
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor, kontaktleisteAus: false, hinweisleisteAus: false })
|
||||
assert.equal(res.status, 400, String(navSelektor))
|
||||
assert.equal(typeof res.body.error, 'string')
|
||||
assert.equal(typeof res.body.fehler?.navSelektor, 'string', String(navSelektor))
|
||||
}
|
||||
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header', kontaktleisteAus: 'ja', hinweisleisteAus: false })
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(typeof res.body.fehler?.kontaktleisteAus, 'string')
|
||||
assert.equal(await ladeZeiten(doc.$id), null, 'kein Dokument angelegt')
|
||||
// ein gueltiger Aufruf nach den Fehlern hat noch Version 0
|
||||
assert.equal((await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header' })).status, 200)
|
||||
})
|
||||
|
||||
test('Team-Einstellungen: Anmeldung, Projekt, Funktion und Zustand werden geprüft', async () => {
|
||||
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header' }
|
||||
assert.equal((await einstellungen(body, {})).status, 401)
|
||||
assert.equal((await einstellungen({ ...body, projectId: undefined })).status, 400)
|
||||
assert.equal((await einstellungen({ ...body, projectId: 'p3' })).status, 403)
|
||||
assert.equal((await einstellungen({ ...body, projectId: 'gibtsnicht' })).status, 403)
|
||||
// eigenes Projekt ohne Funktionsdokument
|
||||
const keine = await einstellungen(body)
|
||||
assert.equal(keine.status, 404)
|
||||
assert.match(keine.body.error, /Öffnungszeiten live/)
|
||||
// Funktion aus (zum Beispiel nach der Datenloeschung): keine verwaisten Einstellungen
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'off' }))
|
||||
const aus = await einstellungen(body)
|
||||
assert.equal(aus.status, 409)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 0)
|
||||
// abgeschaltet: Einstellungen duerfen noch bearbeitet werden (die Daten sind da)
|
||||
await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled', disabledReason: 'team', disabledAt: JETZT.toISOString() })
|
||||
assert.equal((await einstellungen(body)).status, 200)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Abschalten
|
||||
|
||||
test('Abschalten durch Team: disabled, Grund team, keine Löschfrist, Daten bleiben, keine Mail', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true, activatedAt: JETZT.toISOString() })
|
||||
aw.seed('portalFeatures', doc)
|
||||
await mitZeiten(doc, EIN_PLAN())
|
||||
const vorher = await ladeZeiten(doc.$id)
|
||||
await setzeErsatzHash(doc, wochenplanHash(vorher.daten))
|
||||
|
||||
const res = await abschalten({ note: 'Kunde wünscht' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const sicht = res.body.projects[0].features.zeiten
|
||||
assert.equal(sicht.state, 'disabled')
|
||||
assert.equal(sicht.disabledReason, 'team')
|
||||
assert.ok(sicht.disabledAt)
|
||||
assert.equal(sicht.loeschungAm, null, 'bei Grund team gibt es keine Loeschfrist')
|
||||
assert.match(sicht.adminNote, /Andrej: disable – Kunde wünscht$/)
|
||||
|
||||
const nach = zeitenDoc()
|
||||
assert.deepEqual([nach.state, nach.enabled, nach.disabledReason], ['disabled', false, 'team'])
|
||||
assert.deepEqual((await ladeZeiten(doc.$id)).daten, vorher.daten, 'die Zeiten bleiben')
|
||||
assert.equal(sink.sent.length, 0)
|
||||
assert.equal(res.body.ersatzTicket, 'aktuell')
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('Abschalten ohne Funktionsdokument: 409; zweites Abschalten verschiebt disabledAt nicht', async () => {
|
||||
const keine = await abschalten()
|
||||
assert.equal(keine.status, 409)
|
||||
assert.match(keine.body.error, /Öffnungszeiten live/)
|
||||
assert.equal(aw.docs('portalFeatures').size, 0)
|
||||
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
assert.equal((await abschalten()).status, 200)
|
||||
const erstes = zeitenDoc().disabledAt
|
||||
assert.ok(erstes)
|
||||
assert.equal((await abschalten()).status, 200)
|
||||
assert.equal(zeitenDoc().disabledAt, erstes)
|
||||
assert.equal(zeitenDoc().disabledReason, 'team')
|
||||
})
|
||||
|
||||
test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket mit aktuellem Ersatz-HTML', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
const erst = await mitZeiten(doc, EIN_PLAN())
|
||||
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
|
||||
// der Kunde aendert nach dem Einbau den Wochenplan
|
||||
const jetzt = await mitZeiten(doc, GEAENDERT(), erst.version)
|
||||
assert.notEqual(wochenplanHash(jetzt.daten), wochenplanHash(erst.daten))
|
||||
|
||||
const res = await abschalten({ note: 'Abo gekündigt' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.ersatzTicket, 'angelegt')
|
||||
assert.equal(res.body.projects[0].features.zeiten.state, 'disabled')
|
||||
|
||||
assert.equal(tickets().length, 1)
|
||||
const [ticket] = tickets()
|
||||
assert.equal(ticket.topic, 'Ersatz-Tabelle aktualisieren – Salon Haarlinie')
|
||||
assert.equal(ticket.type, 'Webpage')
|
||||
assert.equal(ticket.status, 'Open')
|
||||
assert.equal(ticket.customerId, 'c1')
|
||||
assert.ok(ticket.details.includes(ersatzHtml(jetzt.daten, hauptplan(jetzt.daten))), 'aktuelles Ersatz-HTML im Ticket')
|
||||
assert.ok(!ticket.details.includes(ersatzHtml(erst.daten, hauptplan(erst.daten))), 'nicht das alte')
|
||||
assert.match(ticket.details, /Salon Haarlinie/)
|
||||
assert.match(ticket.details, new RegExp(`siteKey: ${SITE_KEY}`))
|
||||
assert.match(ticket.details, /data-webklar-zeiten/)
|
||||
assert.doesNotMatch(ticket.details, /24:00|24 Uhr/)
|
||||
|
||||
// ein zweiter Klick auf Abschalten legt kein zweites Ticket an (der Hash ist jetzt der des Tickets)
|
||||
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, wochenplanHash(jetzt.daten))
|
||||
const nochmal = await abschalten()
|
||||
assert.equal(nochmal.status, 200)
|
||||
assert.equal(nochmal.body.ersatzTicket, 'aktuell')
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abschalten: das Ersatz-Ticket entsteht ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
const erst = await mitZeiten(doc, EIN_PLAN())
|
||||
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
|
||||
await mitZeiten(doc, GEAENDERT(), erst.version)
|
||||
|
||||
const echt = globalThis.fetch
|
||||
let frei = null
|
||||
mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (init?.method === 'POST' && String(url).includes('/collections/workorders/documents') && frei === null) {
|
||||
const start = Date.now()
|
||||
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
|
||||
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
|
||||
frei = Date.now() - start < 250
|
||||
}
|
||||
return echt(url, init)
|
||||
})
|
||||
const res = await abschalten()
|
||||
assert.equal(res.body.ersatzTicket, 'angelegt', JSON.stringify(res.body))
|
||||
assert.equal(frei, true)
|
||||
})
|
||||
|
||||
test('Abschalten ohne Änderung des Wochenplans ⇒ kein Ersatz-Ticket', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
const erst = await mitZeiten(doc, EIN_PLAN())
|
||||
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
|
||||
// Name und Ausnahmen aendern den Hash nicht (nur tage des Hauptplans)
|
||||
const mitAusnahme = EIN_PLAN()
|
||||
mitAusnahme.plaene[0].name = 'Hauptzeiten'
|
||||
mitAusnahme.ausnahmen = [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-12-21', bis: '2026-12-27', text: 'Weihnachtsurlaub', modus: 'zu', fenster: [], plan: null }]
|
||||
await mitZeiten(doc, mitAusnahme, erst.version)
|
||||
|
||||
const res = await abschalten()
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.ersatzTicket, 'aktuell')
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('Abschalten ohne gemerkten Ersatz-Hash, aber mit Zeiten ⇒ Ersatz-Ticket (R23: unbekannt gilt als geändert)', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
await mitZeiten(doc, EIN_PLAN())
|
||||
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, '')
|
||||
const res = await abschalten()
|
||||
assert.equal(res.body.ersatzTicket, 'angelegt')
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abschalten: Ticket nicht anlegbar ⇒ trotzdem disabled, ersatzTicket fehler, Hash bleibt, nächster Klick versucht es erneut', async () => {
|
||||
const doc = featureDoc('p1', { state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', doc)
|
||||
const erst = await mitZeiten(doc, EIN_PLAN())
|
||||
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
|
||||
await mitZeiten(doc, GEAENDERT(), erst.version)
|
||||
const alterHash = wochenplanHash(erst.daten)
|
||||
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
|
||||
const kaputt = await abschalten()
|
||||
assert.equal(kaputt.status, 200, JSON.stringify(kaputt.body))
|
||||
assert.equal(kaputt.body.ersatzTicket, 'fehler')
|
||||
assert.equal(kaputt.body.projects[0].features.zeiten.state, 'disabled')
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, alterHash, 'Hash unveraendert: der naechste Versuch legt das Ticket an')
|
||||
assert.ok(logs.some((zeile) => zeile.includes('Ersatz')), 'steht im Log')
|
||||
assert.ok(logs.every((zeile) => !/Salon Haarlinie|Standard|Kunde Eins|k@example/.test(zeile)), 'keine Kundendaten im Log')
|
||||
|
||||
const heil = await abschalten()
|
||||
assert.equal(heil.body.ersatzTicket, 'angelegt')
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ nachAbschaltung
|
||||
|
||||
test('nachAbschaltung: keine Zeiten (kein Dokument, nur Team, nur Nebenplan) ⇒ aktuell, kein Ticket', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'ohne oeffnungszeiten-Dokument')
|
||||
await speichereTeam({ feature: doc, eingabe: { navSelektor: 'header' }, teamVersion: 0, by: 'team:u_staff2' })
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'nur Team-Einstellungen')
|
||||
const neben = featureDoc('p2')
|
||||
aw.seed('portalFeatures', neben)
|
||||
aw.seed('oeffnungszeiten', {
|
||||
$id: neben.$id, customerId: 'c1', projectId: 'p2', featureId: neben.$id, version: 1, teamVersion: 0,
|
||||
daten: JSON.stringify({ land: 'RP', plaene: [{ id: P2, kurz: 'nebenan', name: 'Nebenplan', haupt: false, tage: tage({ 1: ['09:00', '18:00'] }) }] }),
|
||||
})
|
||||
assert.equal(await nachAbschaltung(neben, { now: JETZT }), 'aktuell', 'nur ein Nebenplan')
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('nachAbschaltung: wirft nie (Lesefehler, kaputte Eingabe) und liefert fehler', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
await mitZeiten(doc, EIN_PLAN())
|
||||
aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Lesen kaputt' })
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'fehler')
|
||||
assert.equal(await nachAbschaltung(null, { now: JETZT }), 'fehler')
|
||||
assert.equal(await nachAbschaltung({}, { now: JETZT }), 'fehler')
|
||||
assert.equal(await nachAbschaltung(undefined), 'fehler')
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.ok(logs.every((zeile) => !/Standard|Salon Haarlinie/.test(zeile)))
|
||||
})
|
||||
|
||||
test('nachAbschaltung: Hash gleich ⇒ aktuell, Hash fehlt oder weicht ab ⇒ angelegt (einmal je Wochenplan-Stand)', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
const erst = await mitZeiten(doc, EIN_PLAN())
|
||||
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell')
|
||||
await setzeErsatzHash(doc, 'ffffffffffffffff')
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'der Hash ist jetzt der des Tickets')
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
test('nachAbschaltung: Projekt nicht mehr da ⇒ Ticket ohne Projektnamen, kein Fehler', async () => {
|
||||
const doc = featureDoc('p1')
|
||||
aw.seed('portalFeatures', doc)
|
||||
await mitZeiten(doc, EIN_PLAN())
|
||||
await deleteDocument('websiteProjects', 'p1')
|
||||
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
|
||||
assert.equal(tickets()[0].topic, 'Ersatz-Tabelle aktualisieren')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Ansicht
|
||||
|
||||
test('adminView zeigt Pläne, team und hindernis (nur für zeiten)', async () => {
|
||||
// p1: eingerichtet mit zwei Plaenen und Team-Einstellungen, p2: setup ohne Zeiten
|
||||
const eins = featureDoc('p1')
|
||||
aw.seed('portalFeatures', eins)
|
||||
await mitZeiten(eins, ZWEI_PLAENE())
|
||||
await speichereTeam({ feature: eins, eingabe: { navSelektor: 'header', kontaktleisteAus: true }, teamVersion: 0, by: 'team:u_staff2' })
|
||||
aw.seed('portalFeatures', featureDoc('p2', { siteKey: 'bbbbbbbbbbbbbbbb' }))
|
||||
|
||||
const projekte = await ansicht()
|
||||
const z1 = projekte.find((p) => p.projectId === 'p1').features.zeiten
|
||||
assert.deepEqual(z1.plaene, [
|
||||
{ kurz: 'standard', name: 'Standard', haupt: true },
|
||||
{ kurz: 'weihnachten', name: 'Weihnachten', haupt: false },
|
||||
])
|
||||
assert.deepEqual(z1.team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false })
|
||||
assert.equal(z1.teamVersion, 1)
|
||||
assert.equal(z1.hindernis, null)
|
||||
|
||||
const z2 = projekte.find((p) => p.projectId === 'p2').features.zeiten
|
||||
assert.deepEqual(z2.plaene, [])
|
||||
assert.deepEqual(z2.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
|
||||
assert.equal(z2.teamVersion, 0)
|
||||
assert.equal(z2.hindernis, 'zeiten_fehlen')
|
||||
|
||||
// andere Funktionen bekommen die Felder nicht
|
||||
for (const key of ['news', 'termine']) {
|
||||
for (const feld of ['plaene', 'team', 'teamVersion', 'hindernis']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('adminView: hindernis nur in setup und nur wenn der Haken freischaltbar nicht erfüllt ist; Hauptplan steht zuerst', async () => {
|
||||
// Hauptplan nicht an erster Stelle gespeichert
|
||||
const nebenZuerst = featureDoc('p1')
|
||||
aw.seed('portalFeatures', nebenZuerst)
|
||||
await mitZeiten(nebenZuerst, eingabe([planDaten(P2, 'Weihnachten'), planDaten(P1, 'Standard', { haupt: true })]))
|
||||
assert.deepEqual((await zeitenSicht('p1')).plaene.map((p) => p.kurz), ['standard', 'weihnachten'])
|
||||
assert.equal((await zeitenSicht('p1')).hindernis, null)
|
||||
|
||||
// alle Tage zu: bleibt in setup haengen
|
||||
await deleteDocument('oeffnungszeiten', nebenZuerst.$id)
|
||||
await mitZeiten(nebenZuerst, eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage() })]))
|
||||
assert.equal((await zeitenSicht('p1')).hindernis, 'zeiten_fehlen')
|
||||
|
||||
// gleicher Zustand, aber aktiv oder abgeschaltet: kein Hindernis (es gibt nichts mehr freizuschalten)
|
||||
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'active', enabled: true })
|
||||
assert.equal((await zeitenSicht('p1')).hindernis, null)
|
||||
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: JETZT.toISOString() })
|
||||
assert.equal((await zeitenSicht('p1')).hindernis, null)
|
||||
})
|
||||
|
||||
test('adminView ohne Funktionsdokument: leere Pläne, Standard-Team, Version 0, kein Hindernis', async () => {
|
||||
const z = await zeitenSicht('p1')
|
||||
assert.equal(z.state, 'off')
|
||||
assert.deepEqual([z.plaene, z.team, z.teamVersion, z.hindernis], [[], { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, 0, null])
|
||||
})
|
||||
Reference in new issue
Block a user