From 811e054ca433d1aca90f0baa17249a10d2b1de19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:58:25 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Ticket=20nachlegen=20leh?= =?UTF-8?q?nt=20eine=20Behalten-Zahlung=20ab=20(Stillstand=20an=20feature-?= =?UTF-8?q?admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bezahlteSitzung weist Sitzungen mit metadata.zweck behalten mit 409 ab. Sonst ginge die Sitzung über onFeaturePaid in bucheBehalten, das feature-admin: nimmt, während die Team-Route sie schon hält: Stillstand und dauerhaft belegter Schlüssel. Test in test/zeitenTeamBehalten.test.js: 409 binnen Sekunden, Sperre frei, folgende Team-Aktion nicht blockiert. Co-Authored-By: Claude Opus 5.5 --- server/services/siteFeatures.js | 6 ++ test/zeitenTeamBehalten.test.js | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 test/zeitenTeamBehalten.test.js diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 22df7ba..67becc1 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -784,6 +784,12 @@ async function bezahlteSitzung(doc) { 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.') } + // Eine Behalten-Zahlung (Oeffnungszeiten live, zeiten/behalten.js) hat kein Einrichtungs-Ticket nachzulegen. Vor allem aber darf sie nie in + // onFeaturePaid landen: dessen Behalten-Zweig (bucheBehalten) nimmt feature-admin:, und die Team-Route haelt diese Sperre schon + // (withLocks ist nicht wiederbetretbar => Stillstand, der den Schluessel dauerhaft blockiert). + if (session?.metadata?.zweck === 'behalten') { + throw httpError(409, 'Zu dieser Funktion gehört nur eine Behalten-Zahlung, es gibt kein Einrichtungs-Ticket nachzulegen.') + } if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.') return session } diff --git a/test/zeitenTeamBehalten.test.js b/test/zeitenTeamBehalten.test.js new file mode 100644 index 0000000..c580a5e --- /dev/null +++ b/test/zeitenTeamBehalten.test.js @@ -0,0 +1,130 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Team-Aktion "Ticket nachlegen" auf Oeffnungszeiten live mit einer Behalten-Zahlung am Dokument (Merge mit TP2, Fix Runde 1): ticketNachlegen +// holt die bezahlte Sitzung bei Stripe und fuettert sie dem Webhook-Weg. Eine Behalten-Sitzung (metadata.zweck 'behalten') ginge dort in +// bucheBehalten, das `feature-admin:` nimmt, waehrend die Team-Route diese Sperre schon haelt (nicht wiederbetretbar => Stillstand, +// der den Schluessel dauerhaft blockiert). Sie wird darum mit 409 abgelehnt, ohne etwas zu veraendern. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + featureConsents: schemaKeys('featureConsents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { startTestApp, post } = await import('./support/testApp.js') +const { seedFixtures } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') + +const app = await startTestApp({ '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) +seedFixtures(aw) + +const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } } +const doc = () => aw.docs('portalFeatures').get('f_p1_zeiten') +const tickets = () => [...aw.docs('workorders').values()] +const ticketAktion = () => post('/api/team/funktionen/c1/zeiten', { projectId: 'p1', action: 'ticket' }, STAFF) +/** Antwortet die Anfrage nicht binnen 4 s, ist sie haengengeblieben (Stillstand statt 409). */ +const binnen4s = (promise) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve('HANG'), 4000))]) + +let sink +let rebuilds +beforeEach(async () => { + for (const col of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'workorders', 'websiteProjects']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + st.checkoutSessions.clear() + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Projekt p1', subdomain: 'sub-p1', repoFullName: 'JUSN/p1', previewMode: 'auto', previewDetected: 'static' }) + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push(p.$id) + return { queued: true } + } + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => mock.restoreAll()) + +/** Zustand, den bucheBehalten hinterlaesst: setup ohne Ticket, stripeSessionId = bezahlte Behalten-Sitzung, unlockedBy kauf. */ +function behaltenDokument(sitzung = { payment_status: 'paid' }) { + aw.seed('portalFeatures', { + $id: 'f_p1_zeiten', + customerId: 'c1', + projectId: 'p1', + featureKey: 'zeiten', + state: 'setup', + enabled: false, + siteKey: 'abcdefghijklmnop', + unlockedBy: 'kauf', + unlockedByPurchase: true, + purchaseStatus: 'paid', + stripeSessionId: 'cs_beh', + ticketId: '', + einbau: 'angestossen', + }) + st.checkoutSessions.set('cs_beh', { + id: 'cs_beh', + object: 'checkout.session', + mode: 'payment', + status: 'complete', + invoice: null, + metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', zweck: 'behalten' }, + ...sitzung, + }) +} + +test('ticket auf Zeiten mit bezahlter Behalten-Sitzung: sofort 409, nichts verändert, die Sperre feature-admin bleibt frei', async () => { + behaltenDokument() + const vorher = { ...doc() } + const res = await binnen4s(ticketAktion()) + assert.notEqual(res, 'HANG', 'die Anfrage darf nicht an feature-admin haengenbleiben') + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.match(res.body.error, /Behalten-Zahlung/) + assert.deepEqual(doc(), vorher, 'Dokument unveraendert') + assert.equal(tickets().length, 0, 'kein Ticket') + assert.deepEqual(rebuilds, []) + assert.equal(sink.sent.length, 0, 'keine Mail') + + // die Sperre ist frei: ein Probelauf und danach eine weitere Team-Aktion am selben Projekt kommen sofort durch + assert.equal(await binnen4s(withLocks(['feature-admin:p1'], async () => 'frei')), 'frei') + const aus = await binnen4s(post('/api/team/funktionen/c1/zeiten', { projectId: 'p1', action: 'disable' }, STAFF)) + assert.notEqual(aus, 'HANG', 'die folgende Team-Aktion darf nicht blockiert sein') + assert.equal(aus.status, 200, JSON.stringify(aus.body)) + assert.equal(doc().state, 'disabled') +}) + +test('ticket auf Zeiten mit noch nicht bezahlter Behalten-Sitzung: ebenfalls 409 und kein Stillstand', async () => { + behaltenDokument({ payment_status: 'unpaid', status: 'open' }) + const res = await binnen4s(ticketAktion()) + assert.notEqual(res, 'HANG') + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(await binnen4s(withLocks(['feature-admin:p1'], async () => 'frei')), 'frei') +})