Öffnungszeiten: Fixrunde Server (Schema-Kopplung, K27, Wertersatz, Widerruf, Ticket nachlegen)
- activate() schreibt die Zeiten-Felder (aboEndeAt, behaltenSessionId) nur noch über extra
(nur Abo-Rückkehr und Team-Aktivierung der Zeiten); News und Terminplaner laufen auch vor zeiten-setup.mjs
- Abo-Abgleich: K27 gilt als offen, solange der Marker vor disabledAt liegt (kein Zurücksetzen nach disable() nötig);
K27 geht erst nach dem Ersatz-Ticket raus (scheitert es, wiederholt der nächste Lauf beides);
Löschung setzt auch activatedAt zurück
- Zustimmung "beginn" (bezahlt) mit Wertersatz-Satz (§ 357a Abs. 2 BGB) wie bei News und Terminplaner, Fassung 2026-10-05
- Widerruf: Bestellung "zeiten" in ORDERS, FEATURE_OF_ORDER, orderLabel und widerrufen.html
- Team "Ticket nachlegen" der Zeiten ohne Kauf geht über holeZeitenNach statt über den Zahlungsweg
- Einschalten-Route nimmt nur { zustimmung }, nicht die Altform { consent: true }
- Team-Aktivierung behält kauf/unlockedByPurchase bei einem bisher gekauften Dokument
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c10e50d0b3
commit
ecf67d6978
13 files changed
+337
-48
No files matched your search
@@ -1007,6 +1007,40 @@ test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 de
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_zeiten: „Öffnungszeiten live“ gehört zu den Zeiten (nicht zur News), Frist und Produkt in K04 und T09 stimmen', async () => {
|
||||
const newsAt = daysAgo(20)
|
||||
const zeitenAt = daysAgo(3)
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
|
||||
seedBezahlt({ projectId: 'pz1', projektName: 'Praxis Zeiten', featureKey: 'zeiten', orderedAt: zeitenAt })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
const before = st.calls.length
|
||||
assert.equal((await withdraw({ order: 'zeiten', customerNumber: 'KD-9' })).status, 200)
|
||||
const kunde = k04()
|
||||
assert.match(kunde.subject, /WEBklar Öffnungszeiten live$/)
|
||||
assert.match(kunde.text, /Widerruf der Bestellung „Öffnungszeiten live“/)
|
||||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||||
assert.doesNotMatch(kunde.text, /News-Pinnwand|Es erfolgen keine weiteren Abbuchungen/)
|
||||
const team = t09().text
|
||||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.zeiten.label.name}, ${config.features.zeiten.priceLabel} einmalig)`), team)
|
||||
assert.match(team, /Öffnungszeiten live/)
|
||||
assert.match(team, /RECHTZEITIG/, 'Frist der Zeiten (vor 3 Tagen), nicht die der News (vor 20 Tagen)')
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(zeitenAt).endsAt)), 'Fristende der Zeiten')
|
||||
assert.match(team, /Praxis Zeiten: in Einrichtung/)
|
||||
assert.doesNotMatch(team, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
|
||||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||||
assert.equal(aw.docs('portalFeatures').get('f_pz1_zeiten').state, 'setup', 'nichts automatisch geändert')
|
||||
|
||||
// „Funktion“ bleibt die News: ihre Frist ist abgelaufen, die jungen Zeiten retten sie nicht
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.match(t09().text, /NACH Fristende/)
|
||||
assert.doesNotMatch(t09().text, /Praxis Zeiten/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pz1_zeiten')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
// Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss)
|
||||
@@ -1279,6 +1313,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||||
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
|
||||
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/)
|
||||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixt
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { activate } = await import('../server/services/siteFeatures.js')
|
||||
const { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = await import('../server/services/siteFeatures.js')
|
||||
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
|
||||
const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js')
|
||||
const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
|
||||
@@ -394,7 +394,7 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste
|
||||
}
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(!funktion().aboEndeMailAt, 'der Marker der K26 gilt nicht für die K27')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K26 gilt nicht für die K27')
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
|
||||
sink.sendMail = roh
|
||||
@@ -405,17 +405,72 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert: abgeschaltet und K27 gehen trotzdem durch, fehler zählt', async () => {
|
||||
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K27 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K27 genau einmal', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
const erg = await laufe(nach(30))
|
||||
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.equal(erg.abgeschaltet, 1)
|
||||
assert.equal(erg.mails, 1)
|
||||
assert.equal(erg.fehler, 1)
|
||||
assert.equal(ersatzTickets().length, 0)
|
||||
assert.equal(mailsMit(K27).length, 0, 'K27 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
|
||||
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K27-Marker')
|
||||
|
||||
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K27 einmal
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K27')
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
})
|
||||
|
||||
test('Ersatz-Ticket scheitert mehrere Läufe: kein K27, jeder Lauf zählt fehler; sobald es klappt, genau eine K27', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
await laufe(nach(30))
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 0)
|
||||
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abschalten gelingt, die Folge scheitert (Marker der K26 steht noch, älter als disabledAt): der nächste Lauf holt K27 und das Ersatz-Ticket nach, einmal', async () => {
|
||||
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K26
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
|
||||
abgelaufen()
|
||||
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
|
||||
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 1)
|
||||
assert.equal(ersatzTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K27-Marker liegt nie davor, K27 geht nicht täglich raus', async () => {
|
||||
await legeFunktion(1)
|
||||
abgelaufen()
|
||||
await laufe(JETZT)
|
||||
mails().length = 0
|
||||
const tag30 = nach(30)
|
||||
mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs
|
||||
try {
|
||||
assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
|
||||
} finally {
|
||||
mock.timers.reset()
|
||||
}
|
||||
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
|
||||
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mailsMit(K27).length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos
|
||||
@@ -560,9 +615,9 @@ test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht,
|
||||
assert.equal(doc.state, 'off')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.deepEqual(
|
||||
[doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''],
|
||||
['', '', null, null, ''],
|
||||
'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId geleert'
|
||||
[doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
|
||||
['', '', null, null, '', null],
|
||||
'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
|
||||
)
|
||||
assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/)
|
||||
assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
|
||||
@@ -809,7 +864,8 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark
|
||||
behaltenSessionId: 'cs_test_offen',
|
||||
zahlungMeldung: 'abo_ende',
|
||||
})
|
||||
const doc = await activate(funktionsId(1), { adminNote: 'Test' })
|
||||
// der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection)
|
||||
const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER })
|
||||
assert.deepEqual([doc.state, doc.enabled], ['active', true])
|
||||
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
|
||||
assert.equal(doc.zahlungMeldung, 'abo_ende')
|
||||
@@ -818,6 +874,19 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark
|
||||
assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 }))
|
||||
})
|
||||
|
||||
test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => {
|
||||
const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' }
|
||||
await legeFunktion(1, offen)
|
||||
const ohne = await activate(funktionsId(1), { adminNote: 'Test' })
|
||||
assert.equal(ohne.state, 'active')
|
||||
assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen'])
|
||||
|
||||
await aw.seed('portalFeatures', { ...funktion(1), ...offen })
|
||||
const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' })
|
||||
assert.equal(team.state, 'active')
|
||||
assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, ''])
|
||||
})
|
||||
|
||||
test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K27 und das Ersatz-Ticket nach', async () => {
|
||||
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
|
||||
abgelaufen()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { ZEITEN_FEATURE_ATTRIBUTES } from '../server/services/zeiten/schema.js'
|
||||
|
||||
// activate() ist der gemeinsame Schreibpfad aller Funktionen (News, Terminplaner, Zeiten). Die neuen Zeiten-Felder von portalFeatures
|
||||
// (unlockedBy, aboEndeAt, einbau, behaltenSessionId, k24At) legt erst scripts/zeiten-setup.mjs an: laeuft der Code vor diesem Skript,
|
||||
// darf die Aktivierung von News und Terminplaner nie ein Zeiten-Feld schreiben (Appwrite: "Unknown attribute"). Der Nachbau hier hat
|
||||
// darum genau das Schema OHNE die Zeiten-Felder; nur der Zeiten-Pfad (extra) schreibt sie.
|
||||
const ZEITEN_FELDER = ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)
|
||||
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures').filter((k) => !ZEITEN_FELDER.includes(k)) } })
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { activate, adminActivate } = await import('../server/services/siteFeatures.js')
|
||||
|
||||
const ZUVOR = '2026-09-20T10:00:00.000Z'
|
||||
const lesen = (id) => aw.docs('portalFeatures').get(id)
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins' })
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
|
||||
|
||||
for (const key of ['news', 'termine']) {
|
||||
test(`activate() einer abgeschalteten Funktion (${key}) schreibt keine Zeiten-Felder und scheitert nicht ohne zeiten-setup`, async () => {
|
||||
const id = `f_${key}`
|
||||
aw.seed('portalFeatures', {
|
||||
$id: id, customerId: 'c1', projectId: 'p1', featureKey: key, state: 'disabled', enabled: false,
|
||||
disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeMailAt: ZUVOR,
|
||||
})
|
||||
const doc = await activate(id, { by: 'kenso' })
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.disabledAt, doc.disabledReason, doc.aboEndeMailAt], ['active', true, null, null, null])
|
||||
for (const feld of ZEITEN_FELDER) assert.equal(feld in lesen(id), false, `${feld} wurde nicht geschrieben`)
|
||||
})
|
||||
}
|
||||
|
||||
test('adminActivate() (News) schreibt keine Zeiten-Felder', async () => {
|
||||
aw.seed('portalFeatures', { $id: 'f_admin', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false })
|
||||
const doc = await adminActivate('c1', 'p1', 'news', { by: 'kenso' })
|
||||
assert.equal(doc.state, 'active')
|
||||
})
|
||||
@@ -277,6 +277,18 @@ test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout, mit der Grat
|
||||
keineSpuren()
|
||||
})
|
||||
|
||||
test('Altform { consent: true } (News, ohne Fassungsprüfung) wird bei dieser neuen Route nicht angenommen: 400, keine Spur', async () => {
|
||||
// sonst würde nach einer Textänderung der Gratis-Zustimmung ein alter Tab den neuen Text bestätigen, ohne ihn gesehen zu haben
|
||||
for (const body of [{ projectId: 'p1', consent: true }, { projectId: 'p1', consent: true, zustimmung: null }, { projectId: 'p1', consent: true, zustimmung: {} }]) {
|
||||
const res = await einschalten(body)
|
||||
assert.equal(res.status, 400, JSON.stringify(body))
|
||||
assert.equal(res.body.code, 'zustimmung_fehlt')
|
||||
}
|
||||
keineSpuren()
|
||||
// die Form mit Fassung und Haken geht weiter
|
||||
assert.equal((await einschalten(koerper({ consent: true }))).status, 200)
|
||||
})
|
||||
|
||||
test('Projekt: fehlt ⇒ 400, fremd oder unbekannt ⇒ 403, archiviert ⇒ 409', async () => {
|
||||
assert.equal((await einschalten({ zustimmung: zustimmung() })).status, 400)
|
||||
assert.equal((await einschalten(koerper({ projectId: 'p3' }))).status, 403)
|
||||
@@ -455,6 +467,27 @@ test('Team aus disabled: zurück auf setup, Neubau erneut, kein neues Ticket', a
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
test('Team aus disabled bei einem gekauften Dokument: kauf und unlockedByPurchase bleiben (Widerrufs-Zuordnung), einbau bleibt, kein Neubau', async () => {
|
||||
for (const [nr, kauf] of [[1, { unlockedBy: 'kauf', unlockedByPurchase: true }], [2, { unlockedByPurchase: true }]]) {
|
||||
const projectId = `p_k${nr}`
|
||||
projekt(projectId)
|
||||
aw.seed('portalFeatures', {
|
||||
$id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop',
|
||||
einbau: 'angestossen', ticketId: 'ticket_alt', disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', ...kauf,
|
||||
})
|
||||
const { doc } = await einschaltenZeiten({ customerId: 'c1', projectId, by: 'team', now: JETZT })
|
||||
assert.deepEqual([doc.state, sf.unlockedByVon(doc), doc.unlockedByPurchase, doc.disabledAt, doc.disabledReason, doc.ticketId, doc.einbau], ['setup', 'kauf', true, null, null, 'ticket_alt', 'angestossen'], `Fall ${nr}`)
|
||||
}
|
||||
assert.deepEqual([rebuilds.length, tickets().length], [0, 0], 'bei gekauften Dokumenten kümmert sich markPaid um Neubau und Ticket, nicht das Nachholen')
|
||||
// ein Dokument, das nie gekauft war, wird weiter ein Team-Dokument; ohne Dokument ebenso
|
||||
aw.seed('portalFeatures', {
|
||||
$id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop',
|
||||
unlockedBy: 'abo', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 'ticket_alt',
|
||||
})
|
||||
const team = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', now: JETZT })
|
||||
assert.deepEqual([team.doc.unlockedBy, team.doc.unlockedByPurchase], ['team', false])
|
||||
})
|
||||
|
||||
test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy abo und leerem k24At, danach Marker', async () => {
|
||||
aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' })
|
||||
const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
|
||||
|
||||
@@ -46,7 +46,7 @@ test('zeiten steht im Register mit 30 € und Abo-Inklusive', () => {
|
||||
|
||||
test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', () => {
|
||||
const { consent } = feature()
|
||||
assert.equal(consent.version, '2026-10-04')
|
||||
assert.equal(consent.version, '2026-10-05', 'Fassung nach dem Wertersatz-Satz (§ 357a Abs. 2 BGB)')
|
||||
assert.equal(consent.knopf, 'Zahlungspflichtig bestellen · 30 €')
|
||||
assert.deepEqual(consent.items.map((i) => [i.id, i.haken]), [['leistung', true], ['beginn', true]])
|
||||
assert.equal(
|
||||
@@ -55,11 +55,20 @@ test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30
|
||||
)
|
||||
assert.equal(
|
||||
consent.items[1].text,
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB).'
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit vollständiger Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
|
||||
)
|
||||
assert.match(consent.items[1].text, /§ 356 Abs\. 5 Nr\. 2/)
|
||||
})
|
||||
|
||||
test('Zustimmung bezahlt: der Wertersatz-Satz ist wörtlich der von News und Terminplaner (einheitliche Fassungen; Wortlaut bleibt Sache der Rechtsrunde, ZQ3)', () => {
|
||||
const satz = (text) => text.match(/Widerrufe ich vor der vollständigen Erbringung[^.]*\.[^.]*\)\./)?.[0]
|
||||
const zeiten = satz(config.features.zeiten.consent.items.find((i) => i.id === 'beginn').text)
|
||||
assert.ok(zeiten, 'Wertersatz-Satz vorhanden')
|
||||
for (const key of ['news', 'termine']) {
|
||||
assert.equal(satz(config.features[key].consent.items.find((i) => i.id === 'beginn').text), zeiten, `${key} hat denselben Satz`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Zustimmung gratis: ein Haken, Knopf „Einschalten“, kein „zahlungspflichtig“', () => {
|
||||
const f = feature()
|
||||
assert.equal(f.consentFrei.version, '2026-10-04')
|
||||
|
||||
@@ -201,6 +201,44 @@ test('Team „Freischalten (Abnahme)“ der Zeiten: ohne Zeiten 409 (bleibt setu
|
||||
assert.equal(ok.body.ersatzTicket, undefined, 'abnahme legt kein Ersatz-Ticket an')
|
||||
})
|
||||
|
||||
// Review N8 / I1: „Ticket nachlegen“ lief für Abo und Team in den Zahlungsweg und scheiterte immer mit 409 „keine Zahlung vermerkt“
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Team, setup, ohne Ticket): kein 409, Neubau und Einbau-Ticket kommen über das Nachholen, keine K24', async () => {
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'team', einbau: '' }))
|
||||
const res = await aktion('ticket')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = zeitenDoc()
|
||||
assert.ok(doc.ticketId, 'Ticket angelegt')
|
||||
assert.equal(doc.einbau, 'angestossen')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
assert.equal(res.body.projects[0].features.zeiten.ticketMissing, false)
|
||||
assert.match(doc.adminNote, / Andrej: ticket$/)
|
||||
assert.equal(sink.sent.length, 0, 'unlockedBy team: keine K24')
|
||||
|
||||
// zweiter Klick: das Ticket ist da, nichts entsteht neu
|
||||
assert.equal((await aktion('ticket')).status, 200)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(rebuilds.length, 1)
|
||||
})
|
||||
|
||||
test('Team „Ticket nachlegen“ der Zeiten (Abo, setup, ohne Ticket): Ticket und K24 über das Nachholen, nicht über den Zahlungsweg', async () => {
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }))
|
||||
const res = await aktion('ticket')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.ok(zeitenDoc().ticketId)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(sink.sent.length, 1, 'K24 an den Kunden (unlockedBy abo)')
|
||||
assert.ok(zeitenDoc().k24At)
|
||||
})
|
||||
|
||||
test('Team „Ticket nachlegen“ der Zeiten (gekauft, ohne Zahlungsvermerk): bleibt der Zahlungsweg (409)', async () => {
|
||||
aw.seed('portalFeatures', featureDoc('p1', { state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true, einbau: 'angestossen' }))
|
||||
const res = await aktion('ticket')
|
||||
assert.equal(res.status, 409, JSON.stringify(res.body))
|
||||
assert.match(res.body.error, /keine Zahlung vermerkt/)
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user