Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+471
View File
@@ -0,0 +1,471 @@
package cli
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"sultan/pkg/accountant"
"sultan/pkg/crypto"
"sultan/pkg/ledger"
)
// initAccDir initialisiert ein Datenverzeichnis wie der erste Start von
// `sultan accountant`.
func initAccDir(t *testing.T, publicURLs ...string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "accdata")
a, err := accountant.New(context.Background(), accountant.Config{
DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: publicURLs, Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
a.Shutdown(context.Background())
return dir
}
func acc(t *testing.T, dir string, args ...string) (int, string, string) {
t.Helper()
full := append([]string{"accountant"}, args...)
full = append(full, "--data-dir="+dir)
return run(context.Background(), full...)
}
func TestAccountantFlags(t *testing.T) {
cfg, err := parseAccountantFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./accdata" || cfg.Addr != ":8444" || len(cfg.PublicURLs) != 0 || cfg.MetricsAddr != "127.0.0.1:8082" ||
cfg.EnrollLimit != 10 || cfg.StaleWarn != 2*time.Hour || cfg.StaleCrit != 6*time.Hour {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseAccountantFlags([]string{"--metrics-addr=none", "--enroll-limit=-1", "--stale-warn=30m", "--stale-crit=1h"},
&bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.MetricsAddr != "none" || cfg.EnrollLimit != -1 || cfg.StaleWarn != 30*time.Minute || cfg.StaleCrit != time.Hour {
t.Errorf("Overrides: %+v", cfg)
}
env := map[string]string{"SULTAN_PUBLIC_URL": "https://a:8444,https://b:8444"}
cfg, err = parseAccountantFlags([]string{"--data-dir=/srv/acc", "--addr=:9444"}, &bytes.Buffer{},
func(k string) string { return env[k] })
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/acc" || cfg.Addr != ":9444" || strings.Join(cfg.PublicURLs, " ") != "https://a:8444 https://b:8444" {
t.Errorf("Overrides: %+v", cfg)
}
}
// Einladung mit gemerkter Adresse und Fingerprint des Accountants; in der
// DB liegt nur der Hash.
func TestAccountantInvite(t *testing.T) {
dir := initAccDir(t, "https://acc.example.de:8444")
code, stdout, stderr := acc(t, dir, "invite", "--payee=webproxy11")
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
tk, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if err != nil {
t.Fatalf("Ausgabe ist keine Einladung: %q (%v)", stdout, err)
}
cert, _ := crypto.LoadOrCreateServerCert(dir)
if tk.Fingerprint != cert.Fingerprint || tk.ServerURL != "https://acc.example.de:8444" {
t.Errorf("Einladung %+v", tk)
}
if !strings.Contains(stderr, "webproxy11") || !strings.Contains(stderr, "SULTAN_ACCOUNTANT_TOKEN") {
t.Errorf("Hinweis fehlt: %q", stderr)
}
// Gleiche Einladung lässt sich im Ledger einlösen (Hash passt).
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
if err != nil {
t.Fatal(err)
}
defer l.Close()
if _, err := l.Enroll(context.Background(), tk.Secret, ledger.Payee{Hostname: "h", PublicKey: "k"}); err != nil {
t.Errorf("Einladung nicht einlösbar: %v", err)
}
// --server-url überschreibt, --ttl wird geprüft.
code, stdout, _ = acc(t, dir, "invite", "--payee=webproxy12", "--server-url=https://other:8444", "--ttl=7d")
if tk, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); code != 0 || tk.ServerURL != "https://other:8444" {
t.Errorf("--server-url: code=%d %+v", code, tk)
}
for _, args := range [][]string{
{"invite"}, // --payee fehlt
{"invite", "--payee=webproxy13", "--ttl=x"}, // ttl unlesbar
{"invite", "--payee=webproxy13", "--server-url=http://klartext"},
} {
if code, _, _ := acc(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d, erwartet 2", args, code)
}
}
if code, _, stderr := acc(t, dir, "invite", "--payee=webproxy11"); code != 1 || !strings.Contains(stderr, "existiert") {
t.Errorf("schon registriert: code=%d stderr=%q", code, stderr)
}
}
func TestAccountantPayees(t *testing.T) {
dir := initAccDir(t)
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "ID") || strings.Contains(stdout, "webproxy11") {
t.Errorf("leere Liste: code=%d %q", code, stdout)
}
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "webproxy11.lan.r25dc.de", PublicKey: "k", IPAddress: "10.0.0.5"})
l.Close()
code, stdout, _ := acc(t, dir, "payee", "list")
if code != 0 || !strings.Contains(stdout, "webproxy11") || !strings.Contains(stdout, "webproxy11.lan.r25dc.de") ||
!strings.Contains(stdout, "10.0.0.5") {
t.Errorf("Liste: code=%d %q", code, stdout)
}
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 0 {
t.Errorf("delete: code=%d", code)
}
if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 1 {
t.Errorf("zweites delete: code=%d", code)
}
}
// Ein Tippfehler bei --data-dir legt keine neue Accountant-Identität an.
func TestAccountant_UninitializedDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "tippfehler")
if code, _, stderr := acc(t, dir, "payee", "list"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
if code, _, stderr := acc(t, dir, "invite", "--payee=x1"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("invite: code=%d stderr=%q", code, stderr)
}
}
func TestAccountant_Usage(t *testing.T) {
if code, stdout, _ := run(context.Background(), "accountant", "help"); code != 0 || !strings.Contains(stdout, "invite") {
t.Errorf("help: code=%d %q", code, stdout)
}
if code, _, stderr := run(context.Background(), "accountant", "gibts-nicht"); code != 2 || !strings.Contains(stderr, "unbekannt") {
t.Errorf("unbekannt: code=%d %q", code, stderr)
}
if code, stdout, _ := run(context.Background(), "help"); code != 0 || !strings.Contains(stdout, "sultan accountant") {
t.Errorf("Hauptusage ohne accountant: %q", stdout)
}
}
// payee list zeigt den letzten Bericht, payee report seinen Inhalt.
func TestAccountantPayeeReport(t *testing.T) {
dir := initAccDir(t)
l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "h", PublicKey: "k", IPAddress: "10.0.0.5"})
if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "LAST_REPORT") || !strings.Contains(stdout, "nie") {
t.Errorf("ohne Bericht: code=%d %q", code, stdout)
}
if code, _, stderr := acc(t, dir, "payee", "report", "--id=webproxy11"); code != 1 || !strings.Contains(stderr, "nicht gefunden") {
t.Errorf("report ohne Bericht: code=%d %q", code, stderr)
}
l.SaveReport(context.Background(), "webproxy11", []byte(`{"version":"v1","routes":[{"hosts":["kunde.de"]}]}`), nil)
l.Close()
code, stdout, _ := acc(t, dir, "payee", "list")
if code != 0 || strings.Contains(stdout, "nie") || !strings.Contains(stdout, time.Now().Format("2006-01-02")) {
t.Errorf("mit Bericht: code=%d %q", code, stdout)
}
code, stdout, _ = acc(t, dir, "payee", "report", "--id=webproxy11")
if code != 0 || !strings.Contains(stdout, `"kunde.de"`) || !strings.Contains(stdout, "\n ") {
t.Errorf("report: code=%d %q (erwartet eingerücktes JSON)", code, stdout)
}
if code, _, _ := acc(t, dir, "payee", "report"); code != 2 {
t.Errorf("report ohne --id: code=%d", code)
}
}
// dump-checkmk legt die eingebaute Vorlage zum Anpassen ins Datenverzeichnis.
func TestAccountantDumpCheckmk(t *testing.T) {
dir := initAccDir(t)
code, stdout, _ := acc(t, dir, "dump-checkmk")
want := filepath.Join(dir, "checkmk", accountant.CheckmkTemplateName)
if code != 0 || strings.TrimSpace(stdout) != want {
t.Fatalf("code=%d stdout=%q", code, stdout)
}
if code, _, stderr := acc(t, dir, "dump-checkmk"); code != 1 || !strings.Contains(stderr, "--force") {
t.Errorf("zweiter Lauf: code=%d %q", code, stderr)
}
if code, _, _ := acc(t, dir, "dump-checkmk", "--force"); code != 0 {
t.Errorf("--force: code=%d", code)
}
// Nicht initialisiertes Verzeichnis: nichts anlegen.
if code, _, _ := acc(t, filepath.Join(t.TempDir(), "tippfehler"), "dump-checkmk"); code != 1 {
t.Errorf("Tippfehler: code=%d", code)
}
}
// accReport legt einen Bericht samt Historie ab, wie es der Endpunkt tut.
func accReport(t *testing.T, dir, payeeID string, at time.Time, rep accountant.Report) {
t.Helper()
l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db"))
if err != nil {
t.Fatal(err)
}
defer l.Close()
l.SetClock(func() time.Time { return at })
if list, _ := l.ListPayees(context.Background()); !slices.ContainsFunc(list, func(p ledger.Payee) bool { return p.ID == payeeID }) {
l.AddInvite(context.Background(), "tok-"+payeeID, payeeID, at.Add(time.Hour))
if _, err := l.Enroll(context.Background(), "tok-"+payeeID, ledger.Payee{Hostname: payeeID, PublicKey: "k-" + payeeID}); err != nil {
t.Fatal(err)
}
}
if err := accountant.StoreReport(context.Background(), l, payeeID, rep); err != nil {
t.Fatal(err)
}
}
func cliReport(node string, routes ...accountant.ReportRoute) accountant.Report {
return accountant.Report{
GeneratedAt: time.Now(),
Tenants: []accountant.ReportTenant{{ID: "kunde", Name: "Kunde A"}},
Nodes: []accountant.ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true,
OS: "Debian 13", DockerVersion: "29.8.2"}},
Routes: routes,
}
}
func cliRoute(node, router string, hosts ...string) accountant.ReportRoute {
return accountant.ReportRoute{Tenant: "kunde", Node: node, Router: router, Protocol: "http", Hosts: hosts,
Workload: "kunde-a-" + router + "-1", Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081",
Active: true, ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"}
}
// where: Störfall – wohin springen, seit wann, wo vorher?
func TestAccountantWhere(t *testing.T) {
dir := initAccDir(t)
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
accReport(t, dir, "webproxy11", t1, cliReport("webdocker07", cliRoute("webdocker07", "wp", "kunde.de"),
cliRoute("webdocker07", "olat", "olat.kunde.de")))
t2 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local)
accReport(t, dir, "webproxy11", t2, cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de")))
code, stdout, stderr := acc(t, dir, "where", "kunde.de")
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
for _, want := range []string{
"kunde.de, www.kunde.de", "Router wp", "kunde (Kunde A)", "webproxy11", "webdocker11.lan.r25dc.de:8081",
"kunde-a-wp-1", "/srv/kunde-a", "healthy", "Debian 13", "2026-10-01 10:00",
"webdocker07", "2026-09-01 10:00", // vorher
"olat.kunde.de", // verschwunden
} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q in:\n%s", want, stdout)
}
}
if code, _, stderr := acc(t, dir, "where", "gibts-nicht.de"); code != 1 || !strings.Contains(stderr, "nichts gefunden") {
t.Errorf("kein Treffer: code=%d %q", code, stderr)
}
if code, _, _ := acc(t, dir, "where"); code != 2 {
t.Errorf("ohne Suchbegriff: code=%d", code)
}
}
func TestAccountantRoutes(t *testing.T) {
dir := initAccDir(t)
down := cliRoute("webdocker11", "olat", "olat.kunde.de")
down.Down = true
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"), down))
code, stdout, _ := acc(t, dir, "routes")
if code != 0 || !strings.Contains(stdout, "HOST") || !strings.Contains(stdout, "webdocker11.lan.r25dc.de:8081") {
t.Fatalf("code=%d\n%s", code, stdout)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) != 3 || !strings.HasPrefix(lines[1], "kunde.de") || !strings.Contains(lines[2], "gestoppt") {
t.Errorf("Tabelle:\n%s", stdout)
}
if code, stdout, _ := acc(t, dir, "routes", "--tenant=andere"); code != 0 || strings.Contains(stdout, "kunde.de") {
t.Errorf("--tenant: %q", stdout)
}
}
// Kunden: anlegen, ändern (nur angegebene Felder), filtern, zuordnen.
func TestAccountantCustomers(t *testing.T) {
dir := initAccDir(t)
if code, _, stderr := acc(t, dir, "customer", "add", "--id=familie", "--name=Familie", "--group=familie", "--cycle=j"); code != 0 {
t.Fatalf("add: code=%d %s", code, stderr)
}
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de")))
// Automatisch angelegt: kunde.
code, stdout, _ := acc(t, dir, "customer", "list", "--unclassified")
if code != 0 || !strings.Contains(stdout, "kunde") || strings.Contains(stdout, "familie") || !strings.Contains(stdout, "nicht eingeordnet") {
t.Errorf("--unclassified: code=%d\n%s", code, stdout)
}
if code, _, stderr := acc(t, dir, "customer", "set", "--id=kunde", "--cycle=quarterly", "--flat-rate=89,00",
"--note=Kumpel – Nebenabrede Bier", "--group=extern"); code != 0 {
t.Fatalf("set: code=%d %s", code, stderr)
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--name=Kunde A GmbH"); code != 0 { // nur der Name
t.Fatal("set --name")
}
code, stdout, _ = acc(t, dir, "customer", "list")
for _, want := range []string{"Kunde A GmbH", "quartalsweise", "89,00", "extern", "Nebenabrede", "jährlich"} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q:\n%s", want, stdout)
}
}
if code, stdout, _ := acc(t, dir, "customer", "list", "--group=familie"); code != 0 || strings.Contains(stdout, "kunde ") || !strings.Contains(stdout, "Familie") {
t.Errorf("--group:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--no-flat-rate"); code != 0 {
t.Error("--no-flat-rate")
}
// Tippfehler-Mandant auf Nebencluster zuordnen; where zeigt den Kunden.
if code, _, stderr := acc(t, dir, "customer", "map", "--tenant=kunde-a", "--payee=webproxy12", "--customer=kunde"); code != 0 {
t.Fatalf("map: %d %s", code, stderr)
}
code, stdout, _ = acc(t, dir, "customer", "list")
if !strings.Contains(stdout, "webproxy12/kunde-a") {
t.Errorf("Zuordnung nicht gelistet:\n%s", stdout)
}
if _, stdout, _ := acc(t, dir, "where", "kunde.de"); !strings.Contains(stdout, "Kunde A GmbH") || !strings.Contains(stdout, "quartalsweise") {
t.Errorf("where ohne Kunde:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "customer", "unmap", "--tenant=kunde-a", "--payee=webproxy12"); code != 0 {
t.Error("unmap")
}
for _, args := range [][]string{
{"customer", "add", "--id=x1"}, // --name fehlt
{"customer", "add", "--id=x1", "--name=X", "--cycle=w"}, // Zahlweise unbekannt
{"customer", "set", "--id=kunde", "--flat-rate=abc"},
{"customer", "set", "--id=kunde", "--flat-rate=1", "--no-flat-rate"},
{"customer", "map", "--tenant=x"},
} {
if code, _, _ := acc(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d, erwartet 2", args, code)
}
}
if code, _, _ := acc(t, dir, "customer", "delete", "--id=familie"); code != 0 {
t.Error("delete")
}
if code, _, _ := acc(t, dir, "customer", "set", "--id=familie", "--name=x"); code != 1 {
t.Error("set nach delete")
}
}
// Katalog: Klasse und Preis mit Gültig-ab, Kundenpreis.
func TestAccountantProducts(t *testing.T) {
dir := initAccDir(t)
mustAcc := func(args ...string) string {
t.Helper()
code, stdout, stderr := acc(t, dir, args...)
if code != 0 {
t.Fatalf("%v: code=%d %s", args, code, stderr)
}
return stdout
}
mustAcc("product", "add", "--id=pingvin", "--name=Pingvin Share", "--class=free")
mustAcc("product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
mustAcc("product", "set", "--id=pingvin", "--from=2027-03", "--class=billable", "--price=5,00")
mustAcc("product", "set", "--id=olat", "--from=2027-01", "--price=52,00") // Klasse bleibt
mustAcc("customer", "add", "--id=k-1001", "--name=Kunde A")
mustAcc("product", "price", "--id=olat", "--customer=k-1001", "--price=39,00", "--from=2026-11")
out := mustAcc("product", "list")
for _, want := range []string{"pingvin", "Pingvin Share", "ab 2027-03: billable 5,00", "olat", "49,00", "ab 2027-01: billable 52,00"} {
if !strings.Contains(out, want) {
t.Errorf("fehlt %q:\n%s", want, out)
}
}
for _, args := range [][]string{
{"product", "add", "--id=x1", "--name=X", "--class=teuer"},
{"product", "add", "--id=x1", "--name=X"}, // --class fehlt
{"product", "set", "--id=olat", "--from=März"},
{"product", "set", "--id=olat", "--from=2027-02", "--price=1", "--no-price"},
{"product", "price", "--id=olat", "--customer=k-1001"}, // --price fehlt
} {
if code, _, _ := acc(t, dir, args...); code != 2 && code != 1 {
t.Errorf("%v: code=%d, erwartet Fehler", args, code)
}
}
if code, _, _ := acc(t, dir, "product", "set", "--id=gibts-nicht", "--from=2027-01", "--class=free"); code != 1 {
t.Errorf("unbekanntes Produkt: code=%d", code)
}
}
// assign: Produkt, Klasse, Beginn je Posten – bequem über den Host.
func TestAccountantAssign(t *testing.T) {
dir := initAccDir(t)
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable")
accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"),
cliRoute("webdocker11", "olat", "olat.kunde.de")))
if code, _, stderr := acc(t, dir, "assign", "--host=olat.kunde.de", "--product=olat", "--start=2026-11"); code != 0 {
t.Fatalf("assign: %d %s", code, stderr)
}
if code, _, stderr := acc(t, dir, "assign", "--tenant=kunde", "--router=wp", "--class=internal"); code != 0 {
t.Fatalf("assign --router: %d %s", code, stderr)
}
code, stdout, _ := acc(t, dir, "assignments")
if code != 0 || !strings.Contains(stdout, "olat.kunde.de") || !strings.Contains(stdout, "2026-11") || !strings.Contains(stdout, "internal") {
t.Errorf("assignments:\n%s", stdout)
}
if code, _, _ := acc(t, dir, "assign", "--host=www.kunde.de", "--clear"); code != 0 {
t.Error("--clear")
}
if _, stdout, _ := acc(t, dir, "assignments"); strings.Contains(stdout, "internal") {
t.Errorf("nach --clear:\n%s", stdout)
}
for _, args := range [][]string{
{"assign", "--host=gibts-nicht.de", "--class=free"},
{"assign", "--host=kunde.de", "--product=gibts-nicht"},
{"assign", "--host=kunde.de"}, // nichts zugeordnet
{"assign", "--class=free"}, // weder --host noch --tenant/--router
} {
if code, _, _ := acc(t, dir, args...); code == 0 {
t.Errorf("%v akzeptiert", args)
}
}
}
// billing: Monats-CSV auf stdout bzw. in eine Datei.
func TestAccountantBilling(t *testing.T) {
dir := initAccDir(t)
acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00")
r := cliRoute("webdocker11", "olat", "olat.kunde.de")
r.Product = "olat"
accReport(t, dir, "webproxy11", time.Date(2026, 10, 17, 10, 0, 0, 0, time.Local), cliReport("webdocker11", r))
acc(t, dir, "customer", "set", "--id=kunde", "--cycle=q", "--flat-rate=89,00")
code, stdout, stderr := acc(t, dir, "billing", "--month=2026-10")
if code != 0 {
t.Fatalf("code=%d %s", code, stderr)
}
for _, want := range []string{"Monat;Kunde", "2026-10;kunde;Kunde A;;quartalsweise;Pauschale", "89,00;267,00",
"olat.kunde.de", "billable;olat", "49,00;147,00"} {
if !strings.Contains(stdout, want) {
t.Errorf("fehlt %q:\n%s", want, stdout)
}
}
out := filepath.Join(t.TempDir(), "2026-10.csv")
if code, _, _ := acc(t, dir, "billing", "--month=2026-10", "--out="+out); code != 0 {
t.Fatal("--out")
}
if data, _ := os.ReadFile(out); !strings.Contains(string(data), "olat.kunde.de") {
t.Errorf("Datei: %s", data)
}
if code, _, _ := acc(t, dir, "billing"); code != 2 {
t.Errorf("ohne --month: code=%d", code)
}
if code, _, _ := acc(t, dir, "billing", "--month=Oktober"); code != 2 {
t.Errorf("ungültiger Monat: code=%d", code)
}
}