Files
Webklar-Kundenbereich/test/funktionenShopTextform.test.js

316 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Webshop: Aktivierung durch das Team nur mit Zustimmung des Kunden in Textform (AVV Webshop, Vorbemerkung Abs. 2; wie der Terminplaner,
// siehe test/funktionenTermine.test.js "Staff-Aktivierung mit Zustimmung in Textform"). Schalter: shopTextformNoetig (services/shop/schalter.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { leseZustimmung } = await import('../server/services/featureConsents.js')
const { setLegalDirForTests, legalPdfFiles } = await import('../server/services/legalDocs.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { shopTextformNoetig } = await import('../server/services/shop/schalter.js')
const app = await startTestApp({ '/api/team': teamRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
const MODUS = { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] }
after(() => {
config.shop.mode = MODUS.mode
config.shop.pilotEmails = MODUS.pilotEmails
setLegalDirForTests(null)
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' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const shopMit = (mode, pilotEmails = []) => {
config.shop.mode = mode
config.shop.pilotEmails = pilotEmails
}
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const berlinTag = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms)
const tagMitVersatz = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10)
const ttmmjjjj = (tag) => tag.split('-').reverse().join('.')
const consents = (projectId) => [...aw.docs('featureConsents').values()].filter((c) => c.projectId === projectId)
const k16An = (email) => mails.sent.filter((m) => m.subject === 'Dein Webshop ist bereit' && m.to.some((t) => t.address === email))
let nr = 0
/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar. */
function legeKunde({ email } = {}) {
nr += 1
const customerId = `c_shoptf_${nr}`
const projectId = `p_shoptf_${nr}`
aw.seed('customers', { $id: customerId, email: email ?? `shoptf-${nr}@example.invalid`, name: `Kunde Shop ${nr}`, status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Shop-Projekt ${nr}` })
return { customerId, projectId, email: aw.docs('customers').get(customerId).email }
}
const aktivieren = (k, body = {}, opts = STAFF) => team(k.customerId, 'shop', { projectId: k.projectId, action: 'activate', ...body }, opts)
const feld = (over = {}) => ({ textformDate: berlinTag(), textformChannel: 'chat', ...over })
test('shopTextformNoetig: nur im Modus on und nur außerhalb der Pilotliste; „*“ und fehlende Adresse befreien nicht', (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('off', ['a@example.invalid'])
assert.equal(shopTextformNoetig('jemand@example.com'), false)
shopMit('pilot', [])
assert.equal(shopTextformNoetig('jemand@example.com'), false)
shopMit('on', ['kenso.gri@gmail.com'])
assert.equal(shopTextformNoetig('jemand@example.com'), true)
assert.equal(shopTextformNoetig(' KENSO.gri@gmail.com '), false, 'Pilotadresse, Schreibweise egal')
assert.equal(shopTextformNoetig('kenso.gri@gmail.com.evil.de'), true)
shopMit('on', ['*'])
assert.equal(shopTextformNoetig('jemand@example.com'), true, 'der Stern zählt in on nicht als Pilotadresse')
assert.equal(shopTextformNoetig('*'), true)
for (const email of [undefined, null, '', ' ']) assert.equal(shopTextformNoetig(email), true, `ohne Adresse: ${JSON.stringify(email)}`)
})
test('shop textform: ohne Angaben 400, mit gültigen Angaben 200; Nachweis mit Fassung, Datum, Weg, Erklärungen, Mitarbeiter; K16 mit LB und AVV als PDF', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('on', ['pilot@example.invalid'])
const k = legeKunde()
const v = config.shop.fassung
const mailsVorher = mails.sent.length
const ohne = await aktivieren(k)
assert.equal(ohne.status, 400)
assert.match(ohne.body.error, /Datum der Zustimmung/)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt')
assert.equal(consents(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
const nurDatum = await aktivieren(k, { textformDate: berlinTag() })
assert.equal(nurDatum.status, 400)
assert.match(nurDatum.body.error, /Weg der Zustimmung/)
const tag = berlinTag()
const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'email' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(featuresOf(ok, k.projectId).shop.state, 'active')
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop')
assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet')
assert.match(doc.adminNote, /Andrej: activate/, 'Name des Mitarbeiters in der Notiz')
assert.equal(consents(k.projectId).length, 1)
const [nachweis] = consents(k.projectId)
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'shop'])
assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung')
assert.equal(nachweis.ip, '')
assert.equal(nachweis.userAgent, '')
assert.equal(nachweis.konto, 'Mitarbeiter: Andrej')
assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt des Einschaltens')
const gelesen = leseZustimmung(nachweis)
assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v })
assert.deepEqual(gelesen.texte.map((x) => x.id), ['textform', 'unterlagen', 'verantwortung', 'stripe'])
assert.equal(
gelesen.texte[0].text,
`Der Kunde hat am ${ttmmjjjj(tag)} per E-Mail in Textform erklärt, dass er der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung ${v}) zustimmt und die folgenden Erklärungen der Bestelltexte der Fassung ${v} abgibt.`
)
// Wortlaut der Erklärungen = die Bestelltexte der Fassung (ohne Widerrufsverlangen und Pflichtinformationen)
const { featureConfig } = await import('../server/services/featureRegistry.js')
const items = featureConfig('shop').consent.items
for (const id of ['unterlagen', 'verantwortung', 'stripe']) {
assert.equal(gelesen.texte.find((x) => x.id === id).text, items.find((i) => i.id === id).text)
}
assert.ok(gelesen.texte[1].text.includes(`Fassung ${v}`))
assert.match(nachweis.textHash, /^[0-9a-f]{64}$/)
// Weg Chat heißt „Chat im Kundenportal“
const chat = legeKunde()
assert.equal((await aktivieren(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200)
assert.ok(leseZustimmung(consents(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjj(tag)} per Chat im Kundenportal in Textform`))
// K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung
const k16 = k16An(k.email)
assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / '))
assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Webshop-${v}.pdf`, `AVV-Webshop-${v}.pdf`])
for (const a of k16[0].attachments) {
assert.equal(a.contentType, 'application/pdf')
assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
}
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
})
test('shop textform: scheitert der Versand von K16, bleibt der Webshop freigeschaltet, adminNote und warnung:true sagen es', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
t.mock.method(console, 'error', () => {})
shopMit('on', [])
const gut = legeKunde()
const ok = await aktivieren(gut, feld())
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.warnung, undefined)
const k = legeKunde()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await aktivieren(k, feld())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.warnung, true)
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'shop')
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
assert.equal(consents(k.projectId).length, 1, 'der Nachweis bleibt')
assert.ok(doc.adminNote.includes('K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'), doc.adminNote)
})
test('shop textform entfällt: Modus off und pilot, Modus on mit Pilotadresse, schon aktiv, Abschalten und News brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
const mailsVorher = mails.sent.length
for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) {
shopMit(mode, pilot)
const k = legeKunde()
const res = await aktivieren(k)
assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`)
assert.equal(featuresOf(res, k.projectId).shop.state, 'active')
assert.equal(consents(k.projectId).length, 0, mode)
}
const pilotKunde = legeKunde({ email: 'Pilot-Shop@example.invalid' })
shopMit('on', ['pilot-shop@example.invalid'])
assert.equal((await aktivieren(pilotKunde)).status, 200)
assert.equal(consents(pilotKunde.projectId).length, 0)
// Modus on, andere Adresse: Pflicht; Abschalten und News bleiben frei; ein schon aktiver Webshop braucht nichts mehr
const k = legeKunde()
assert.equal((await aktivieren(k)).status, 400)
const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' })
assert.equal(news.status, 200)
const aktiv = legeKunde()
shopMit('off')
assert.equal((await aktivieren(aktiv)).status, 200)
shopMit('on', [])
assert.equal((await aktivieren(aktiv)).status, 200, 'schon aktiv: keine neue Zustimmung')
assert.equal(consents(aktiv.projectId).length, 0)
const aus = await team(aktiv.customerId, 'shop', { projectId: aktiv.projectId, action: 'disable' })
assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben')
assert.equal(featuresOf(aus, aktiv.projectId).shop.state, 'disabled')
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null)
assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Webshop ist bereit').length, 0, 'ohne Textform keine K16')
// ein bezahlter Webshop (Zustimmung des Checkouts liegt vor) braucht keine Zustimmung in Textform
const bezahlt = legeKunde()
aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_shop`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'shop', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
const res = await aktivieren(bezahlt)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, bezahlt.projectId).shop.state, 'active')
assert.equal(consents(bezahlt.projectId).length, 0)
})
test('shop textform: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
shopMit('on', [])
const k = legeKunde()
const v = config.shop.fassung
const heute = berlinTag()
const mailsVorher = mails.sent.length
for (const [body, muster] of [
[{ textformDate: tagMitVersatz(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/],
[{ textformDate: tagMitVersatz(heute, 1), textformChannel: 'chat' }, /in der Zukunft/],
[{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/],
[{ textformDate: heute }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/],
[{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/],
]) {
const res = await aktivieren(k, body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.match(res.body.error, muster)
}
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null)
assert.equal(consents(k.projectId).length, 0)
assert.equal(mails.sent.length, mailsVorher)
// die Fassung selbst als Datum gilt (nicht davor)
assert.equal((await aktivieren(k, { textformDate: v, textformChannel: 'email' })).status, 200)
})
test('shop textform: fremdes Projekt und Kundensitzung ändern nichts; nicht lesbare oder leere PDFs ergeben 503 vor jeder Änderung', async (t) => {
t.after(() => {
shopMit(MODUS.mode, MODUS.pilotEmails)
setLegalDirForTests(null)
})
shopMit('on', [])
const k = legeKunde()
const fremd = legeKunde()
assert.equal((await team(k.customerId, 'shop', { projectId: fremd.projectId, action: 'activate', ...feld() })).status, 403)
const alsKunde = await aktivieren(k, feld(), { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403)
assert.equal(consents(k.projectId).length + consents(fremd.projectId).length, 0)
const echt = legalPdfFiles('shop', config.shop.fassung)
const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'shoptf-pdf-'))
fs.mkdirSync(path.join(kopien, 'rechtliches'))
t.after(() => fs.rmSync(kopien, { recursive: true, force: true }))
const kopiere = () => {
for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path)))
}
setLegalDirForTests(kopien)
const mailsVorher = mails.sent.length
// gar keine Dateien
let res = await aktivieren(k, feld())
assert.equal(res.status, 503)
assert.match(res.body.error, /Rechtsunterlagen/)
assert.match(res.body.error, /Webshop/)
for (const [doc, inhalt] of [['lb', ''], ['avv', '<html>kein PDF</html>']]) {
kopiere()
fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt)
res = await aktivieren(k, feld())
assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`)
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'shop'), null, 'nichts angelegt')
assert.equal(consents(k.projectId).length, 0, 'kein Nachweis')
assert.equal(mails.sent.length, mailsVorher)
}
kopiere()
res = await aktivieren(k, feld())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(consents(k.projectId).length, 1)
assert.equal(k16An(k.email).length, 1)
})
test('shop textform: adminActivate verweigert Textform für andere Funktionen (news), der Terminplaner-Nachweis bleibt wie bisher', async () => {
const k = legeKunde()
await assert.rejects(
sf.adminActivate(k.customerId, k.projectId, 'news', { by: 'Andrej', adminNote: 'x', textform: { date: berlinTag(), channel: 'chat' } }),
(err) => err.status === 400 && /nur für den Terminplaner, den Webshop und den Buchungskalender/.test(err.message)
)
assert.equal(sf.textformFassung('shop'), config.shop.fassung)
assert.equal(sf.textformFassung('termine'), config.termine.avvVersion)
assert.match(sf.textformNachweisText({ date: '2026-10-05', channel: 'chat' }, '2026-10-05'), /^Der Kunde hat der Leistungsbeschreibung Terminplaner/)
})
test('GET /api/team/funktionen/:kunde liefert shop { textformNoetig, avvVersion, links } je Modus; termine bleibt', async (t) => {
t.after(() => shopMit(MODUS.mode, MODUS.pilotEmails))
const k = legeKunde({ email: 'get-shoptf@example.invalid' })
const v = config.shop.fassung
const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).shop
shopMit('off')
assert.equal((await feldVon()).textformNoetig, false)
shopMit('pilot', ['*'])
assert.equal((await feldVon()).textformNoetig, false)
shopMit('on', ['get-shoptf@example.invalid'])
assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse')
shopMit('on', [])
const f = await feldVon()
assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig'])
assert.equal(f.textformNoetig, true)
assert.equal(f.avvVersion, v)
assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb'])
assert.equal(f.links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-webshop-${v}.html`)
assert.equal(f.links.avv, `${config.publicBaseUrl}/rechtliches/avv-webshop-${v}.html`)
const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF)
assert.ok(Array.isArray(ganz.projects))
assert.deepEqual(Object.keys(ganz.termine).sort(), ['avvVersion', 'links', 'textformNoetig'])
shopMit('on', ['*'])
assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).shop.textformNoetig, true)
})