import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel, Notfall-Details und gezielte Wiederholung. // websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { createFeatureTicket } = await import('../server/services/ticketAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const mails = mailSink() setMailTransportForTests(mails) after(() => setMailTransportForTests(null)) aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' }) const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen' const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? [...aw.docs('workorders').values()] const zeilen = (n, breite = 49) => Array.from({ length: n }, (_, i) => `Zeile ${String(i).padStart(4, '0')} `.padEnd(breite, 'x')).join('\n') let zaehler = 0 /** Projekt (Kunde c1) anlegen und die Funktion bestellen: liefert { projectId, sessionId }. */ async function bestelle(key, projekt = {}) { zaehler += 1 const projectId = `bt${zaehler}` const sessionId = `cs_bt${zaehler}` aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Projekt ${zaehler}`, ...projekt }) await sf.markOrdered({ customerId: 'c1', projectId, key, sessionId, consent: {} }) return { projectId, sessionId } } /** Register-Funktion tauschen und danach zurueckstellen. */ async function mitTermineDetails(fn, run) { const alt = config.features.termine.ticket.details config.features.termine.ticket.details = fn try { return await run() } finally { config.features.termine.ticket.details = alt } } /** * POSTs auf workorders mitschreiben ({ woid, topic, details, status }) und die ersten POSTs scheitern lassen: * fail = Liste von { status, message } (der Nachbau liefert den Fehler) oder 'netz' (fetch wirft, kein Status). */ async function mitPosts(fail, run) { const original = globalThis.fetch const posts = [] const queue = [...fail] globalThis.fetch = async (input, init = {}) => { const url = new URL(String(input?.url ?? input)) if ((init.method || 'GET') === 'POST' && url.pathname.endsWith('/collections/workorders/documents')) { const { data } = JSON.parse(init.body) const eintrag = { woid: data.woid, topic: data.topic, details: data.details, status: null } posts.push(eintrag) const next = queue.shift() if (next === 'netz') { eintrag.status = 'netz' throw new TypeError('fetch failed') } if (next) aw.failNext({ collection: 'workorders', method: 'POST', status: next.status, message: next.message }) const res = await original(input, init) eintrag.status = res.status return res } return original(input, init) } try { return await run(posts) } finally { globalThis.fetch = original } } // ----------------------------------------------------------------------------------- kuerze --- test('kuerze: kurze Texte bleiben unveraendert, lange werden hart gekuerzt', () => { assert.equal(reg.kuerze('abc', 5), 'abc') assert.equal(reg.kuerze('abcde', 5, { endzeile: '…' }), 'abcde') assert.equal(reg.kuerze('a'.repeat(10), 5), 'aaaaa') assert.equal(reg.kuerze('abcdefghij', 6, { endzeile: '…' }), 'abcde…') assert.equal(reg.kuerze(null, 5), '') }) test('kuerze: nie ein halbes Zeichenpaar', () => { const kurz = reg.kuerze('😀'.repeat(10), 5) assert.equal(kurz, '😀😀') assert.doesNotMatch(kurz, HALBES_PAAR) const mitEnde = reg.kuerze('😀'.repeat(10), 6, { endzeile: '…' }) assert.equal(mitEnde, '😀😀…') assert.doesNotMatch(mitEnde, HALBES_PAAR) for (let max = 1; max < 12; max += 1) { const text = reg.kuerze('a😀b😀c😀d😀e', max, { endzeile: max > 3 ? '…' : '' }) assert.ok(text.length <= max, `max ${max}: ${text.length}`) assert.doesNotMatch(text, HALBES_PAAR, `max ${max}`) } }) test('kuerze: anZeile schneidet an der letzten Zeilengrenze und haengt die Endzeile in eine eigene Zeile', () => { assert.equal(reg.kuerze('eins\nzwei\ndrei\nvier', 16, { anZeile: true, endzeile: 'ENDE' }), 'eins\nzwei\nENDE') // die Zeile endet genau am Budget assert.equal(reg.kuerze('aaaa\nbbbb\ncccc\ndddd', 14, { anZeile: true, endzeile: 'ENDE' }), 'aaaa\nbbbb\nENDE') // keine Zeilengrenze im Budget: harter Schnitt (surrogatsicher) const hart = reg.kuerze('😀'.repeat(30), 21, { anZeile: true, endzeile: 'ENDE' }) assert.equal(hart, '😀'.repeat(8) + '\nENDE') assert.ok(hart.length <= 21) assert.doesNotMatch(hart, HALBES_PAAR) }) // ----------------------------------------------------------------------------- anleitungsLink --- test('anleitungsLink: WOMS-Kunde, Reiter funktionen, Projekt kodiert', () => { assert.equal(reg.anleitungsLink('c1', 'p1'), `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p1`) assert.equal(reg.anleitungsLink('c1', 'p 1/ä'), `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${encodeURIComponent('p 1/ä')}`) }) // ------------------------------------------------------------------- minimaleTicketDetails --- test('minimaleTicketDetails: Kopfzeilen und Verweis', () => { const ctx = { feature: reg.featureConfig('termine'), doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' }, project: { projectName: 'Salon', liveDomain: 'salon.de', previewUrl: 'https://p.example', subdomain: 'salon' }, customer: { name: 'Frau Muster' }, } const link = `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p9` assert.equal( reg.minimaleTicketDetails(ctx), ['Funktion: termine', 'Projekt: Salon', 'Domain: salon.de', 'Kunde: Frau Muster', 'siteKey: abcdefghijklmnop', `Verweis: ${link}`].join('\n') ) assert.match(reg.minimaleTicketDetails({ ...ctx, project: { subdomain: 's', previewUrl: 'https://p.example' } }), /^Projekt: s\nDomain: https:\/\/p\.example\n/m) // ohne Kunde und ohne Domain bleibt die Zeile bestehen const ohne = reg.minimaleTicketDetails({ ...ctx, customer: null, project: { projectName: 'X' } }) assert.match(ohne, /^Domain: $/m) assert.match(ohne, /^Kunde: c1$/m) }) // ------------------------------------------------------------------------------ ticketTexte --- test('ticketTexte: Register-Texte, Deckel und Rueckfall bei unbrauchbaren Details', async () => { const feature = reg.featureConfig('termine') const doc = { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' } const ctx = { feature, doc, project: { projectName: 'Salon' }, customer: { name: 'Frau Muster' } } const minimal = reg.minimaleTicketDetails(ctx) const normal = await mitTermineDetails(async () => 'Anleitung kurz', () => reg.ticketTexte(ctx)) assert.deepEqual(normal, { topic: 'Terminplaner einbauen – Salon', details: 'Anleitung kurz', fallbackDetails: minimal }) for (const unbrauchbar of ['', ' \n ', null, undefined, 42, {}]) { const r = await mitTermineDetails(async () => unbrauchbar, () => reg.ticketTexte(ctx)) assert.equal(r.details, minimal, `Wert ${JSON.stringify(unbrauchbar)}`) } const wirft = await mitTermineDetails(async () => { throw new Error('kaputt') }, () => reg.ticketTexte(ctx)) assert.equal(wirft.details, minimal) const lang = await mitTermineDetails(async () => zeilen(400), () => reg.ticketTexte(ctx)) assert.ok(lang.details.length <= reg.TICKET_DETAILS_MAX) assert.ok(lang.details.endsWith(`\n${GEKUERZT}`)) const langerName = await reg.ticketTexte({ ...ctx, project: { projectName: 'N'.repeat(255) } }) assert.ok(langerName.topic.length <= reg.TICKET_TOPIC_MAX) assert.ok(langerName.topic.startsWith('Terminplaner einbauen – NNN')) }) test('Konstanten des Deckels', () => { assert.equal(reg.TICKET_TOPIC_MAX, 128) assert.equal(reg.TICKET_DETAILS_MAX, 7000) assert.equal(reg.LANGE_DETAILS, 1000) }) // ------------------------------------------------------------------------------ News byte-gleich --- test('News-Ticket byte-gleich', async () => { const { projectId, sessionId } = await bestelle('news', { projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) const { doc, created } = await sf.markPaid({ sessionId }) assert.equal(created, true) const ticket = aw.docs('workorders').get(doc.ticketId) assert.equal(ticket.topic, 'News-Pinnwand einbauen – Bäckerei') assert.equal( ticket.details, [ 'Funktion: news', 'Projekt: Bäckerei', 'Domain: baeckerei.de', `siteKey: ${doc.siteKey}`, `Einbau:
+ https://project.webklar.com/embed/news.js`, 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', ].join('\n') ) assert.equal(doc.projectId, projectId) }) // ------------------------------------------------------------------------ Termine aus dem Register --- test('Termine-Ticket aus dem Register', async () => { const { projectId, sessionId } = await bestelle('termine', { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.de' }) const { doc, created } = await sf.markPaid({ sessionId }) assert.equal(created, true) assert.equal(doc.state, 'setup') const ticket = aw.docs('workorders').get(doc.ticketId) assert.ok(ticket.topic.startsWith('Terminplaner einbauen – ')) assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie') assert.ok(ticket.details.includes(doc.siteKey)) assert.ok(ticket.details.includes(`?tab=funktionen&anleitung=${projectId}`)) assert.ok(ticket.details.includes('Kunde: Kunde Eins')) assert.ok(ticket.details.includes('Domain: haarlinie.de')) assert.match(ticket.details, /Einrichtung bezahlt \(149 €\), Zeit nicht auf das Stundenkontingent anrechnen\.$/) assert.equal(ticket.customerId, 'c1') }) test('Stub termineTicketDetails: Minimal-Details plus Hinweis zum Stundenkontingent', async () => { const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js') const ctx = { feature: reg.featureConfig('termine'), doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' }, project: { projectName: 'Salon' }, customer: null, } assert.equal( await termineTicketDetails(ctx), `${reg.minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.` ) }) // ----------------------------------------------------------------------------------- Deckel --- test('Deckel: Projektname mit 255 Zeichen', async () => { const { sessionId } = await bestelle('termine', { projectName: 'P'.repeat(255) }) const { doc } = await sf.markPaid({ sessionId }) const ticket = aw.docs('workorders').get(doc.ticketId) assert.ok(ticket.topic.length <= 128, `topic ${ticket.topic.length}`) assert.ok(ticket.topic.startsWith('Terminplaner einbauen – PPP')) }) test('Deckel: 100 Emojis als Projektname ergeben eine gueltige Zeichenkette', async () => { const { sessionId } = await bestelle('termine', { projectName: '😀'.repeat(100) }) const { doc } = await sf.markPaid({ sessionId }) const ticket = aw.docs('workorders').get(doc.ticketId) assert.ok(ticket.topic.length <= 128) assert.doesNotMatch(ticket.topic, HALBES_PAAR) assert.ok(ticket.topic.includes('😀')) assert.doesNotMatch(ticket.details, HALBES_PAAR) assert.equal(JSON.parse(JSON.stringify(ticket.topic)), ticket.topic) }) test('Deckel: 20000 Zeichen Details werden an einer Zeilengrenze mit Schlusszeile gekuerzt', async () => { const original = zeilen(400) assert.ok(original.length > 19000) const { sessionId } = await bestelle('termine') const { doc } = await mitTermineDetails(async () => original, () => sf.markPaid({ sessionId })) const ticket = aw.docs('workorders').get(doc.ticketId) assert.ok(ticket.details.length <= 7000, `details ${ticket.details.length}`) assert.ok(ticket.details.length > 6800) assert.ok(ticket.details.endsWith(`\n${GEKUERZT}`)) const rumpf = ticket.details.slice(0, -(GEKUERZT.length + 1)) assert.ok(original.startsWith(`${rumpf}\n`), 'Schnitt an einer Zeilengrenze') assert.equal(ticket.details.split(GEKUERZT).length, 2, 'genau eine Schlusszeile') }) test('Deckel: genau 7000 Zeichen bleiben unveraendert', async () => { const genau = zeilen(201, 34).slice(0, 7000) assert.equal(genau.length, 7000) const { sessionId } = await bestelle('termine') const { doc } = await mitTermineDetails(async () => genau, () => sf.markPaid({ sessionId })) assert.equal(aw.docs('workorders').get(doc.ticketId).details, genau) }) // ----------------------------------------------------------------------------------- Notfall --- test('Notfall: wirft die Details-Funktion, entsteht das Ticket mit den Minimal-Details', async () => { const { projectId, sessionId } = await bestelle('termine', { projectName: 'Notfall', liveDomain: 'notfall.de' }) const { doc, created } = await mitTermineDetails(async () => { throw new Error('Anleitung nicht berechenbar') }, () => sf.markPaid({ sessionId })) assert.equal(created, true) assert.equal(doc.state, 'setup') assert.ok(doc.ticketId) const ticket = aw.docs('workorders').get(doc.ticketId) assert.equal( ticket.details, [ 'Funktion: termine', 'Projekt: Notfall', 'Domain: notfall.de', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${projectId}`, ].join('\n') ) }) test('Notfall: leere oder keine Zeichenkette gilt wie ein Fehler', async () => { for (const unbrauchbar of [' ', null, 7]) { const { sessionId } = await bestelle('termine') const { doc } = await mitTermineDetails(async () => unbrauchbar, () => sf.markPaid({ sessionId })) const ticket = aw.docs('workorders').get(doc.ticketId) assert.match(ticket.details, /^Funktion: termine\n/) assert.match(ticket.details, /\nKunde: Kunde Eins\n/) assert.match(ticket.details, /\nVerweis: .*\?tab=funktionen&anleitung=bt\d+$/) assert.doesNotMatch(ticket.details, /Einrichtung bezahlt/) } }) // ---------------------------------------------------------------- Wiederholung nur bei langen Details --- test('Wiederholung: lange Details und ein 400 => zweiter Versuch mit den Minimal-Details, gleiche woid', async () => { const { projectId, sessionId } = await bestelle('termine', { projectName: 'Lang' }) const vorher = workorders().length const lang = zeilen(100) // 5000 Zeichen const { doc, created } = await mitTermineDetails(async () => lang, () => mitPosts([{ status: 400, message: 'zu lang' }], async (posts) => { const r = await sf.markPaid({ sessionId }) assert.equal(posts.length, 2) assert.equal(posts[0].status, 400) assert.equal(posts[1].status, 201) assert.equal(posts[0].woid, posts[1].woid) assert.equal(posts[0].details, lang) assert.equal(posts[1].details.split('\n').at(0), 'Funktion: termine') return r }) ) assert.equal(created, true) assert.equal(workorders().length, vorher + 1, 'genau ein Dokument') const ticket = aw.docs('workorders').get(doc.ticketId) assert.ok(ticket.details.includes(`anleitung=${projectId}`)) assert.ok(ticket.details.includes(doc.siteKey)) assert.doesNotMatch(ticket.details, /Zeile 0001/, 'ohne Snippet') assert.doesNotMatch(ticket.details, /Einrichtung bezahlt/) }) test('Wiederholung: zweimal 400 => der erste Fehler, Dokument bleibt setup, spaeter genau ein Ticket', async () => { const { sessionId } = await bestelle('termine', { projectName: 'Doppelt' }) const vorher = workorders().length const lang = zeilen(100) await mitTermineDetails(async () => lang, async () => { await mitPosts([{ status: 400, message: 'erster Fehler' }, { status: 400, message: 'zweiter Fehler' }], async (posts) => { await assert.rejects(sf.markPaid({ sessionId }), (err) => err.status === 400 && err.message === 'erster Fehler') assert.equal(posts.length, 2) }) assert.equal(workorders().length, vorher) const stuck = (await sf.listCustomerFeatureDocs('c1')).find((d) => d.stripeSessionId === sessionId) assert.equal(stuck.state, 'setup') assert.ok(!stuck.ticketId) // Stripe wiederholt: die Einrichtung wird fortgesetzt, genau ein Ticket await mitPosts([], async (posts) => { const { doc, created } = await sf.markPaid({ sessionId }) assert.equal(created, true) assert.ok(doc.ticketId) assert.equal(posts.length, 1) }) assert.equal(workorders().length, vorher + 1) assert.equal((await sf.markPaid({ sessionId })).created, false) assert.equal(workorders().length, vorher + 1) }) }) test('Wiederholung: 503 bei langen Details wirft ohne Wiederholung (kein Doppel-Ticket)', async () => { const { sessionId } = await bestelle('termine', { projectName: 'Fuenf Null Drei' }) const vorher = workorders().length await mitTermineDetails(async () => zeilen(100), () => mitPosts([{ status: 503, message: 'nicht erreichbar' }], async (posts) => { await assert.rejects(sf.markPaid({ sessionId }), (err) => err.message === 'nicht erreichbar') assert.equal(posts.length, 1) }) ) assert.equal(workorders().length, vorher) }) test('Wiederholung: Netzfehler bei langen Details wirft ohne Wiederholung', async () => { const { sessionId } = await bestelle('termine', { projectName: 'Netz' }) const vorher = workorders().length await mitTermineDetails(async () => zeilen(100), () => mitPosts(['netz'], async (posts) => { await assert.rejects(sf.markPaid({ sessionId }), TypeError) assert.equal(posts.length, 1) }) ) assert.equal(workorders().length, vorher) }) test('Wiederholung: kurze Details (News, Standardtext) und ein 400 werfen ohne Wiederholung', async () => { const vorher = workorders().length const news = await bestelle('news', { projectName: 'Kurz News' }) await mitPosts([{ status: 400, message: 'news kaputt' }], async (posts) => { await assert.rejects(sf.markPaid({ sessionId: news.sessionId }), (err) => err.status === 400 && err.message === 'news kaputt') assert.equal(posts.length, 1) }) const termine = await bestelle('termine', { projectName: 'Kurz Termine' }) await mitTermineDetails(async () => 'nur eine kurze Zeile', () => mitPosts([{ status: 400, message: 'termine kaputt' }], async (posts) => { await assert.rejects(sf.markPaid({ sessionId: termine.sessionId }), (err) => err.message === 'termine kaputt') assert.equal(posts.length, 1) }) ) assert.equal(workorders().length, vorher) // genau 1000 Zeichen zaehlen noch als kurz const grenze = await bestelle('termine', { projectName: 'Grenze' }) await mitTermineDetails(async () => 'g'.repeat(1000), () => mitPosts([{ status: 400, message: 'grenze' }], async (posts) => { await assert.rejects(sf.markPaid({ sessionId: grenze.sessionId }), (err) => err.message === 'grenze') assert.equal(posts.length, 1) }) ) }) // ------------------------------------------------------------------ Termine ueber den Webhook --- test('Termine ueber markPaid: lange Kurzfassung + 400 => Ticket mit Minimal-Details, K15/T16 gehen raus', async () => { const { projectId, sessionId } = await bestelle('termine', { projectName: 'Webhook Salon' }) const vorherTickets = workorders().length const vorherMails = mails.sent.length const event = { id: 'evt_b8', object: 'event', type: 'checkout.session.completed', data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'termine' }, }, }, } await mitTermineDetails(async () => zeilen(100), () => mitPosts([{ status: 400, message: 'zu lang' }], async (posts) => { await sf.handleFeatureCheckoutEvent(event) assert.equal(posts.length, 2) }) ) assert.equal(workorders().length, vorherTickets + 1) const doc = await sf.getFeatureDoc('c1', projectId, 'termine') assert.equal(doc.state, 'setup') assert.ok(doc.ticketId) assert.match(aw.docs('workorders').get(doc.ticketId).details, /^Funktion: termine\n/) assert.equal(mails.sent.length - vorherMails, 2, 'K15 an den Kunden und T16 an das Team') // Stripe wiederholt: nichts doppelt await sf.handleFeatureCheckoutEvent(event) assert.equal(workorders().length, vorherTickets + 1) assert.equal(mails.sent.length - vorherMails, 2) }) // ---------------------------------------------------------------------------- Analyse unberuehrt --- test('Analyse unberuehrt: eigener Text ohne fallbackDetails, ein Aufruf, Deckel nur bei Ueberlaenge', async () => { const project = { $id: 'pa_analyse', projectName: 'Analyse Projekt', subdomain: 'ap' } await mitPosts([], async (posts) => { const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'analyse', siteKey: 'abcdefghijklmnop', topic: 'Analyse einbauen – Analyse Projekt', details: 'Zeile eins\nZeile zwei', }) assert.equal(posts.length, 1) const ticket = aw.docs('workorders').get(ticketId) assert.equal(ticket.topic, 'Analyse einbauen – Analyse Projekt') assert.equal(ticket.details, 'Zeile eins\nZeile zwei') }) // Ueberlaenge wird gedeckelt const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'analyse', siteKey: 'abcdefghijklmnop', topic: 'T'.repeat(200), details: zeilen(400), }) const lang = aw.docs('workorders').get(ticketId) assert.ok(lang.topic.length <= 128) assert.ok(lang.details.length <= 7000) assert.ok(lang.details.endsWith(`\n${GEKUERZT}`)) // lange Details, 400 und kein fallbackDetails: keine Wiederholung await mitPosts([{ status: 400, message: 'abgelehnt' }], async (posts) => { await assert.rejects( createFeatureTicket({ customerId: 'c1', project, featureKey: 'analyse', siteKey: 'abcdefghijklmnop', topic: 'Analyse', details: zeilen(100) }), (err) => err.message === 'abgelehnt' ) assert.equal(posts.length, 1) }) }) test('Defaults: ohne topic und details der heutige News-Text', async () => { const project = { $id: 'pa_def', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' } const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: 'abcdefghijklmnop' }) const ticket = aw.docs('workorders').get(ticketId) assert.equal(ticket.topic, 'News-Pinnwand einbauen – Bäckerei') assert.equal( ticket.details, [ 'Funktion: news', 'Projekt: Bäckerei', 'Domain: baeckerei.de', 'siteKey: abcdefghijklmnop', 'Einbau: + https://project.webklar.com/embed/news.js', 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', ].join('\n') ) // nur topic gesetzt: Details bleiben der Standard const { ticketId: zweite } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: 'abcdefghijklmnop', topic: 'Mein Thema' }) assert.equal(aw.docs('workorders').get(zweite).topic, 'Mein Thema') assert.equal(aw.docs('workorders').get(zweite).details, ticket.details) })