Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)
Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16 einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg, geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus markPaid in legeTicketAn herausgeloest. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fe14c2fe6
commit
f830698dc3
3 files changed
+699
-12
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { featureConfig } from '../../services/featureRegistry.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import {
|
||||
FEATURE_KEYS,
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
adminActivate,
|
||||
assertOwnProject,
|
||||
disable,
|
||||
freischaltenNachAbnahme,
|
||||
getFeatureDoc,
|
||||
listCustomerFeatureDocs,
|
||||
listCustomerFeatures,
|
||||
ticketNachlegen,
|
||||
} from '../../services/siteFeatures.js'
|
||||
|
||||
/**
|
||||
@@ -69,19 +72,27 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
|
||||
// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), disable, abnahme (Freischalten nach Abnahme, in
|
||||
// setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket nachlegen, in setup). abnahme und ticket gibt es nur fuer
|
||||
// Funktionen mit ticketFreischaltung im Register.
|
||||
const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket']
|
||||
const TICKET_ACTIONS = ['abnahme', 'ticket']
|
||||
|
||||
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
try {
|
||||
const { customerId, key } = req.params
|
||||
if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
||||
const { projectId, action } = req.body || {}
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
|
||||
if (action !== 'activate' && action !== 'disable') {
|
||||
if (!ACTIONS.includes(action)) {
|
||||
return res.status(400).json({ error: 'Unbekannte Aktion' })
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) {
|
||||
return res.status(400).json({ error: 'Projekt fehlt' })
|
||||
}
|
||||
if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) {
|
||||
return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' })
|
||||
}
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const by = adminName(req)
|
||||
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||
@@ -89,6 +100,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
||||
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
|
||||
if (action === 'activate') {
|
||||
await adminActivate(customerId, projectId, key, { by, adminNote })
|
||||
} else if (action === 'abnahme') {
|
||||
await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote })
|
||||
} else if (action === 'ticket') {
|
||||
await ticketNachlegen({ 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 })
|
||||
|
||||
@@ -358,20 +358,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||||
})
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||
const customer = await customerOf(doc.customerId).catch(() => null)
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
const ticketId = await legeTicketAn(setup, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||||
return { doc: updated, created: true, fall: null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern.
|
||||
* Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins
|
||||
* Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail.
|
||||
* @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey)
|
||||
* @param {object} project websiteProjects-Dokument
|
||||
* @param {object|null} customer Kundendokument, best effort
|
||||
* @returns {Promise<string>} ID des neuen Tickets
|
||||
*/
|
||||
async function legeTicketAn(doc, project, customer) {
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {}
|
||||
const { ticketId } = await createFeatureTicket({
|
||||
customerId: doc.customerId,
|
||||
project,
|
||||
featureKey: doc.featureKey,
|
||||
siteKey: doc.siteKey,
|
||||
...texte,
|
||||
})
|
||||
return ticketId
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
|
||||
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
|
||||
@@ -586,6 +600,171 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
|
||||
return activated
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||||
const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||||
/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */
|
||||
const ABNAHME_HINDERNIS = {
|
||||
kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'],
|
||||
keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'],
|
||||
abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'],
|
||||
}
|
||||
|
||||
/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */
|
||||
async function dokumentFuerTeamAktion(customerId, projectId, key) {
|
||||
assertKey(key)
|
||||
const doc = await getFeatureDoc(customerId, projectId, key)
|
||||
if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
return doc
|
||||
}
|
||||
|
||||
/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */
|
||||
async function frischesDokument(vorab, key) {
|
||||
try {
|
||||
return await getDocument(config.collections.portalFeatures, vorab.$id)
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen (Terminplaner:
|
||||
* laufendes Wartungs-Abo und gespeicherte Einstellung); fehlt der Haken (News), gibt es keine Pruefung und kein Abo wird gelesen.
|
||||
* Die Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert).
|
||||
*/
|
||||
async function abnahmeHindernis(doc) {
|
||||
if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null
|
||||
const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js')
|
||||
return pruefe(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen
|
||||
* wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben.
|
||||
* Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate:<id> wie der Freischalt-Job
|
||||
* (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts.
|
||||
* - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}".
|
||||
* - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false.
|
||||
* - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503.
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args
|
||||
* @returns {Promise<{ doc: object, activated: boolean }>}
|
||||
*/
|
||||
export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state === 'active') return { doc, activated: false }
|
||||
if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP)
|
||||
const grund = await abnahmeHindernis(doc)
|
||||
if (grund) {
|
||||
const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.']
|
||||
throw httpError(status, text)
|
||||
}
|
||||
const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)')
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('active'),
|
||||
activatedAt: now.toISOString(),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
return { doc: updated, activated: true }
|
||||
})
|
||||
if (ergebnis.activated) {
|
||||
const { meldeFreigeschaltet } = await import('./termine/freischaltung.js')
|
||||
await meldeFreigeschaltet(ergebnis.doc)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */
|
||||
async function ticketBesteht(ticketId) {
|
||||
try {
|
||||
await getDocument(config.collections.workorders, ticketId)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */
|
||||
async function bezahlteSitzung(doc) {
|
||||
if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||||
let session
|
||||
try {
|
||||
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message)
|
||||
throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.')
|
||||
}
|
||||
if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.')
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie
|
||||
* im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das
|
||||
* Ticket und sendet nichts. Keine Sperre feature-paid:<session> hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar).
|
||||
*/
|
||||
async function ticketNachZahlung(doc, note) {
|
||||
const session = await bezahlteSitzung(doc)
|
||||
await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })
|
||||
const nachher = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (!nachher.ticketId) return { doc: nachher, created: false }
|
||||
if (!note) return { doc: nachher, created: true }
|
||||
try {
|
||||
return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true }
|
||||
} catch (err) {
|
||||
// Ticket und Mails sind raus; die Notiz ist nur ein Vermerk
|
||||
console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message)
|
||||
return { doc: nachher, created: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim
|
||||
* ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:<session>), damit kein Stripe-Retry daneben
|
||||
* schreibt; das Dokument wird darunter erneut gelesen.
|
||||
*/
|
||||
async function ersetzeGeloeschtesTicket(doc, note) {
|
||||
return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false }
|
||||
const project = await getDocument(config.collections.websiteProjects, frisch.projectId)
|
||||
const customer = await customerOf(frisch.customerId).catch(() => null)
|
||||
const ticketId = await legeTicketAn(frisch, project, customer)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) })
|
||||
return { doc: updated, created: true }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409).
|
||||
* (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen,
|
||||
* Ticket + K15 + T16 genau einmal (ticketNachZahlung);
|
||||
* (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket);
|
||||
* (c) Ticket vorhanden: nichts, created false.
|
||||
* Eine Sperre feature-ticket:<id> je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten
|
||||
* (ein Ticket, created nur einmal true).
|
||||
* @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args
|
||||
* @returns {Promise<{ doc: object, created: boolean }>}
|
||||
*/
|
||||
export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) {
|
||||
const vorab = await dokumentFuerTeamAktion(customerId, projectId, key)
|
||||
const note = adminNote ?? byNote(by, 'Ticket nachgelegt')
|
||||
return withLocks([`feature-ticket:${vorab.$id}`], async () => {
|
||||
const doc = await frischesDokument(vorab, key)
|
||||
if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP)
|
||||
if (!doc.ticketId) return ticketNachZahlung(doc, note)
|
||||
if (await ticketBesteht(doc.ticketId)) return { doc, created: false }
|
||||
return ersetzeGeloeschtesTicket(doc, note)
|
||||
})
|
||||
}
|
||||
|
||||
/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */
|
||||
export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
|
||||
if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null
|
||||
|
||||
@@ -21,9 +21,17 @@ const sf = await import('../server/services/siteFeatures.js')
|
||||
const { loeschTag } = await import('../server/services/termine/frist.js')
|
||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||
const { aboMeldung } = await import('../server/services/featureOrder.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { mailSink } = await import('./support/mailSink.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
|
||||
@@ -546,3 +554,488 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei
|
||||
const pilot = legeKunde({ over: { unlockedByPurchase: false } })
|
||||
assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active')
|
||||
})
|
||||
|
||||
// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) ---
|
||||
// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal).
|
||||
// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen
|
||||
// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts.
|
||||
const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' }
|
||||
const K15_BETREFF = 'Deine Bestellung: Terminplaner'
|
||||
const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner'
|
||||
const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||||
const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||||
const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'
|
||||
const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.'
|
||||
const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.'
|
||||
const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.'
|
||||
const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.'
|
||||
const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email))
|
||||
const seit = (n) => mails.sent.slice(n)
|
||||
|
||||
let d7Nr = 0
|
||||
/**
|
||||
* Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung.
|
||||
* ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt).
|
||||
* session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein.
|
||||
*/
|
||||
function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) {
|
||||
d7Nr += 1
|
||||
const n = d7Nr
|
||||
const customerId = `cd${n}`
|
||||
const projectId = `cdp${n}`
|
||||
const docId = `f_${projectId}_${key}`
|
||||
const ticketId = `t_cd${n}`
|
||||
const sessionId = session ? `cs_cd${n}` : ''
|
||||
const email = `cd${n}@example.invalid`
|
||||
aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' })
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` })
|
||||
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId }))
|
||||
if (ticket === 'Open' || ticket === 'Closed') {
|
||||
aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' })
|
||||
}
|
||||
if (session) {
|
||||
st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` })
|
||||
st.checkoutSessions.set(sessionId, {
|
||||
id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`,
|
||||
metadata: { customerId, projectId, featureKey: key },
|
||||
})
|
||||
}
|
||||
aw.seed('portalFeatures', {
|
||||
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active',
|
||||
unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId,
|
||||
...(ticket ? { ticketId } : {}), adminNote: '', ...over,
|
||||
})
|
||||
if (einstellung) {
|
||||
aw.seed('termineConfig', {
|
||||
$id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test',
|
||||
})
|
||||
}
|
||||
return { customerId, projectId, docId, ticketId, sessionId, email }
|
||||
}
|
||||
const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts)
|
||||
const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts)
|
||||
const ticketDoc = (id) => aw.docs('workorders').get(id)
|
||||
const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` })
|
||||
|
||||
// ----------------------------------------------------------------------------- abnahme ---
|
||||
test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId])
|
||||
const doc = docById(k.docId)
|
||||
assert.deepEqual([doc.state, doc.enabled], ['active', true])
|
||||
assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt')
|
||||
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
|
||||
assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt)
|
||||
assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt')
|
||||
assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen')
|
||||
assert.equal(doc.unlockedByPurchase, true)
|
||||
assert.equal(doc.purchaseStatus, 'paid')
|
||||
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/)
|
||||
const k16 = mailsAn(k.email, K16_BETREFF.termine)
|
||||
assert.equal(k16.length, 1, 'K16 genau einmal')
|
||||
assert.match(k16[0].text, /dashboard\.html#termine/)
|
||||
})
|
||||
|
||||
test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => {
|
||||
const k = legeEinrichtung()
|
||||
assert.equal((await abnahme(k)).status, 200)
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const nochmal = await abnahme(k)
|
||||
assert.equal(nochmal.status, 200)
|
||||
assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active')
|
||||
assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
|
||||
|
||||
const k2 = legeEinrichtung()
|
||||
const erster = await dienst(k2, 'freischaltenNachAbnahme')
|
||||
assert.equal(erster.activated, true)
|
||||
assert.equal(erster.doc.state, 'active')
|
||||
const zweiter = await dienst(k2, 'freischaltenNachAbnahme')
|
||||
assert.equal(zweiter.activated, false)
|
||||
assert.equal(zweiter.doc.state, 'active')
|
||||
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const [a, b] = await Promise.all([abnahme(k), abnahme(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
|
||||
// auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal
|
||||
const k2 = legeEinrichtung()
|
||||
const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')])
|
||||
assert.deepEqual([x.activated, y.activated].sort(), [false, true])
|
||||
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => {
|
||||
for (const abnahmeZuerst of [true, false]) {
|
||||
const k = legeEinrichtung({ ticket: 'Closed' })
|
||||
const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })]
|
||||
if (!abnahmeZuerst) start.reverse()
|
||||
await Promise.all(start.map((f) => f()))
|
||||
assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => {
|
||||
const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
|
||||
legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
|
||||
legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
|
||||
]
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409, vorher.state)
|
||||
assert.equal(res.body.error, NUR_SETUP, vorher.state)
|
||||
assert.deepEqual(docById(k.docId), vorher, vorher.state)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ abo: null }),
|
||||
legeEinrichtung({ abo: 'hosting' }),
|
||||
legeEinrichtung({ abo: 'wartung' }),
|
||||
]
|
||||
// beendetes Wartungs-Abo: das dritte Abo auf canceled stellen
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409, k.customerId)
|
||||
assert.equal(res.body.error, OHNE_ABO)
|
||||
assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => {
|
||||
for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) {
|
||||
const k = legeEinrichtung()
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver }))
|
||||
assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => {
|
||||
const k = legeEinrichtung({ einstellung: false })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, OHNE_EINSTELLUNG)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeEinrichtung()
|
||||
const vorher = { ...docById(k.docId) }
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 503)
|
||||
assert.equal(res.body.error, ABO_UNKLAR)
|
||||
assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log')
|
||||
assert.equal((await abnahme(k)).status, 200)
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
})
|
||||
|
||||
test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const vorher = { ...docById(k.docId) }
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2')
|
||||
assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403)
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403)
|
||||
const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) })
|
||||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' })
|
||||
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' })
|
||||
assert.equal(ohneDoc.status, 409)
|
||||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||||
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
|
||||
assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => {
|
||||
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false })
|
||||
const abfragen = aw.listCalls('subscriptions').length
|
||||
const res = await abnahme(k, 'news')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(featuresOf(res, k.projectId).news.state, 'active')
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.equal(docById(k.docId).ticketId, k.ticketId)
|
||||
assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
for (const action of ['abnahme', 'ticket']) {
|
||||
for (const key of ['analyse', 'kalender']) {
|
||||
const res = await team(k.customerId, key, { projectId: k.projectId, action })
|
||||
assert.equal(res.status, 404, `${key} ${action}`)
|
||||
}
|
||||
}
|
||||
assert.equal(aw.docs('portalFeatures').size, docs)
|
||||
})
|
||||
|
||||
test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => {
|
||||
const k = legeEinrichtung({ key: 'news' })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const alt = config.features.news.ticketFreischaltung
|
||||
config.features.news.ticketFreischaltung = false
|
||||
try {
|
||||
for (const action of ['abnahme', 'ticket']) {
|
||||
const res = await team(k.customerId, 'news', { projectId: k.projectId, action })
|
||||
assert.equal(res.status, 409, action)
|
||||
assert.equal(res.body.error, KEINE_AKTION, action)
|
||||
}
|
||||
} finally {
|
||||
config.features.news.ticketFreischaltung = alt
|
||||
}
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0)
|
||||
// activate und disable gehoeren nicht dazu und gehen weiter
|
||||
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
|
||||
})
|
||||
|
||||
test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeEinrichtung()
|
||||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
|
||||
t.after(() => setMailTransportForTests(mails))
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' '))))
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------------- ticket ---
|
||||
test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId, 'ticketId gesetzt')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
const ticket = ticketDoc(doc.ticketId)
|
||||
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
|
||||
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details)
|
||||
assert.equal(ticket.status, 'Open')
|
||||
assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true])
|
||||
assert.match(doc.adminNote, /Andrej: ticket/)
|
||||
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
|
||||
// K15 an den Kunden, T16 ans Team, je einmal
|
||||
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
|
||||
const neu = seit(mailsVorher)
|
||||
assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1)
|
||||
assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid)))
|
||||
assert.equal(neu.length, 2, 'sonst nichts')
|
||||
|
||||
// zweiter Klick: Ticket vorhanden => nichts Neues
|
||||
const nochmal = await nachlegen(k)
|
||||
assert.equal(nochmal.status, 200)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(docById(k.docId).ticketId, doc.ticketId)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails')
|
||||
|
||||
// ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an
|
||||
await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } })
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails')
|
||||
assert.equal(docById(k.docId).ticketId, doc.ticketId)
|
||||
})
|
||||
|
||||
test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')])
|
||||
assert.deepEqual([a.created, b.created].sort(), [false, true])
|
||||
assert.equal(a.doc.ticketId, b.doc.ticketId)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal')
|
||||
const spaeter = await dienst(k, 'ticketNachlegen')
|
||||
assert.equal(spaeter.created, false)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
})
|
||||
|
||||
test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2)
|
||||
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
|
||||
})
|
||||
|
||||
test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const vorher = { ...docById(unbezahlt.docId) }
|
||||
const res = await nachlegen(unbezahlt)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, NICHT_BEZAHLT)
|
||||
assert.deepEqual(docById(unbezahlt.docId), vorher)
|
||||
assert.equal(workorders(), tickets)
|
||||
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const vorherK = { ...docById(k.docId) }
|
||||
st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' })
|
||||
const aus = await nachlegen(k)
|
||||
assert.equal(aus.status, 503)
|
||||
assert.equal(typeof aus.body.error, 'string')
|
||||
assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen')
|
||||
assert.deepEqual(docById(k.docId), vorherK)
|
||||
assert.equal(workorders(), tickets)
|
||||
assert.equal(seit(mailsVorher).length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log')
|
||||
|
||||
assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2)
|
||||
})
|
||||
|
||||
test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => {
|
||||
const k = legeEinrichtung({ ticket: null })
|
||||
const aufrufe = st.calls.length
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||||
assert.equal(st.calls.length, aufrufe)
|
||||
})
|
||||
|
||||
test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const aufrufe = st.calls.length
|
||||
assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt')
|
||||
assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true)
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
const ticket = ticketDoc(doc.ticketId)
|
||||
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
|
||||
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'))
|
||||
assert.equal(ticket.status, 'Open')
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.match(doc.adminNote, /Andrej: ticket/)
|
||||
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
|
||||
assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus')
|
||||
assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt')
|
||||
})
|
||||
|
||||
test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
|
||||
const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets2 = workorders()
|
||||
const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')])
|
||||
assert.deepEqual([x.created, y.created].sort(), [false, true])
|
||||
assert.equal(workorders(), tickets2 + 1)
|
||||
assert.equal(x.doc.ticketId, y.doc.ticketId)
|
||||
})
|
||||
|
||||
test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => {
|
||||
for (const ticket of ['Open', 'Closed']) {
|
||||
const k = legeEinrichtung({ ticket, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const aufrufe = st.calls.length
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, ticket)
|
||||
assert.deepEqual(docById(k.docId), vorher, ticket)
|
||||
assert.equal(workorders(), tickets, ticket)
|
||||
assert.equal(seit(mailsVorher).length, 0, ticket)
|
||||
assert.equal(st.calls.length, aufrufe, ticket)
|
||||
const dienstErgebnis = await dienst(k, 'ticketNachlegen')
|
||||
assert.equal(dienstErgebnis.created, false, ticket)
|
||||
assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket)
|
||||
}
|
||||
})
|
||||
|
||||
test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
|
||||
legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
|
||||
legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }),
|
||||
legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
|
||||
]
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 409, vorher.state)
|
||||
assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state)
|
||||
assert.deepEqual(docById(k.docId), vorher, vorher.state)
|
||||
}
|
||||
assert.equal(workorders(), tickets)
|
||||
assert.equal(seit(mailsVorher).length, 0)
|
||||
aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' })
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' })
|
||||
assert.equal(ohneDoc.status, 409)
|
||||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||||
assert.equal(aw.docs('portalFeatures').size, docs)
|
||||
})
|
||||
|
||||
test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const tickets = workorders()
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403)
|
||||
const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) })
|
||||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(workorders(), tickets)
|
||||
})
|
||||
|
||||
test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => {
|
||||
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' })
|
||||
const mailsVorher = mails.sent.length
|
||||
const res = await nachlegen(k, 'news')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId)
|
||||
assert.equal(doc.state, 'setup')
|
||||
const neu = seit(mailsVorher)
|
||||
assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1)
|
||||
assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden')
|
||||
})
|
||||
Reference in new issue
Block a user