Buchungskalender: origin/test zusammenführen
This commit is contained in:
commit
dc4df16cab
48 files changed
+6387
-367
No files matched your search
@@ -0,0 +1,215 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand, kundeMitKundenart, erstelltMeldung, auftragMeldung,
|
||||
} = await loadModule('src/lib/auftraegeAnsicht.js')
|
||||
|
||||
// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht
|
||||
const konten = (extra = {}) => ({
|
||||
auftraegeAktiv: false,
|
||||
kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' },
|
||||
konten: [{ projektId: 'p1', name: 'Website' }],
|
||||
ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 },
|
||||
stripeOk: true,
|
||||
...extra,
|
||||
})
|
||||
const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }
|
||||
|
||||
// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe
|
||||
function fakeApi({ projektkonten, auftraege } = {}) {
|
||||
const calls = []
|
||||
const antwort = (wert) => {
|
||||
if (wert instanceof Error) throw wert
|
||||
return wert
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) },
|
||||
listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) },
|
||||
}
|
||||
}
|
||||
|
||||
test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => {
|
||||
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') })
|
||||
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||
assert.deepEqual(api.calls, [['konten', 'k1']])
|
||||
assert.equal(result.konten.auftraegeAktiv, false)
|
||||
assert.equal(result.auftraege, null)
|
||||
assert.equal(result.auftraegeFehler, '')
|
||||
// ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {}
|
||||
const fehlt = fakeApi({ projektkonten: { konten: [] } })
|
||||
assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null)
|
||||
assert.deepEqual(fehlt.calls, [['konten', 'k1']])
|
||||
const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null }))
|
||||
assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' })
|
||||
})
|
||||
|
||||
test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => {
|
||||
const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
|
||||
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } })
|
||||
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||
assert.deepEqual(result.auftraege, liste)
|
||||
assert.equal(result.auftraegeFehler, '')
|
||||
// eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes
|
||||
const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} }))
|
||||
assert.deepEqual(leer.auftraege, [])
|
||||
})
|
||||
|
||||
test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => {
|
||||
const api = fakeApi({
|
||||
projektkonten: konten({ auftraegeAktiv: true }),
|
||||
auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }),
|
||||
})
|
||||
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||
assert.equal(result.konten.konten.length, 1)
|
||||
assert.equal(result.auftraege, null)
|
||||
assert.equal(result.auftraegeFehler, 'Interner Fehler.')
|
||||
const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') }))
|
||||
assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER)
|
||||
assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.')
|
||||
})
|
||||
|
||||
test('Projektkonten scheitern, Aufträge laden: der Abschnitt Aufträge bleibt bedienbar, der Konten-Fehler steht eigens da', async () => {
|
||||
const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
|
||||
const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
|
||||
const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: liste } })
|
||||
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||
assert.deepEqual(result, { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' })
|
||||
const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: new Error(''), auftraege: { auftraege: liste } }))
|
||||
assert.equal(ohneText.kontenFehler, KONTEN_FEHLER)
|
||||
})
|
||||
|
||||
test('Projektkonten und Aufträge scheitern: der Fehler der Projektkonten geht weiter (Schalter aus gibt 404)', async () => {
|
||||
const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
|
||||
const api = fakeApi({ projektkonten: fehler, auftraege: Object.assign(new Error('Nicht gefunden.'), { status: 404 }) })
|
||||
await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler)
|
||||
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||
})
|
||||
|
||||
test('ansichtsZustand: Projekte-Fehler mit geladenen Aufträgen – Projekte „fehler“, Aufträge „bereit“', () => {
|
||||
const liste = [{ id: 'a1' }]
|
||||
const z = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: '' })
|
||||
assert.equal(z.projekteStatus, 'fehler')
|
||||
assert.equal(z.fehler, 'Interner Fehler.')
|
||||
assert.equal(z.auftraegeAktiv, true) // der Server hat die Liste geliefert, der Schalter ist also an
|
||||
assert.equal(z.auftraegeStatus, 'bereit')
|
||||
assert.deepEqual(z.auftraege, liste)
|
||||
assert.deepEqual(z.konten, [])
|
||||
assert.equal(z.nachladeFehler, '')
|
||||
// scheitert auch ein späteres Neuladen, steht dessen Meldung im Fehlerkasten; die Aufträge bleiben stehen
|
||||
const spaeter = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: 'Netz weg' })
|
||||
assert.equal(spaeter.projekteStatus, 'fehler')
|
||||
assert.equal(spaeter.fehler, 'Netz weg')
|
||||
assert.equal(spaeter.auftraegeStatus, 'bereit')
|
||||
})
|
||||
|
||||
test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => {
|
||||
const laden = ansichtsZustand({ data: null, error: '' })
|
||||
assert.equal(laden.projekteStatus, 'laden')
|
||||
assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern
|
||||
assert.deepEqual(laden.konten, [])
|
||||
assert.equal(ansichtsZustand().projekteStatus, 'laden')
|
||||
const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER })
|
||||
assert.equal(fehler.projekteStatus, 'fehler')
|
||||
assert.equal(fehler.fehler, KONTEN_FEHLER)
|
||||
assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge
|
||||
assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.')
|
||||
})
|
||||
|
||||
test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => {
|
||||
const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' })
|
||||
assert.equal(z.projekteStatus, 'bereit')
|
||||
assert.equal(z.auftraegeAktiv, false)
|
||||
assert.equal(z.auftraegeStatus, 'aus')
|
||||
assert.equal(z.konten.length, 1)
|
||||
assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 })
|
||||
assert.equal(z.stripeOk, true)
|
||||
assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' })
|
||||
assert.equal(z.nachladeFehler, '')
|
||||
})
|
||||
|
||||
test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => {
|
||||
const liste = [{ id: 'a1' }]
|
||||
const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' })
|
||||
assert.equal(bereit.auftraegeAktiv, true)
|
||||
assert.equal(bereit.auftraegeStatus, 'bereit')
|
||||
assert.deepEqual(bereit.auftraege, liste)
|
||||
const kaputt = ansichtsZustand({
|
||||
data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' },
|
||||
error: '',
|
||||
})
|
||||
assert.equal(kaputt.projekteStatus, 'bereit')
|
||||
assert.equal(kaputt.auftraegeStatus, 'fehler')
|
||||
assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.')
|
||||
assert.deepEqual(kaputt.auftraege, [])
|
||||
})
|
||||
|
||||
test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => {
|
||||
const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' }
|
||||
const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' })
|
||||
assert.equal(z.projekteStatus, 'bereit')
|
||||
assert.equal(z.fehler, '')
|
||||
assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.')
|
||||
assert.equal(z.konten.length, 1)
|
||||
assert.equal(z.auftraegeStatus, 'bereit')
|
||||
assert.equal(z.auftraege.length, 1)
|
||||
})
|
||||
|
||||
test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => {
|
||||
for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) {
|
||||
const z = ansichtsZustand({ data, error: '' })
|
||||
assert.equal(z.projekteStatus, 'bereit')
|
||||
assert.deepEqual(z.konten, [])
|
||||
assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER)
|
||||
assert.equal(z.stripeOk, true)
|
||||
assert.equal(z.kunde, null)
|
||||
assert.equal(z.auftraegeAktiv, false)
|
||||
assert.equal(z.auftraegeStatus, 'aus')
|
||||
}
|
||||
assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false)
|
||||
})
|
||||
|
||||
test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => {
|
||||
const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' }
|
||||
assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' })
|
||||
assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert
|
||||
assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat')
|
||||
})
|
||||
|
||||
test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => {
|
||||
const doc = { $id: 'k1', kundenart: 'unternehmen' }
|
||||
for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) {
|
||||
assert.equal(kundeMitKundenart(doc, info), doc)
|
||||
}
|
||||
assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null)
|
||||
})
|
||||
|
||||
test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => {
|
||||
assert.deepEqual(
|
||||
erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }),
|
||||
{ tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' },
|
||||
)
|
||||
assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||
assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||
assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||
const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.'
|
||||
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text })
|
||||
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text })
|
||||
})
|
||||
|
||||
test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => {
|
||||
assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||
assert.deepEqual(
|
||||
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }),
|
||||
{ tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' },
|
||||
)
|
||||
assert.deepEqual(
|
||||
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }),
|
||||
{ tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' },
|
||||
)
|
||||
assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' })
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js')
|
||||
const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js')
|
||||
|
||||
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
|
||||
|
||||
// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper)
|
||||
function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) {
|
||||
const calls = []
|
||||
const request = createFinanceRequest({
|
||||
baseUrl: BASE,
|
||||
createJWT: async () => ({ jwt: 'jwt-1' }),
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization })
|
||||
return answer()
|
||||
},
|
||||
})
|
||||
return { api: createAuftraegeEndpoints(request), calls }
|
||||
}
|
||||
|
||||
// [Funktion, Argumente, Methode, Pfad ab BASE, Körper]
|
||||
const FAELLE = [
|
||||
['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined],
|
||||
['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined],
|
||||
['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }],
|
||||
['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined],
|
||||
['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined],
|
||||
['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }],
|
||||
['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }],
|
||||
['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined],
|
||||
['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined],
|
||||
['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined],
|
||||
['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined],
|
||||
['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined],
|
||||
['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }],
|
||||
]
|
||||
|
||||
test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => {
|
||||
const { api } = setup()
|
||||
assert.deepEqual(Object.keys(api).sort(), [
|
||||
'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto',
|
||||
'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag',
|
||||
])
|
||||
})
|
||||
|
||||
test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => {
|
||||
for (const [name, args, method, path, body] of FAELLE) {
|
||||
const { api, calls } = setup()
|
||||
await api[name](...args)
|
||||
assert.equal(calls.length, 1, name)
|
||||
assert.equal(calls[0].url, `${BASE}${path}`, name)
|
||||
assert.equal(calls[0].method, method, name)
|
||||
assert.equal(calls[0].auth, 'Bearer jwt-1', name)
|
||||
assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => {
|
||||
const { api, calls } = setup()
|
||||
await api.getProjektkonten('k 1&x=2')
|
||||
await api.getAuftrag('a/b?c')
|
||||
await api.storniereAuftrag('a#1')
|
||||
await api.ordneAuftragProjektZu('a b', 'p/1')
|
||||
assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [
|
||||
'/projektkonten?kunde=k%201%26x%3D2',
|
||||
'/auftraege/a%2Fb%3Fc',
|
||||
'/auftraege/a%231/stornieren',
|
||||
'/auftraege/a%20b/projekt',
|
||||
])
|
||||
assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad
|
||||
assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131')
|
||||
assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2')
|
||||
})
|
||||
|
||||
test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => {
|
||||
const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
|
||||
const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei }))
|
||||
assert.equal(await ok.api.getAuftragPdf('a1'), datei)
|
||||
assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`)
|
||||
assert.equal(ok.calls[0].method, 'GET')
|
||||
const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') }))
|
||||
await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404)
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')
|
||||
|
||||
// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
|
||||
function umgebung() {
|
||||
const ablauf = []
|
||||
const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
|
||||
const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
|
||||
const urlApi = {
|
||||
createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
|
||||
revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
|
||||
}
|
||||
const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
|
||||
return { ablauf, tab, win, urlApi, timer }
|
||||
}
|
||||
|
||||
test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
|
||||
const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
|
||||
const pdf = alsPdfBlob(roh)
|
||||
assert.equal(pdf.type, 'application/pdf')
|
||||
assert.equal(await pdf.text(), '%PDF-1.4')
|
||||
const schon = new Blob(['x'], { type: 'application/pdf' })
|
||||
assert.equal(alsPdfBlob(schon), schon)
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
|
||||
const u = umgebung()
|
||||
await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
|
||||
assert.equal(PDF_FREIGABE_NACH_MS, 300000)
|
||||
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
|
||||
assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
|
||||
assert.equal(u.tab.opener, null)
|
||||
u.timer.fn()
|
||||
assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
|
||||
const u = umgebung()
|
||||
await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
|
||||
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
|
||||
let geholt = false
|
||||
const win = { open: () => null }
|
||||
await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
|
||||
assert.equal(geholt, false)
|
||||
})
|
||||
@@ -0,0 +1,680 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
|
||||
parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
|
||||
neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
|
||||
pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
|
||||
kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
|
||||
AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen,
|
||||
auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre,
|
||||
} = await loadModule('src/lib/auftragView.js')
|
||||
|
||||
const KONTEN = [
|
||||
{ projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
|
||||
{ projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
|
||||
]
|
||||
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
|
||||
const festpreisForm = (over = {}) => ({
|
||||
...neuesFormular({ heute: '2026-10-06' }),
|
||||
titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
|
||||
positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
|
||||
...over,
|
||||
})
|
||||
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
|
||||
const OK = { ok: true, fehler: {}, erstes: '' }
|
||||
|
||||
test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
|
||||
assert.deepEqual(AUFTRAG_LIMITS, {
|
||||
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
|
||||
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
|
||||
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
|
||||
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
|
||||
})
|
||||
assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
|
||||
assert.deepEqual(AUFTRAG_WEGE, [
|
||||
{ value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
|
||||
{ value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('festpreis'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('aufwand'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'monatlich', label: 'Monatlich nach Aufwand' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(SCHRITTE, [
|
||||
{ id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
|
||||
const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
|
||||
for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
|
||||
for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
|
||||
assert.equal(parseStunden(schlecht), null, String(schlecht))
|
||||
}
|
||||
})
|
||||
|
||||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
|
||||
assert.equal(stundenText(null), '–')
|
||||
assert.equal(stundenText('x'), '–')
|
||||
})
|
||||
|
||||
test('parseProzent und parseTage', () => {
|
||||
assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
|
||||
for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
|
||||
assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
|
||||
for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
|
||||
})
|
||||
|
||||
test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
|
||||
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
|
||||
const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
|
||||
const { positionen, ...rest } = f
|
||||
assert.equal(positionen.length, 1)
|
||||
const { uid, ...zeile } = positionen[0]
|
||||
assert.equal(typeof uid, 'number')
|
||||
assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-06', auftragWeg: '',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
|
||||
startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
|
||||
})
|
||||
assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
|
||||
assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
|
||||
assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
|
||||
assert.notEqual(neuePosition().uid, neuePosition().uid)
|
||||
})
|
||||
|
||||
test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
|
||||
const entwurf = {
|
||||
id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
|
||||
}
|
||||
const f = auftragZuFormular(entwurf)
|
||||
assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
|
||||
const { positionen, ...rest } = f
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
|
||||
})
|
||||
const aufwand = auftragZuFormular({
|
||||
...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
|
||||
zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
|
||||
})
|
||||
assert.equal(aufwand.stundensatz, '95,50')
|
||||
assert.equal(aufwand.schaetzung, '12,5')
|
||||
assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
|
||||
assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
|
||||
})
|
||||
|
||||
test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
|
||||
const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
|
||||
const f1 = waehleProjekt(f0, 'p1', KONTEN)
|
||||
assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
|
||||
const f2 = waehleProjekt(f1, 'p2', KONTEN)
|
||||
assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
|
||||
const f3 = waehleProjekt(f2, '', KONTEN)
|
||||
assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
|
||||
assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
|
||||
const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
|
||||
const f4 = waehleProjekt(hand, 'p2', KONTEN)
|
||||
assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
|
||||
assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
|
||||
})
|
||||
|
||||
test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
|
||||
const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
|
||||
assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
|
||||
assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
|
||||
assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
|
||||
assert.equal(wechselArt(f, 'quatsch'), f)
|
||||
})
|
||||
|
||||
test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
|
||||
const f = festpreisForm({
|
||||
positionen: [
|
||||
{ uid: 1, description: 'a', quantity: '2', price: '100,00' },
|
||||
{ uid: 2, description: 'b', quantity: '1', price: '50' },
|
||||
{ uid: 3, description: 'c', quantity: 'x', price: '9,99' },
|
||||
],
|
||||
})
|
||||
assert.equal(formularSummeCents(f), 25000)
|
||||
assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
|
||||
assert.equal(formularSummeCents(aufwandForm()), null)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
|
||||
const f = neuesFormular({ heute: '2026-10-06' })
|
||||
const r = pruefeSchritt(1, f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, false)
|
||||
assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
|
||||
assert.equal(r.erstes, 'titel')
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
const r = pruefe({ auftragVom: '', auftragWeg: '' })
|
||||
assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
|
||||
assert.equal(r.erstes, 'auftragVom')
|
||||
for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
|
||||
assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
|
||||
assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
|
||||
|
||||
const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
|
||||
const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
|
||||
const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||
assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
|
||||
assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
|
||||
// Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
|
||||
const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
|
||||
assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
|
||||
assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
|
||||
const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
|
||||
assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
|
||||
assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
|
||||
const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(festpreisForm()), OK)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
|
||||
for (const schlecht of ['', '0', '100', '33,3']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
|
||||
const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
|
||||
for (const schlecht of ['', '61', '-1', '1,5']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
|
||||
assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
|
||||
const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
|
||||
const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
|
||||
assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
|
||||
const f = festpreisForm({
|
||||
titel: ' Website Praxis ', projektId: 'p1',
|
||||
positionen: [
|
||||
{ uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
|
||||
{ uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
|
||||
],
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
const r = formularZuNutzlast(f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, 203450)
|
||||
assert.deepEqual(r.payload, {
|
||||
projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
|
||||
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
|
||||
],
|
||||
stundensatzCents: null, schaetzungMinuten: null,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
|
||||
zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
|
||||
const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
|
||||
assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
|
||||
assert.equal(frei.payload.projektId, '')
|
||||
assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
|
||||
const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, null)
|
||||
assert.deepEqual(r.payload.positionen, [])
|
||||
assert.equal(r.payload.art, 'aufwand')
|
||||
assert.equal(r.payload.stundensatzCents, 9550)
|
||||
assert.equal(r.payload.schaetzungMinuten, 750)
|
||||
assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
|
||||
assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
|
||||
const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
|
||||
assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
|
||||
const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
|
||||
const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
|
||||
})
|
||||
|
||||
test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
|
||||
const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
|
||||
assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
// Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: null }), '')
|
||||
assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
|
||||
assert.equal(v({ art: 'frei', text: '' }), '')
|
||||
assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
|
||||
assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
|
||||
assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'unbekannt' }), '')
|
||||
})
|
||||
|
||||
test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
|
||||
assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
|
||||
erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
|
||||
})
|
||||
|
||||
const TICKETS = [
|
||||
{ $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
|
||||
{ $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
|
||||
{ $id: 't1', woid: '100131', topic: 'Website Praxis' },
|
||||
{ $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
|
||||
]
|
||||
|
||||
test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
|
||||
const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
|
||||
assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
|
||||
assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
|
||||
assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
|
||||
assert.equal(o[0].label, '#100131 Website Praxis')
|
||||
assert.equal(o[1].label, '#100149 Neueste Anfrage')
|
||||
assert.equal(o[2].label, 'Ticket alt77')
|
||||
assert.equal(o[4].label, '#12345 Ohne Nummer')
|
||||
assert.deepEqual(zeitquelleOptionen(null, [], []), [])
|
||||
})
|
||||
|
||||
test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
|
||||
assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
|
||||
const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
|
||||
})
|
||||
|
||||
test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
|
||||
assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam(null, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
|
||||
assert.equal(kundenartWirksam(null, undefined), '')
|
||||
})
|
||||
|
||||
test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
|
||||
const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
assert.equal(VOR_ORT_HINWEIS, TEXT)
|
||||
assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
|
||||
for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
|
||||
assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
|
||||
const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
|
||||
const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
|
||||
assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
|
||||
assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
|
||||
assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
|
||||
// Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
|
||||
assert.deepEqual(hinweise({ einstellungen: undefined }), [])
|
||||
assert.deepEqual(hinweise({ einstellungen: null }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
|
||||
// die Kundenart des Kunden hat Vorrang vor dem Formular
|
||||
const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
|
||||
assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
|
||||
assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
|
||||
})
|
||||
|
||||
// --- Liste -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
const A = (o = {}) => ({
|
||||
id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
|
||||
art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
|
||||
bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
|
||||
erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
|
||||
pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
|
||||
})
|
||||
|
||||
test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
|
||||
assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
|
||||
['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
|
||||
['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
|
||||
])
|
||||
assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
|
||||
// „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
|
||||
assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
|
||||
})
|
||||
|
||||
test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
|
||||
const [z] = auftragZeilen([A()])
|
||||
assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
|
||||
assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
|
||||
assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
|
||||
assert.equal(z.auftrag.id, 'a1')
|
||||
})
|
||||
|
||||
test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
|
||||
const entwurf = A({
|
||||
id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
|
||||
stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
|
||||
})
|
||||
const [z] = auftragZeilen([entwurf], { konten })
|
||||
assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
|
||||
assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
|
||||
assert.equal(z.projektText, 'Praxis Müller – Relaunch')
|
||||
assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A()])[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
|
||||
assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
|
||||
})
|
||||
|
||||
test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
|
||||
const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
|
||||
assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
|
||||
assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
|
||||
assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
|
||||
assert.equal(zeilen[1].istEntwurf, true)
|
||||
assert.deepEqual(auftragZeilen(null), [])
|
||||
assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
|
||||
})
|
||||
|
||||
test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
|
||||
const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
|
||||
const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
|
||||
assert.deepEqual(texte({}), [])
|
||||
assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
|
||||
assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
|
||||
// nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
|
||||
assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
|
||||
// ohne Wirkung bei Entwurf und bei beendeten ABs
|
||||
assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
|
||||
assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
|
||||
assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
|
||||
const alle = [
|
||||
A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
|
||||
A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
|
||||
A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
|
||||
]
|
||||
assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
|
||||
assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
|
||||
assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
|
||||
assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
|
||||
assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
|
||||
})
|
||||
|
||||
test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
|
||||
const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
|
||||
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
|
||||
for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
|
||||
}
|
||||
// ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
|
||||
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
|
||||
assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
|
||||
assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
|
||||
assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
|
||||
assert.deepEqual(auftragAktionen(null), [])
|
||||
assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
|
||||
assert.equal(istEntwurf(A()), false)
|
||||
assert.equal(istEntwurf(null), false)
|
||||
})
|
||||
|
||||
test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
|
||||
assert.deepEqual(zuordnenOptionen(konten), [
|
||||
{ value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
|
||||
])
|
||||
assert.deepEqual(zuordnenOptionen(undefined), [])
|
||||
assert.deepEqual(zuordnenOptionen('x'), [])
|
||||
const ohne = (o) => A({ projektId: '', ...o })
|
||||
for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
|
||||
}
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
|
||||
}
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
|
||||
// hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
|
||||
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
|
||||
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
|
||||
// ohne Projekt zur Auswahl ist nichts zu tun
|
||||
assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
|
||||
assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
|
||||
assert.equal(projektZuordnenMoeglich(null, konten), false)
|
||||
})
|
||||
|
||||
test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Website' }]
|
||||
const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
|
||||
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
|
||||
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
|
||||
// mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
// Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
|
||||
const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
|
||||
assert.equal(aktion.label, 'Projekt zuordnen')
|
||||
assert.notEqual(aktion.danger, true)
|
||||
assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
|
||||
// die Zeilen geben die Konten weiter
|
||||
assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
})
|
||||
|
||||
test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
|
||||
assert.equal(
|
||||
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
|
||||
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
|
||||
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
|
||||
)
|
||||
assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
|
||||
assert.equal(
|
||||
auftragBestaetigung('stornieren', A()),
|
||||
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('erinnern', A()),
|
||||
'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
|
||||
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
|
||||
)
|
||||
assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
|
||||
// ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
|
||||
assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
|
||||
for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
|
||||
assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
|
||||
})
|
||||
|
||||
test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
|
||||
const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
|
||||
assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
|
||||
tone: 'warn',
|
||||
text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
|
||||
tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
|
||||
assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
|
||||
assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
|
||||
assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
|
||||
tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
|
||||
assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
|
||||
tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
|
||||
tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
|
||||
})
|
||||
|
||||
test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
|
||||
const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
|
||||
assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
|
||||
art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
|
||||
art: 'kundenart',
|
||||
text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
|
||||
zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
|
||||
art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
|
||||
art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
|
||||
})
|
||||
// Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
|
||||
assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
|
||||
art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
|
||||
})
|
||||
assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
|
||||
assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
|
||||
art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
|
||||
art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
|
||||
art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(500, 'Interner Fehler.'), {
|
||||
art: 'server',
|
||||
text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
|
||||
zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
|
||||
assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
|
||||
assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
|
||||
assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
|
||||
})
|
||||
|
||||
test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
|
||||
const sperre = neueSperre()
|
||||
let aufrufe = 0
|
||||
let fertig
|
||||
const warten = new Promise((r) => { fertig = r })
|
||||
const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
|
||||
const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
|
||||
assert.deepEqual(zweiter, { uebersprungen: true })
|
||||
assert.equal(sperre.belegt, true)
|
||||
fertig()
|
||||
assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
|
||||
assert.equal(aufrufe, 1)
|
||||
assert.equal(sperre.belegt, false)
|
||||
assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
|
||||
})
|
||||
|
||||
test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
|
||||
const sperre = neueSperre()
|
||||
await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
|
||||
assert.equal(sperre.belegt, false)
|
||||
})
|
||||
@@ -4,11 +4,11 @@ import { loadModule } from './load.mjs'
|
||||
|
||||
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
|
||||
|
||||
test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => {
|
||||
test('customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins', () => {
|
||||
assert.deepEqual(customerTabs(true), [
|
||||
{ id: 'overview', label: 'Übersicht' },
|
||||
{ id: 'tickets', label: 'Tickets' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
{ id: 'invoices', label: 'Aufträge und Rechnungen' },
|
||||
{ id: 'projects', label: 'Projekte' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'abo', label: 'Abo & Angebote' },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js')
|
||||
|
||||
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
|
||||
|
||||
/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */
|
||||
const reply = (status, { json, blob } = {}) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (json === undefined) throw new SyntaxError('Unexpected token % in JSON')
|
||||
return json
|
||||
},
|
||||
blob: async () => blob,
|
||||
})
|
||||
|
||||
function client({ responses = [], createJWT } = {}) {
|
||||
const calls = []
|
||||
const queue = [...responses]
|
||||
const api = createFinanceRequest({
|
||||
baseUrl: BASE,
|
||||
createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url, init })
|
||||
return queue.shift() || reply(200, { json: {} })
|
||||
},
|
||||
})
|
||||
return { api, calls }
|
||||
}
|
||||
|
||||
const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
|
||||
const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' })
|
||||
|
||||
test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => {
|
||||
const { api, calls } = client({
|
||||
responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }],
|
||||
})
|
||||
const result = await api('/auftraege/a1/pdf', { as: 'blob' })
|
||||
assert.ok(result instanceof Blob)
|
||||
assert.equal(result.type, 'application/pdf')
|
||||
assert.equal(await result.text(), '%PDF-1.7 Probe')
|
||||
assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`)
|
||||
assert.equal(calls[0].init.method, 'GET')
|
||||
assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
|
||||
})
|
||||
|
||||
test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => {
|
||||
const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] })
|
||||
const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' }))
|
||||
assert.equal(err.message, 'PDF nicht gefunden.')
|
||||
assert.equal(err.status, 404)
|
||||
// Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen
|
||||
const { api: api2 } = client({ responses: [reply(502)] })
|
||||
assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT)
|
||||
})
|
||||
|
||||
test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => {
|
||||
let n = 0
|
||||
const datei = pdf()
|
||||
const { api, calls } = client({
|
||||
createJWT: async () => ({ jwt: `jwt-${++n}` }),
|
||||
responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })],
|
||||
})
|
||||
assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei)
|
||||
assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
|
||||
})
|
||||
|
||||
test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => {
|
||||
const { api } = client({
|
||||
responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }],
|
||||
})
|
||||
assert.deepEqual(await api('/x'), { ok: 1 })
|
||||
const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] })
|
||||
assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 })
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
|
||||
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
|
||||
} = await loadModule('src/lib/kundenFelder.js')
|
||||
|
||||
test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
|
||||
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
|
||||
assert.deepEqual(KUNDENART_OPTIONEN, [
|
||||
{ value: '', label: 'Nicht festgelegt' },
|
||||
{ value: 'unternehmen', label: 'Unternehmen' },
|
||||
{ value: 'privat', label: 'Privatperson' },
|
||||
])
|
||||
assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
|
||||
assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
|
||||
assert.equal(kundenartLabel('privat'), 'Privatperson')
|
||||
for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
|
||||
})
|
||||
|
||||
test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
|
||||
assert.deepEqual(kundenartAenderung('', ''), {})
|
||||
assert.deepEqual(kundenartAenderung('', undefined), {})
|
||||
assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
|
||||
assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
|
||||
assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
|
||||
assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
|
||||
assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
|
||||
// unbekannte Werte werden nie geschrieben
|
||||
assert.deepEqual(kundenartAenderung('firma', ''), {})
|
||||
assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
|
||||
})
|
||||
|
||||
test('Steuernummer: Hinweis und Grenze', () => {
|
||||
assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
|
||||
assert.equal(STEUERNUMMER_MAX, 40)
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
|
||||
assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
|
||||
assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
|
||||
assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
|
||||
assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
|
||||
for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
|
||||
assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
|
||||
}
|
||||
assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
|
||||
assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: andere Zeichen und zu lang', () => {
|
||||
const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
|
||||
for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
|
||||
assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
|
||||
}
|
||||
const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
|
||||
assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
|
||||
assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
|
||||
assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
|
||||
})
|
||||
|
||||
test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
|
||||
assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
|
||||
for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
|
||||
assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
|
||||
}
|
||||
for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
|
||||
// noch nicht geladen oder nicht ladbar: es wird nichts behauptet
|
||||
for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
|
||||
stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
|
||||
artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
|
||||
} = await loadModule('src/lib/projektkontoView.js')
|
||||
|
||||
const konto = (over = {}) => ({
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
projektTyp: 'Website',
|
||||
tickets: [{ id: 't1', woid: '100131' }],
|
||||
zeitquelle: ['t1'],
|
||||
vereinbart: { festpreisCents: 120000, aufwand: null },
|
||||
berechnetCents: 50000,
|
||||
bezahltCents: 20000,
|
||||
offenCents: 70000,
|
||||
zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
|
||||
...over,
|
||||
})
|
||||
const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
|
||||
|
||||
test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.equal(stundenDe(750), '12,5 Std.')
|
||||
assert.equal(stundenDe(135), '2,25 Std.')
|
||||
assert.equal(stundenDe(60), '1 Std.')
|
||||
assert.equal(stundenDe(20), '0,33 Std.')
|
||||
assert.equal(stundenDe(0), '0 Std.')
|
||||
assert.equal(stundenDe(null), '0 Std.')
|
||||
assert.equal(stundenDe('abc'), '0 Std.')
|
||||
})
|
||||
|
||||
test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
|
||||
assert.equal(satzText(10000), '100 €/Std.')
|
||||
assert.equal(satzText(8550), '85,50 €/Std.')
|
||||
assert.equal(satzText(null), '–')
|
||||
})
|
||||
|
||||
test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
|
||||
assert.equal(ticketsText([{ id: 'a' }, null]), '')
|
||||
assert.equal(ticketsText(undefined), '')
|
||||
})
|
||||
|
||||
test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
|
||||
assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
|
||||
assert.equal(vereinbartText(undefined), 'keine AB')
|
||||
})
|
||||
|
||||
test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
|
||||
assert.deepEqual(kontoZeile(konto()), {
|
||||
key: 'p1',
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
meta: 'Website · #100131',
|
||||
vereinbart: '1.200,00 €',
|
||||
berechnet: '500,00 €',
|
||||
bezahlt: 'bezahlt 200,00 €',
|
||||
offen: '700,00 €',
|
||||
zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
|
||||
})
|
||||
// ohne Art und ohne Ticket bleibt nur der Name
|
||||
assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
|
||||
})
|
||||
|
||||
test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
|
||||
const z = kontoZeile(konto({
|
||||
vereinbart: AUFWAND,
|
||||
offenCents: null,
|
||||
zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
|
||||
}))
|
||||
assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(z.offen, '300,00 €')
|
||||
assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
|
||||
})
|
||||
|
||||
test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
|
||||
const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
|
||||
assert.equal(z.vereinbart, 'keine AB')
|
||||
assert.equal(z.offen, '–')
|
||||
})
|
||||
|
||||
test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
|
||||
const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
|
||||
assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
|
||||
assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
|
||||
// Aufwand: der Wert der offenen Zeit braucht kein Stripe
|
||||
const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
|
||||
assert.equal(a.offen, '1.250,00 €')
|
||||
assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
|
||||
assert.equal(stripeHinweis(true), '')
|
||||
assert.equal(stripeHinweis(undefined), '')
|
||||
assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
|
||||
})
|
||||
|
||||
test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
|
||||
assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
|
||||
key: 'ohne-projekt',
|
||||
projektId: '',
|
||||
name: 'Ohne Projekt',
|
||||
meta: '3 Rechnungen',
|
||||
vereinbart: '–',
|
||||
berechnet: '300,00 €',
|
||||
bezahlt: 'bezahlt 100,00 €',
|
||||
offen: '–',
|
||||
zeit: { stunden: '–', wert: '', abo: '' },
|
||||
})
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
|
||||
assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
|
||||
const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
|
||||
assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
|
||||
})
|
||||
|
||||
test('Hinweis für Kunden ohne Projekte', () => {
|
||||
assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
|
||||
})
|
||||
|
||||
test('artLabel: Spalte Art der Rechnungstabelle', () => {
|
||||
assert.deepEqual(
|
||||
['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
|
||||
['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
|
||||
)
|
||||
assert.equal(artLabel(undefined), '–')
|
||||
assert.equal(artLabel('unbekannt'), '–')
|
||||
assert.equal(artLabel('constructor'), '–')
|
||||
})
|
||||
|
||||
test('projektSpalte: Projektname der Rechnung oder „–“', () => {
|
||||
assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
|
||||
assert.equal(projektSpalte({ projektName: '' }), '–')
|
||||
assert.equal(projektSpalte({}), '–')
|
||||
assert.equal(projektSpalte(null), '–')
|
||||
})
|
||||
|
||||
test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
|
||||
const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
|
||||
assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
|
||||
assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
|
||||
assert.equal(abgerechnetText(map, 'w3'), null)
|
||||
assert.equal(abgerechnetText(map, 'constructor'), null)
|
||||
assert.equal(abgerechnetText(null, 'w1'), null)
|
||||
assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w3'), null)
|
||||
})
|
||||
|
||||
test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
|
||||
assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
|
||||
assert.deepEqual(ticketIdsOf(undefined), [])
|
||||
assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX,
|
||||
vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern,
|
||||
zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter,
|
||||
} = await loadModule('src/lib/rechnungAnfrage.js')
|
||||
const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js')
|
||||
|
||||
const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' }
|
||||
const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price })
|
||||
const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false })
|
||||
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||
const basis = (over = {}) => ({
|
||||
kunde, art: 'festbetrag', projektId: 'p1', auftragId: '',
|
||||
items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [],
|
||||
blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' },
|
||||
zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over,
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => {
|
||||
const r = rechnungAnfrage(basis())
|
||||
assert.equal(r.ok, true)
|
||||
assert.deepEqual(r.body, {
|
||||
customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14,
|
||||
items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
|
||||
})
|
||||
assert.equal(r.totalCents, 50000)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => {
|
||||
const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' }))
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.body.art, 'frei')
|
||||
assert.equal('projektId' in r.body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => {
|
||||
assert.equal(LEISTUNGSZEITRAUM_MAX, 140)
|
||||
const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true }))
|
||||
assert.equal(r.body.memo, 'Danke')
|
||||
assert.equal(r.body.leistungszeitraum, 'Oktober 2026')
|
||||
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false)
|
||||
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => {
|
||||
assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' })
|
||||
assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.')
|
||||
assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error,
|
||||
'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'
|
||||
)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => {
|
||||
const r = rechnungAnfrage(basis({
|
||||
art: 'aufwand', blaetter: B, abgewaehlt: ['b2'],
|
||||
zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' },
|
||||
zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')],
|
||||
}))
|
||||
assert.equal(r.ok, true)
|
||||
assert.deepEqual(r.body, {
|
||||
customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14,
|
||||
arbeitsblattIds: ['b1', 'b3'],
|
||||
items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }],
|
||||
zeilentexte: { '2026-10-06': '06.10.2026: Startseite' },
|
||||
})
|
||||
assert.equal(r.totalCents, null)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => {
|
||||
assert.deepEqual(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })),
|
||||
{ ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
)
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error,
|
||||
'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||
)
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error,
|
||||
'Zusatzposition 1: Bitte eine Beschreibung eingeben.'
|
||||
)
|
||||
// eine ganz leere Zeile zählt nicht
|
||||
const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] }))
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal('items' in r.body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => {
|
||||
const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } }))
|
||||
assert.deepEqual(prozent.body, {
|
||||
customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14,
|
||||
anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' },
|
||||
})
|
||||
const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } }))
|
||||
assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' })
|
||||
assert.equal(BEZEICHNUNG_MAX, 60)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => {
|
||||
const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung }))
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.')
|
||||
for (const wert of ['0', '0,00', '101', 'abc', '']) {
|
||||
assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert)
|
||||
}
|
||||
for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert)
|
||||
assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => {
|
||||
const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' })
|
||||
const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }]
|
||||
assert.deepEqual(rechnungAnfrage(e).body, {
|
||||
customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke',
|
||||
})
|
||||
assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } })
|
||||
assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' })
|
||||
})
|
||||
|
||||
test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => {
|
||||
assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' })
|
||||
assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' })
|
||||
const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) })
|
||||
assert.equal(vorschauAnfrage(e).ok, true)
|
||||
assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||
assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
|
||||
})
|
||||
|
||||
test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => {
|
||||
const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body)
|
||||
const k1 = key({})
|
||||
assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1)
|
||||
assert.notEqual(key({ abgewaehlt: ['b2'] }), k1)
|
||||
assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1)
|
||||
assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1)
|
||||
})
|
||||
|
||||
test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => {
|
||||
const ok = { ok: true, body: {} }
|
||||
const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } }
|
||||
const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args })
|
||||
assert.deepEqual(
|
||||
vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }),
|
||||
{ state: 'nicht-noetig', message: '' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }),
|
||||
{ state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
)
|
||||
assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' })
|
||||
assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt')
|
||||
assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert
|
||||
assert.deepEqual(
|
||||
status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }),
|
||||
{ state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }),
|
||||
{ state: 'fehler', message: 'Nichts mehr offen.' }
|
||||
)
|
||||
assert.deepEqual(status({}), { state: 'bereit', message: '' })
|
||||
assert.equal(darfSpeichern({ state: 'bereit' }), true)
|
||||
assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true)
|
||||
for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state)
|
||||
})
|
||||
|
||||
test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => {
|
||||
const e1 = basis({ art: 'aufwand', blaetter: B })
|
||||
const a1 = vorschauAnfrage(e1)
|
||||
const k1 = vorschauSchluessel(a1.body)
|
||||
const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } }
|
||||
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true)
|
||||
const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] })
|
||||
const k2 = vorschauSchluessel(a2.body)
|
||||
assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3'])
|
||||
assert.notEqual(k2, k1)
|
||||
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false)
|
||||
})
|
||||
|
||||
test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => {
|
||||
const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €'
|
||||
assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' })
|
||||
assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' })
|
||||
assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' })
|
||||
const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung })
|
||||
assert.equal(zeileBearbeitbar(pos('2026-10-06')), true)
|
||||
assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true)
|
||||
for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel)
|
||||
assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false)
|
||||
const hinten = ' – 2,25 Std. à 100,00 €'
|
||||
assert.equal(maxVorn(hinten), 200 - hinten.length)
|
||||
assert.equal(maxVorn('x'.repeat(250)), 1)
|
||||
})
|
||||
|
||||
const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } }
|
||||
const anfrage = rechnungAnfrage(basis())
|
||||
|
||||
test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => {
|
||||
const gesendet = []
|
||||
const submit = createSubmitter({
|
||||
create: async (body) => (gesendet.push(body), okAntwort),
|
||||
confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'),
|
||||
})
|
||||
const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||
assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false })
|
||||
assert.equal(gesendet[0].senden, false)
|
||||
assert.equal(gesendet[0].art, 'festbetrag')
|
||||
})
|
||||
|
||||
test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => {
|
||||
let frage = ''
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } })
|
||||
assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' })
|
||||
assert.equal(create, 0)
|
||||
assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.')
|
||||
})
|
||||
|
||||
test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => {
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true })
|
||||
const ohneAdresse = { ...kunde, street: '' }
|
||||
assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT })
|
||||
assert.equal(create, 0)
|
||||
assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag
|
||||
assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich
|
||||
})
|
||||
|
||||
test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => {
|
||||
let create = 0
|
||||
let fertig
|
||||
const submit = createSubmitter({
|
||||
create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) },
|
||||
confirm: () => true,
|
||||
})
|
||||
const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||
const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||
assert.deepEqual(zweiter, { status: 'laeuft' })
|
||||
fertig()
|
||||
assert.equal((await erster).status, 'ok')
|
||||
assert.equal(create, 1)
|
||||
// danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können)
|
||||
const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||
fertig()
|
||||
assert.equal((await dritter).status, 'ok')
|
||||
assert.equal(create, 2)
|
||||
})
|
||||
|
||||
test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => {
|
||||
const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true })
|
||||
const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 })
|
||||
assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), {
|
||||
status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409,
|
||||
})
|
||||
const ohneStatus = new Error('Irgendwas ist schiefgegangen.')
|
||||
assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' })
|
||||
const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), {
|
||||
status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009',
|
||||
})
|
||||
assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), {
|
||||
status: 'festgeschrieben',
|
||||
notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.',
|
||||
finalizedFilter: 'offen',
|
||||
})
|
||||
})
|
||||
|
||||
test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => {
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true })
|
||||
const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 })
|
||||
assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' })
|
||||
assert.equal(create, 0)
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
|
||||
|
||||
test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
|
||||
assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
|
||||
projektId: 'p1', art: '', auftragId: '',
|
||||
items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
|
||||
abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
|
||||
zeitraum: '', zeitraumGeaendert: false, days: '14', tageGeaendert: false, memo: '',
|
||||
})
|
||||
assert.equal(anfangszustand().projektId, null)
|
||||
assert.equal(anfangszustand({ projektId: '' }).projektId, '')
|
||||
})
|
||||
|
||||
test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'tage', wert: '30' })
|
||||
s = reduce(s, { type: 'notiz', wert: 'Danke' })
|
||||
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b1' })
|
||||
s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
|
||||
s = reduce(s, { type: 'projekt', projektId: 'p2' })
|
||||
assert.equal(s.projektId, 'p2')
|
||||
assert.equal(s.art, '')
|
||||
assert.deepEqual(s.abgewaehlt, [])
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
assert.equal(s.days, '30')
|
||||
assert.equal(s.memo, 'Danke')
|
||||
assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
|
||||
})
|
||||
|
||||
test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
|
||||
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
|
||||
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||
assert.equal(s.art, 'aufwand')
|
||||
assert.equal(s.items[0].description, 'Pauschale')
|
||||
assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
|
||||
// eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
|
||||
assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
|
||||
assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
|
||||
})
|
||||
|
||||
test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
|
||||
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
assert.equal(s.zusatz[0].uid, 2)
|
||||
s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
|
||||
assert.deepEqual(s.zusatz, [])
|
||||
for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
assert.equal(s.zusatz.length, 20)
|
||||
})
|
||||
|
||||
test('blatt: Abwählen und Wiederanwählen', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||
assert.deepEqual(s.abgewaehlt, ['b2'])
|
||||
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||
assert.deepEqual(s.abgewaehlt, [])
|
||||
})
|
||||
|
||||
test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
|
||||
const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'ticket+', ticket })
|
||||
s = reduce(s, { type: 'ticket+', ticket })
|
||||
assert.deepEqual(s.weitereTickets, [ticket])
|
||||
s = reduce(s, { type: 'blatt', id: 'w1' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b9' })
|
||||
s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
|
||||
assert.deepEqual(s.weitereTickets, [])
|
||||
assert.deepEqual(s.abgewaehlt, ['b9'])
|
||||
})
|
||||
|
||||
test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
|
||||
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
|
||||
s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
|
||||
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
|
||||
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
|
||||
// ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
|
||||
const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||
assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
|
||||
assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
|
||||
})
|
||||
|
||||
test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
|
||||
s = setze('Neu')
|
||||
assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
|
||||
s = setze('Alt')
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
s = setze('Neu')
|
||||
s = setze(' ')
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
})
|
||||
|
||||
test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
|
||||
s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
|
||||
assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
|
||||
s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
|
||||
assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
|
||||
assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
|
||||
})
|
||||
|
||||
test('tage-vorschlag: übernimmt das Zahlungsziel der AB, solange der Nutzer es nicht selbst geändert hat', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'tage-vorschlag', wert: '30' })
|
||||
assert.equal(s.days, '30')
|
||||
assert.equal(s.tageGeaendert, false)
|
||||
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s) // derselbe Wert: nichts zu tun
|
||||
s = reduce(s, { type: 'tage-vorschlag', wert: '14' }) // andere AB gewählt: der Vorschlag folgt
|
||||
assert.equal(s.days, '14')
|
||||
// eine Eingabe des Nutzers wird nie überschrieben, auch nicht nach einem Projektwechsel
|
||||
s = reduce(s, { type: 'tage', wert: '7' })
|
||||
assert.equal(s.tageGeaendert, true)
|
||||
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s)
|
||||
s = reduce(s, { type: 'projekt', projektId: 'p2' })
|
||||
assert.equal(s.days, '7')
|
||||
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }).days, '7')
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
AB_HINWEIS, ENTWURF_HINWEIS,
|
||||
startProjekt, projektOptionen, wirksamesProjekt,
|
||||
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
|
||||
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
|
||||
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
|
||||
STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
|
||||
STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten,
|
||||
} = await loadModule('src/lib/rechnungDialogView.js')
|
||||
|
||||
const ab = (over = {}) => ({
|
||||
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
|
||||
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
|
||||
})
|
||||
const rk = (over = {}) => ({
|
||||
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
|
||||
})
|
||||
const bl = (id, minuten, over = {}) => ({
|
||||
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
|
||||
})
|
||||
const KONTEN = [
|
||||
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
|
||||
{ projektId: 'p2', name: 'Shop', tickets: [] },
|
||||
]
|
||||
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
|
||||
const zustand = (o) => o.map((x) => [x.key, x.enabled])
|
||||
|
||||
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
|
||||
assert.equal(startProjekt(undefined), null)
|
||||
assert.equal(startProjekt(null), '')
|
||||
assert.equal(startProjekt('p1'), 'p1')
|
||||
})
|
||||
|
||||
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
|
||||
assert.deepEqual(projektOptionen(KONTEN), [
|
||||
{ value: 'p1', label: 'Website Relaunch · #100131' },
|
||||
{ value: 'p2', label: 'Shop' },
|
||||
{ value: '', label: 'Ohne Projekt' },
|
||||
])
|
||||
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
|
||||
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
|
||||
})
|
||||
|
||||
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
|
||||
assert.equal(wirksamesProjekt('', KONTEN), '')
|
||||
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
|
||||
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
|
||||
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
|
||||
assert.equal(wirksamesProjekt(null, KONTEN), null)
|
||||
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
|
||||
assert.equal(wirksamesProjekt(null, undefined), null)
|
||||
})
|
||||
|
||||
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
|
||||
const list = [
|
||||
ab({ id: 'a1' }),
|
||||
ab({ id: 'a2', status: 'entwurf' }),
|
||||
ab({ id: 'a3', art: 'aufwand' }),
|
||||
ab({ id: 'a4', status: 'abgerechnet' }),
|
||||
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
|
||||
ab({ id: 'a6', status: 'storniert' }),
|
||||
]
|
||||
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
|
||||
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
|
||||
})
|
||||
|
||||
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
|
||||
const list = [
|
||||
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
|
||||
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
|
||||
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
|
||||
ab({ id: 'a1' }),
|
||||
]
|
||||
assert.equal(aufwandAuftrag(list).id, 'x2')
|
||||
assert.equal(aufwandAuftrag([ab()]), null)
|
||||
assert.equal(aufwandAuftrag(undefined), null)
|
||||
})
|
||||
|
||||
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
|
||||
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
|
||||
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
|
||||
})
|
||||
|
||||
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
|
||||
const list = [
|
||||
rk(),
|
||||
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
|
||||
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
|
||||
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
|
||||
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
|
||||
]
|
||||
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
|
||||
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
|
||||
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
|
||||
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
|
||||
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
|
||||
})
|
||||
|
||||
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
|
||||
const o = optionen({ auftraege: [ab()] })
|
||||
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
|
||||
})
|
||||
|
||||
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
|
||||
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
|
||||
const o = optionen(args)
|
||||
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.equal(o[0].hint, AB_HINWEIS)
|
||||
assert.equal(o[1].hint, AB_HINWEIS)
|
||||
}
|
||||
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||
})
|
||||
|
||||
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
|
||||
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
|
||||
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.equal(o[1].hint, ENTWURF_HINWEIS)
|
||||
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
|
||||
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
|
||||
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
|
||||
assert.equal(zwei[1].enabled, true)
|
||||
})
|
||||
|
||||
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
|
||||
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
|
||||
const frei = artOptionen({ projektId: '', auftraege: [ab({ projektId: 'p1' })], rechnungen: [], auftraegeAktiv: true })
|
||||
assert.deepEqual(zustand(frei), [['frei', true]])
|
||||
})
|
||||
|
||||
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
|
||||
const lage = (args, extra = {}) => {
|
||||
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
|
||||
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
|
||||
}
|
||||
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
|
||||
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
|
||||
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
|
||||
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
|
||||
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
|
||||
assert.equal(lage({}), 'festbetrag')
|
||||
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
|
||||
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
|
||||
})
|
||||
|
||||
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
|
||||
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
|
||||
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
|
||||
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
|
||||
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
|
||||
assert.equal(standardAuftrag('anzahlung', []), '')
|
||||
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
|
||||
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
|
||||
})
|
||||
|
||||
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
|
||||
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
|
||||
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
|
||||
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
|
||||
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
|
||||
assert.deepEqual(anzahlungStandard(null), vorgabe)
|
||||
})
|
||||
|
||||
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
|
||||
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
|
||||
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
|
||||
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
|
||||
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
|
||||
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
|
||||
})
|
||||
|
||||
const ws = (over = {}) => ({
|
||||
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
|
||||
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
|
||||
})
|
||||
|
||||
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
|
||||
assert.deepEqual(blattAusAppwrite(ws(), {}), {
|
||||
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
|
||||
})
|
||||
// Unterstützung zählt mit: 60 + 1 × 30
|
||||
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
|
||||
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
|
||||
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
|
||||
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
|
||||
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
|
||||
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
|
||||
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
|
||||
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
|
||||
})
|
||||
|
||||
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
|
||||
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
|
||||
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
|
||||
assert.equal(blattAusAppwrite({}, {}), null)
|
||||
})
|
||||
|
||||
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
|
||||
const T = [
|
||||
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
|
||||
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
|
||||
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
|
||||
{ $id: 't4', woid: '100150', topic: '', title: '' },
|
||||
]
|
||||
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
|
||||
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
|
||||
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
|
||||
])
|
||||
assert.deepEqual(ticketOptionen(undefined), [])
|
||||
})
|
||||
|
||||
test('zahlungszielVorschlag: Anzahlung und Schluss übernehmen das Zahlungsziel der gewählten AB, sonst 14', () => {
|
||||
const dreissig = ab({ zahlungszielTage: 30 })
|
||||
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: dreissig }), '30')
|
||||
assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: dreissig }), '30')
|
||||
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab({ zahlungszielTage: 0 }) }), '0') // sofort fällig ist erlaubt
|
||||
// Aufwand: die jüngste gültige Aufwands-AB
|
||||
const aufwand = ab({ id: 'x2', art: 'aufwand', zahlungszielTage: 21 })
|
||||
assert.equal(zahlungszielVorschlag({ art: 'aufwand', auftraege: [ab(), aufwand] }), '21')
|
||||
// ohne AB, ohne Wert oder mit unbrauchbarem Wert bleibt es bei 14; frei und Festbetrag kennen keine AB
|
||||
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: undefined }), '14')
|
||||
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab() }), '14')
|
||||
for (const kaputt of [-1, 61, 1.5, '30', null, NaN]) assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: ab({ zahlungszielTage: kaputt }) }), '14')
|
||||
assert.equal(zahlungszielVorschlag({ art: 'festbetrag', ab: dreissig }), '14')
|
||||
assert.equal(zahlungszielVorschlag({ art: 'frei', ab: dreissig }), '14')
|
||||
assert.equal(zahlungszielVorschlag({ art: '', auftraege: [aufwand] }), '14')
|
||||
})
|
||||
|
||||
test('steuernummerHinweis: nicht sperrender Hinweis, solange die Steuernummer in den Einstellungen leer ist', () => {
|
||||
assert.equal(
|
||||
STEUERNUMMER_RECHNUNG_HINWEIS,
|
||||
'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
|
||||
)
|
||||
for (const leer of [{}, { steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }]) {
|
||||
assert.equal(steuernummerHinweis(leer), STEUERNUMMER_RECHNUNG_HINWEIS)
|
||||
}
|
||||
assert.equal(steuernummerHinweis({ steuernummer: '12/345/67890' }), '')
|
||||
// noch nicht geladen (undefined) und Ladefehler (null) behaupten nichts
|
||||
assert.equal(steuernummerHinweis(undefined), '')
|
||||
assert.equal(steuernummerHinweis(null), '')
|
||||
assert.equal(steuernummerHinweis([]), '')
|
||||
})
|
||||
|
||||
test('abSummenText: Auftragssumme, berechnet und noch offen; bei Stripe-Ausfall „Beträge unbekannt“ statt 0,00 €', () => {
|
||||
assert.equal(
|
||||
abSummenText({ ab: ab(), rechnungen: [rk()], stripeOk: true }),
|
||||
'Auftragssumme 800,00 € · bereits berechnet 400,00 € · noch nicht berechnet 400,00 €'
|
||||
)
|
||||
assert.equal(abSummenText({ ab: ab(), rechnungen: [], stripeOk: undefined }), 'Auftragssumme 800,00 € · bereits berechnet 0,00 € · noch nicht berechnet 800,00 €')
|
||||
assert.equal(STRIPE_UNBEKANNT, 'Stripe nicht erreichbar – Beträge unbekannt')
|
||||
const aus = abSummenText({ ab: ab(), rechnungen: [], stripeOk: false })
|
||||
assert.equal(aus, 'Auftragssumme 800,00 € · Stripe nicht erreichbar – Beträge unbekannt')
|
||||
assert.ok(!aus.includes('bereits berechnet'))
|
||||
})
|
||||
|
||||
test('standardArt: bei Stripe-Ausfall wird keine Art vorgeschlagen (Beträge unbekannt)', () => {
|
||||
const lage = { auftraege: [ab()], rechnungen: [], offeneMinuten: 90 }
|
||||
const o = optionen({ auftraege: [ab()] })
|
||||
assert.equal(standardArt(o, lage), 'anzahlung')
|
||||
assert.equal(standardArt(o, { ...lage, stripeOk: true }), 'anzahlung')
|
||||
assert.equal(standardArt(o, { ...lage, stripeOk: false }), '')
|
||||
// ohne Projekt gibt es nichts zu raten: die freie Rechnung bleibt die Vorgabe
|
||||
const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||
assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei')
|
||||
})
|
||||
|
||||
test('artOptionen ohne Projekt: eine gültige AB ohne Projekt macht Anzahlung und Schlussrechnung wählbar', () => {
|
||||
const ohne = (args = {}) => artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true, ...args })
|
||||
assert.deepEqual(zustand(ohne()), [['frei', true], ['anzahlung', true], ['schluss', true]])
|
||||
assert.deepEqual(ohne().map((x) => x.label), ['Freie Positionen', 'Anzahlung', 'Schlussrechnung'])
|
||||
// eine AB mit Projekt, ein Entwurf, ein falscher Status oder der ausgeschaltete Schalter: wie bisher nur frei bzw. gesperrt
|
||||
assert.deepEqual(zustand(ohne({ auftraege: [ab({ projektId: 'p1' })] })), [['frei', true]])
|
||||
assert.deepEqual(zustand(ohne({ auftraege: [ab({ status: 'entwurf' })] })), [['frei', true]])
|
||||
assert.deepEqual(zustand(ohne({ auftraegeAktiv: false })), [['frei', true]])
|
||||
const entwurf = ohne({ rechnungen: [rk({ status: 'entwurf' })] })
|
||||
assert.deepEqual(zustand(entwurf), [['frei', true], ['anzahlung', true], ['schluss', false]])
|
||||
assert.equal(entwurf[2].hint, ENTWURF_HINWEIS)
|
||||
// Vorgabe bleibt die freie Rechnung
|
||||
assert.equal(standardArt(ohne(), { auftraege: [ab()], rechnungen: [], offeneMinuten: 0 }), 'frei')
|
||||
})
|
||||
|
||||
test('ohneProjektAbDaten: nur ABs ohne Projekt und deren Rechnungen aus Auftrags- und Rechnungsliste', () => {
|
||||
const daten = ohneProjektAbDaten(
|
||||
{ auftraege: [ab({ id: 'a1', projektId: '' }), ab({ id: 'a2', projektId: 'p1' }), ab({ id: 'a3' })] },
|
||||
{ rechnungen: [rk({ auftragId: 'a1' }), rk({ id: 'in_2', auftragId: 'a2' }), rk({ id: 'in_3', auftragId: '' }), rk({ id: 'in_4', auftragId: 'a3', status: 'entwurf' })] }
|
||||
)
|
||||
assert.deepEqual(daten.auftraege.map((a) => a.id), ['a1', 'a3'])
|
||||
assert.deepEqual(daten.rechnungen.map((r) => r.id), ['in_1', 'in_4'])
|
||||
assert.deepEqual(ohneProjektAbDaten(undefined, null), { auftraege: [], rechnungen: [] })
|
||||
assert.deepEqual(ohneProjektAbDaten({ auftraege: 'x' }, { rechnungen: {} }), { auftraege: [], rechnungen: [] })
|
||||
})
|
||||
Reference in new issue
Block a user