Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt: 1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer Ausnahmen, die sich nicht aus der Kategorie ergeben. 2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS. 3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert. Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt. Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0 bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem leeren Feld eine 0 gemacht. Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen exakt den Rechnungsbetrag ergibt. Der Satz wandert von der Rechnung an die BESTELLPOSITION (OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis: wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte entstand in der vorigen Migration und wurde nie in Betrieb beschrieben. VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der sicherere ist — als Lebensmittel waeren 7 richtig, als Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen vermerkt. Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand" gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die Saetze ohne globalen Zustand testen lassen, und greift auf die Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
import { describe, test, expect } from "vitest";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
/**
|
|
* Haelt die Kubernetes-Manifeste und den Code in Deckung.
|
|
*
|
|
* Die Manifeste unter k8s/ sind ausdruecklich dafuer gedacht, mit moeglichst
|
|
* wenig Anpassung als Produktivkonfiguration uebernommen zu werden. Das
|
|
* traegt nur, solange sie VOLLSTAENDIG sind. Ohne diese Pruefung faellt eine
|
|
* im Code neu hinzugekommene Umgebungsvariable erst im Betrieb auf, und zwar
|
|
* als stiller Rueckfall auf einen Standardwert statt als Fehler: zur Laufzeit
|
|
* ist eine fehlende Variable nicht von einer absichtlich leeren zu
|
|
* unterscheiden.
|
|
*
|
|
* Bewusst KEIN YAML-Parser: das Projekt hat keinen als Abhaengigkeit, und
|
|
* fuer diesen Zweck genuegt es, die Schluesselzeilen der flachen
|
|
* data:/stringData:-Bloecke zu lesen. Die Manifeste sind von Hand gepflegt
|
|
* und halten dieses einfache Format ein.
|
|
*/
|
|
|
|
const REPO_ROOT = process.cwd();
|
|
const CONFIGMAP = path.join(REPO_ROOT, "k8s", "01-configmap.yaml");
|
|
const SECRET = path.join(REPO_ROOT, "k8s", "02-secret.example.yaml");
|
|
const SRC = path.join(REPO_ROOT, "src");
|
|
|
|
/**
|
|
* Schluessel eines Manifests: genau zwei Leerzeichen eingerueckt, beginnend
|
|
* mit einem Grossbuchstaben. Kommentarzeilen (" # ...") und Metadatenfelder
|
|
* (" name: ...") fallen dadurch heraus, ohne Sonderfaelle.
|
|
*/
|
|
function manifestKeys(file: string): string[] {
|
|
return readFileSync(file, "utf8")
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
const match = /^ {2}([A-Z][A-Z0-9_]*):/.exec(line);
|
|
return match ? [match[1]] : [];
|
|
});
|
|
}
|
|
|
|
/** Alle process.env-Zugriffe unterhalb von src/ — das ist die Laufzeit der App. */
|
|
function envNamesUsedInCode(dir: string): Set<string> {
|
|
const found = new Set<string>();
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
for (const name of envNamesUsedInCode(full)) found.add(name);
|
|
continue;
|
|
}
|
|
if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith(".test.ts")) continue;
|
|
for (const match of readFileSync(full, "utf8").matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) {
|
|
found.add(match[1]);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Schluessel, die die App zwar liest, aber nicht ueber ein woertliches
|
|
* `process.env.NAME` — deshalb findet sie die Suche unten nicht.
|
|
*
|
|
* lib/vat.ts bekommt die Umgebung als Parameter uebergeben, damit sich die
|
|
* Steuersaetze ohne globalen Zustand testen lassen, und greift auf die
|
|
* Kategorie-Saetze ueber einen berechneten Schluessel zu. Beides ist Absicht;
|
|
* eine Regex kann es nicht sehen.
|
|
*/
|
|
const READ_INDIRECTLY: Record<string, string> = {
|
|
VAT_RATE_PERCENT: "lib/vat.ts — als Parameter uebergebene Umgebung (vatRateForCategory).",
|
|
VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
|
VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
|
VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.",
|
|
};
|
|
|
|
/**
|
|
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
|
|
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
|
|
* liest — sonst sammelt sich Konfiguration an, die nichts mehr tut.
|
|
*/
|
|
const USED_OUTSIDE_THE_APP: Record<string, string> = {
|
|
DB_PASSWORD:
|
|
"Datenbank-StatefulSet (03-postgres.yaml/03-mysql.yaml) und Sicherung (07-backup-cronjob.yaml); die App kennt nur DATABASE_URL.",
|
|
ADMIN_EMAIL: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
|
|
ADMIN_PASSWORD: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
|
|
};
|
|
|
|
describe("Kubernetes-Manifeste und Code", () => {
|
|
test("die Manifeste liefern jede Variable, die die App liest", () => {
|
|
// Arrange
|
|
const provided = new Set([...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]);
|
|
|
|
// Act
|
|
const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name)).sort();
|
|
|
|
// Assert
|
|
expect(missing, `In k8s/ fehlen: ${missing.join(", ")}`).toEqual([]);
|
|
});
|
|
|
|
test("kein Schluessel steht doppelt in ConfigMap und Secret", () => {
|
|
// Arrange
|
|
const config = manifestKeys(CONFIGMAP);
|
|
const secret = new Set(manifestKeys(SECRET));
|
|
|
|
// Act
|
|
const inBoth = config.filter((name) => secret.has(name)).sort();
|
|
|
|
// Assert: stuenden sie in beiden, entschiede allein die Reihenfolge der
|
|
// envFrom-Eintraege, welcher Wert gewinnt — eine Falle beim Suchen.
|
|
expect(inBoth, `Doppelt vergeben: ${inBoth.join(", ")}`).toEqual([]);
|
|
});
|
|
|
|
test("kein Schluessel in den Manifesten, den niemand liest", () => {
|
|
// Arrange
|
|
const used = envNamesUsedInCode(SRC);
|
|
|
|
// Act
|
|
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
|
|
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP) && !(name in READ_INDIRECTLY))
|
|
.sort();
|
|
|
|
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
|
|
// als liesse sich damit etwas einstellen.
|
|
expect(unused, `Wird nirgends gelesen: ${unused.join(", ")}`).toEqual([]);
|
|
});
|
|
|
|
test("die Secret-Vorlage enthaelt keine ausgefuellten Werte", () => {
|
|
// Arrange
|
|
const secret = readFileSync(SECRET, "utf8");
|
|
|
|
// Act
|
|
const filledIn = secret
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
const match = /^ {2}([A-Z][A-Z0-9_]*): "(.*)"$/.exec(line);
|
|
if (!match) return [];
|
|
const [, key, value] = match;
|
|
const isPlaceholder = value === "" || value.includes("CHANGE-ME");
|
|
return isPlaceholder ? [] : [key];
|
|
})
|
|
.sort();
|
|
|
|
// Assert: die Vorlage wird committet — ein echter Wert waere damit im
|
|
// Repository. Platzhalter sind leer oder tragen "CHANGE-ME".
|
|
expect(filledIn, `Sieht nach echten Werten aus: ${filledIn.join(", ")}`).toEqual([]);
|
|
});
|
|
});
|