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', 'kein PDF']]) { 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 und den Webshop/.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) })