Files
sultan/pkg/cli/accountant_test.go

472 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}