diff --git a/src/lib/auftraegeAnsicht.js b/src/lib/auftraegeAnsicht.js new file mode 100644 index 0000000..8732565 --- /dev/null +++ b/src/lib/auftraegeAnsicht.js @@ -0,0 +1,69 @@ +// Ansichtslogik des Reiters „Aufträge und Rechnungen“ (rein, ohne React und ohne Appwrite; unter Node testbar). +// Die API-Funktionen kommen als Argument. Der Container (AuftraegeUndRechnungen.jsx) verdrahtet diese Funktionen mit +// useFinanceResource und den Abschnitten des Reiters. + +export const KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.' +export const AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.' + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const ohneProjektLeer = () => ({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) + +/** + * Lädt Projektkonten und, nur bei eingeschaltetem Schalter (`auftraegeAktiv === true`), die Aufträge des Kunden. + * Scheitert getProjektkonten, geht der Fehler weiter (keine Aufträge, sie würden ohne Schalter 404 liefern). + * Scheitert nur listAuftraege, bleiben die Projektkonten und der Fehler steht eigens da. + * -> { konten, auftraege: Liste | null, auftraegeFehler: Text | '' } + */ +export async function ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) { + const antwort = await getProjektkonten(kundeId) + const konten = istObjekt(antwort) ? antwort : {} + if (konten.auftraegeAktiv !== true) return { konten, auftraege: null, auftraegeFehler: '' } + try { + const liste = await listAuftraege(kundeId) + return { konten, auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [], auftraegeFehler: '' } + } catch (err) { + return { konten, auftraege: null, auftraegeFehler: (err && err.message) || AUFTRAEGE_FEHLER } + } +} + +/** + * Zustand der Ansicht aus dem Ergebnis von useFinanceResource ({ data, error }). + * -> { projekteStatus: 'laden' | 'fehler' | 'bereit', fehler, nachladeFehler, + * auftraegeAktiv, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler, + * konten, ohneProjekt, stripeOk, kunde, auftraege } + * Ohne Daten gibt es keinen Aufträge-Abschnitt (der Schalter ist noch unbekannt). Scheitert das Neuladen, nachdem Daten + * da sind, bleiben die alten Daten stehen und die Meldung kommt als `nachladeFehler` daneben. + */ +export function ansichtsZustand({ data, error } = {}) { + if (!data) { + return { + projekteStatus: error ? 'fehler' : 'laden', + fehler: error || '', + nachladeFehler: '', + auftraegeAktiv: false, + auftraegeStatus: 'aus', + auftraegeFehler: '', + konten: [], + ohneProjekt: ohneProjektLeer(), + stripeOk: true, + kunde: null, + auftraege: [], + } + } + const k = istObjekt(data.konten) ? data.konten : {} + const auftraegeAktiv = k.auftraegeAktiv === true + const auftraegeStatus = !auftraegeAktiv ? 'aus' : data.auftraegeFehler ? 'fehler' : 'bereit' + return { + projekteStatus: 'bereit', + fehler: '', + nachladeFehler: error || '', + auftraegeAktiv, + auftraegeStatus, + auftraegeFehler: auftraegeStatus === 'fehler' ? data.auftraegeFehler : '', + konten: Array.isArray(k.konten) ? k.konten : [], + ohneProjekt: istObjekt(k.ohneProjekt) ? k.ohneProjekt : ohneProjektLeer(), + stripeOk: k.stripeOk !== false, + kunde: istObjekt(k.kunde) ? k.kunde : null, + auftraege: auftraegeAktiv && Array.isArray(data.auftraege) ? data.auftraege : [], + } +} diff --git a/tests/unit/auftraegeAnsicht.test.mjs b/tests/unit/auftraegeAnsicht.test.mjs new file mode 100644 index 0000000..857036c --- /dev/null +++ b/tests/unit/auftraegeAnsicht.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand } = 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: der Fehler geht weiter, die Aufträge werden nicht abgerufen', async () => { + const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 }) + const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: [] } }) + await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler) + assert.deepEqual(api.calls, [['konten', 'k1']]) +}) + +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) +})