From 9166e023c571e8d198f5ed14e18f4d777cc39992 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:22:24 +0000 Subject: [PATCH] Kundenansicht neu P7: bekannte Fehlertexte der Dienste in der neuen Ansicht in du Co-Authored-By: Claude Sonnet 5.5 --- server/routes/abo.js | 2 + server/routes/takeoverCredits.js | 2 + server/services/duFehler.js | 65 ++++++++++++++++ test/knDuFehler.test.js | 127 +++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 server/services/duFehler.js create mode 100644 test/knDuFehler.test.js diff --git a/server/routes/abo.js b/server/routes/abo.js index 8fc66e4..58f61f7 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -29,6 +29,7 @@ import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { FEHLER_BEDINGUNGEN, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG, WECHSEL_GESPERRT, bestelltexteFuerClient, bestellZeile, nachweisTexte } from '../services/bestelltexte.js' import { kundenartWirksam } from '../services/kundenart.js' import { fassungDerAnfrage, istNeueFassung, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js' +import { duFehlerAntworten } from '../services/duFehler.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -47,6 +48,7 @@ import { } from '../services/stripeBilling.js' const router = Router() +router.use(duFehlerAntworten) // neue Ansicht: bekannte Fehlertexte in du (duFehler.js), sonst unverändert const fmtDate = (iso) => iso diff --git a/server/routes/takeoverCredits.js b/server/routes/takeoverCredits.js index 5170052..30c7002 100644 --- a/server/routes/takeoverCredits.js +++ b/server/routes/takeoverCredits.js @@ -7,6 +7,7 @@ import { withLocks } from '../services/keyedLock.js' import { nachweisTexte } from '../services/bestelltexte.js' import { kundenartWirksam } from '../services/kundenart.js' import { fassungDerAnfrage, speichereNachweisFuerRoute } from '../services/vertragsNachweis.js' +import { duFehlerAntworten } from '../services/duFehler.js' import { answerQuestion, applyTakeoverCredit, @@ -55,6 +56,7 @@ function sendError(res, err, fallback) { // --------------------------------------------------------------------------- const router = Router() +router.use(duFehlerAntworten) // neue Ansicht: bekannte Fehlertexte in du (duFehler.js), sonst unverändert router.use(requireSession) router.get('/', async (req, res) => { diff --git a/server/services/duFehler.js b/server/services/duFehler.js new file mode 100644 index 0000000..d31b4c1 --- /dev/null +++ b/server/services/duFehler.js @@ -0,0 +1,65 @@ +import { neueAnsicht } from './kundenansicht/schalter.js' + +/** + * Fehlertexte der Dienste in der neuen Ansicht in „du“ (Kundenansicht neu, P7, N37). + * + * planSwitch.js, mailStorage.js, stripeBilling.js und die Antworten der Abo-Routen werfen ihre Texte im Quelltext in der Sie-Form; die + * alte Ansicht zeigt sie unveraendert. Fuer Sitzungen mit neuer Ansicht uebersetzt duFehlerAntworten bekannte Saetze vor der Antwort. + * Unbekannte Texte bleiben unveraendert; test/knDuFehler.test.js sucht in den drei Diensten nach Sie-Saetzen ohne Uebersetzung. + * Bewusst nicht uebersetzt (Mail-/Textrunde, N37): Pruefsaetze in takeoverCredits.js ausser den zwei Saetzen unten und der custom_text + * der Stripe-Zahlungsseite. + */ + +export const DU_FEHLER = Object.freeze({ + // planSwitch.js + 'Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.': 'Der Wechsel ist erst nach Beginn deines Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.', + 'Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.': 'Dein Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.', + 'Die Vorschau fehlt. Bitte laden Sie die Seite neu.': 'Die Vorschau fehlt. Bitte lade die Seite neu.', + 'Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.': 'Die Vorschau ist ungültig. Bitte lade die Seite neu.', + 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.': 'Bitte stimme den Vertragsbedingungen zu, um zu wechseln.', + 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.': 'Bitte bestätige, dass wir sofort mit dem Wartungs-Abo beginnen sollen.', + 'Ihr Wechsel wird gerade verarbeitet.': 'Dein Wechsel wird gerade verarbeitet.', + 'Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.': 'Dein Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.', + // mailStorage.js + 'Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.': 'Dein Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.', + 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.': 'Bitte stimme den Regeln zum Zusatzspeicher zu.', + 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.': 'Dein Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.', + 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.': 'Dein Wechsel wartet noch auf die Zahlung. Bitte versuche es danach erneut.', + 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.': 'Eine Änderung läuft bereits. Bitte warte einen Moment und lade die Seite neu.', + 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.': 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuche es später erneut.', + // stripeBilling.js + 'Sie haben bereits ein laufendes Abo abgeschlossen.': 'Du hast bereits ein laufendes Abo abgeschlossen.', + // abo.js + 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).': 'Bitte wähle ein Abo (Hosting-Abo oder Wartungs-Abo).', + 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.': 'Bitte stimme den Vertragsbedingungen zu, um zu bestellen.', + 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.': 'Bitte wähle, wann wir mit der Leistung beginnen sollen.', + 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.': 'Dein Angebot gilt für ein anderes Abo. Bitte lade die Seite neu.', + 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.': 'Dein Angebot hat sich geändert. Bitte lade die Seite neu.', + 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.': 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte lade die Seite neu.', + 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.': 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.', + // takeoverCredits.js (die zwei Sätze, die die Seite nicht vorab prüft) + 'Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft.': 'Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft.', + 'Sie haben bereits mehrere offene Anträge. Bitte warten Sie unsere Antwort ab.': 'Du hast bereits mehrere offene Anträge. Bitte warte auf unsere Antwort.', +}) + +const PAKETZAHL = /^Bitte wählen Sie zwischen 0 und (\d+) Paketen Zusatzspeicher\.$/ + +/** Bekannter Satz → du-Satz; das Muster der Paketzahl dazu; alles andere (auch kein Text) unverändert. */ +export function duText(message) { + if (typeof message !== 'string') return message + if (Object.hasOwn(DU_FEHLER, message)) return DU_FEHLER[message] + const treffer = PAKETZAHL.exec(message) + return treffer ? `Bitte wähle zwischen 0 und ${treffer[1]} Paketen Zusatzspeicher.` : message +} + +/** + * Express-Middleware: gilt die neue Ansicht (neueAnsicht(req)), ersetzt sie in res.json den Text body.error durch duText(body.error); + * sonst reicht sie die Anfrage unveraendert durch. Andere Antworten (kein Objekt, kein Text) laufen unveraendert. + */ +export function duFehlerAntworten(req, res, next) { + if (neueAnsicht(req)) { + const json = res.json.bind(res) + res.json = (body) => json(body !== null && typeof body === 'object' && typeof body.error === 'string' ? { ...body, error: duText(body.error) } : body) + } + next() +} diff --git a/test/knDuFehler.test.js b/test/knDuFehler.test.js new file mode 100644 index 0000000..dc2c096 --- /dev/null +++ b/test/knDuFehler.test.js @@ -0,0 +1,127 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Bekannte Fehlertexte der Dienste: in der neuen Ansicht „du“, überall sonst unverändert („Sie“) +const aw = await startAppwriteMock({ + schema: { + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + aboOffers: schemaKeys('aboOffers'), + hourCredits: schemaKeys('hourCredits'), + featureConsents: schemaKeys('featureConsents'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' }) +const { startTestApp, post } = await import('./support/testApp.js') +const { customerCookie, seedFixtures } = await import('./support/fixtures.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { default: takeoverRoutes } = await import('../server/routes/takeoverCredits.js') +const { DU_FEHLER, duText, duFehlerAntworten } = await import('../server/services/duFehler.js') + +const app = await startTestApp({ '/api/abo': aboRoutes, '/api/abo/takeover-credits': takeoverRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedFixtures(aw) + +const SIE = /\b(Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b/ +const PAARE = [ // [Sie-Satz im Quelltext, du-Satz in der neuen Ansicht] + // planSwitch.js + ['Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.', 'Der Wechsel ist erst nach Beginn deines Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.'], + ['Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.', 'Dein Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.'], + ['Die Vorschau fehlt. Bitte laden Sie die Seite neu.', 'Die Vorschau fehlt. Bitte lade die Seite neu.'], + ['Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.', 'Die Vorschau ist ungültig. Bitte lade die Seite neu.'], + ['Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.', 'Bitte stimme den Vertragsbedingungen zu, um zu wechseln.'], + ['Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.', 'Bitte bestätige, dass wir sofort mit dem Wartungs-Abo beginnen sollen.'], + ['Ihr Wechsel wird gerade verarbeitet.', 'Dein Wechsel wird gerade verarbeitet.'], + ['Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.', 'Dein Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.'], + // mailStorage.js + ['Ihr Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.', 'Dein Speicherstand hat sich geändert. Bitte Seite neu laden und der Bestellung zustimmen.'], + ['Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.', 'Bitte stimme den Regeln zum Zusatzspeicher zu.'], + ['Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.', 'Dein Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.'], + ['Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.', 'Dein Wechsel wartet noch auf die Zahlung. Bitte versuche es danach erneut.'], + ['Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.', 'Eine Änderung läuft bereits. Bitte warte einen Moment und lade die Seite neu.'], + ['Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.', 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuche es später erneut.'], + // stripeBilling.js + ['Sie haben bereits ein laufendes Abo abgeschlossen.', 'Du hast bereits ein laufendes Abo abgeschlossen.'], + // abo.js + ['Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).', 'Bitte wähle ein Abo (Hosting-Abo oder Wartungs-Abo).'], + ['Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.', 'Bitte stimme den Vertragsbedingungen zu, um zu bestellen.'], + ['Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.', 'Bitte wähle, wann wir mit der Leistung beginnen sollen.'], + ['Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.', 'Dein Angebot gilt für ein anderes Abo. Bitte lade die Seite neu.'], + ['Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.', 'Dein Angebot hat sich geändert. Bitte lade die Seite neu.'], + ['Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte laden Sie die Seite neu.', 'Der Leistungsbeginn hat sich durch den Datumswechsel verschoben. Bitte lade die Seite neu.'], + ['Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.', 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.'], + // takeoverCredits.js (die zwei Sätze, die die Seite nicht vorab prüft) + ['Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft.', 'Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft.'], + ['Sie haben bereits mehrere offene Anträge. Bitte warten Sie unsere Antwort ab.', 'Du hast bereits mehrere offene Anträge. Bitte warte auf unsere Antwort.'], +] + +test('duText: jeder bekannte Satz wird du, Unbekanntes bleibt', () => { + for (const [sie, du] of PAARE) { + assert.equal(duText(sie), du) + assert.doesNotMatch(du, SIE, du) + } + assert.equal(duText('Bitte wählen Sie zwischen 0 und 10 Paketen Zusatzspeicher.'), 'Bitte wähle zwischen 0 und 10 Paketen Zusatzspeicher.') + assert.equal(duText('Bitte wählen Sie zwischen 0 und 3 Paketen Zusatzspeicher.'), 'Bitte wähle zwischen 0 und 3 Paketen Zusatzspeicher.') + for (const x of ['Kein Abo vorhanden.', 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.', 'Bitte wählen Sie zwischen 0 und x Paketen Zusatzspeicher.', '', undefined, null, 7]) assert.equal(duText(x), x) + assert.ok(Object.isFrozen(DU_FEHLER)) + assert.deepEqual(Object.keys(DU_FEHLER).sort(), PAARE.map(([sie]) => sie).sort(), 'die Tabelle enthält genau die geprüften Paare') +}) + +test('Quelltext: jeder Sie-Satz der drei Dienste hat eine Übersetzung', () => { + // Grund in der Mail K14, keine Antwort der API + const AUSNAHMEN = ['Die Zahlung der Wechselrechnung wurde nicht rechtzeitig abgeschlossen, zum Beispiel weil die von Ihrer Bank verlangte Bestätigung nicht erfolgt ist.'] + for (const datei of ['planSwitch.js', 'mailStorage.js', 'stripeBilling.js']) { + const q = readFileSync(new URL(`../server/services/${datei}`, import.meta.url), 'utf8').replace(/^\s*(?:\/\/|\*|\/\*).*$/gm, '') + for (const [, satz] of q.matchAll(/'([^'\n]*\b(?:Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b[^'\n]*)'/g)) { + if (!AUSNAHMEN.includes(satz)) assert.notEqual(duText(satz), satz, `${datei}: ${satz}`) + } + // Sätze in Schablonen (Backticks) mit Paketzahl + for (const [, satz] of q.matchAll(/`([^`\n]*\b(?:Sie|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres|Ihnen)\b[^`\n]*)`/g)) { + const bekannt = satz.replace(/\$\{[^}]*\}/g, '7') + if (/Bestellung haben Sie im Kundenportal/.test(satz)) continue // custom_text der Stripe-Zahlungsseite: Mail-/Textrunde (N37) + assert.notEqual(duText(bekannt), bekannt, `${datei}: ${satz}`) + } + } +}) + +test('Route: Pilot-Kunde bekommt du, Nicht-Pilot und Gast bleiben bei Sie', async () => { + const body = { productKey: 'gibtsnicht', acceptTerms: true } + const pilot = await post('/api/abo/checkout', body, { cookie: customerCookie('c1') }) + const alt = await post('/api/abo/checkout', body, { cookie: customerCookie('c2') }) + assert.deepEqual([pilot.status, pilot.body.error, alt.status, alt.body.error], [400, 'Bitte wähle ein Abo (Hosting-Abo oder Wartungs-Abo).', 400, 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).']) + assert.equal((await post('/api/abo/checkout', body)).status, 401) // ohne Sitzung: keine Umschreibung, keine Antwort mit Text +}) + +test('Route: Fehlertexte der Dienste (Speicher) und Antworten ohne Text bleiben richtig', async () => { + const pilot = await post('/api/abo/storage/preview', { units: 99 }, { cookie: customerCookie('c1') }) + const alt = await post('/api/abo/storage/preview', { units: 99 }, { cookie: customerCookie('c2') }) + assert.equal(pilot.status, 400) + assert.equal(pilot.body.error, 'Bitte wähle zwischen 0 und 10 Paketen Zusatzspeicher.') + assert.equal(alt.body.error, 'Bitte wählen Sie zwischen 0 und 10 Paketen Zusatzspeicher.') + // Antworten ohne Text oder mit anderen Schlüsseln laufen unverändert durch + const ok = await post('/api/abo/portal', {}, { cookie: customerCookie('c1') }) + assert.ok(ok.status === 200 || typeof ok.body?.error === 'string' || ok.body?.url !== undefined) +}) + +test('Middleware: reicht Nicht-Objekte und Antworten ohne error-Text unverändert durch', () => { + const gesendet = [] + const res = { json: (b) => { gesendet.push(b); return res } } + duFehlerAntworten({ session: { customerId: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid' } }, res, () => {}) + res.json(null) + res.json({ error: 42 }) + res.json({ url: 'https://x.test/' }) + res.json({ error: 'Ihr Wechsel wird gerade verarbeitet.', code: 'x' }) + assert.deepEqual(gesendet, [null, { error: 42 }, { url: 'https://x.test/' }, { error: 'Dein Wechsel wird gerade verarbeitet.', code: 'x' }]) + // ohne neue Ansicht wird res.json nicht angefasst + const res2 = { json: (b) => b } + const vorher = res2.json + duFehlerAntworten({ session: { customerId: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid' } }, res2, () => {}) + assert.equal(res2.json, vorher) +})