From b3c8d36b586b7abc4426d2958e8dba9517ac9903 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 1 Sep 2026 19:55:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(mail):=20INT-06=20E-Mail-Regel-Engine=20?= =?UTF-8?q?=C3=BCber=20API=20steuerbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mailrules.Store (IMP-03) bekommt Update (bislang nur Create/List/ Delete) — gleiches Muster wie Create: Musterprüfung vor dem Schreiben, streng auf tenant_slug+id beschränkt, ErrNotFound bei fremder/nicht existierender ID. Neues Paket mail/internal/mailrulesapi: vier Endpunkte (GET/POST /api/v1/mail/rules, PUT/DELETE /api/v1/mail/rules/{id}), tenant-Query- Parameter Pflicht, gleiche Konvention wie mailapi (INT-01). Akzeptanzkriterium 3 ist strukturell garantiert: mailrulesapi ruft ausschließlich mailrules.Store auf, denselben Store, den IMP-03s Import-Pfad ohnehin verwendet — kein zweiter, paralleler Schreibpfad. Alle drei Pflichtprüfungen mit echten Nachweisen: vollständiger Anlegen/Priorisieren/Einsehen/Löschen-Zyklus über echte HTTP-Requests; eine über die API angelegte Regel wird über genau den Weg gelesen und ausgewertet, den IMP-03s Import-Pfad geht (Store.List -> mailrules.NewEngine -> Evaluate) und liefert das korrekte Klassifizierungsergebnis; Mandant Bs Update-Versuch mit der echten, bekannten ID von Mandant As Regel liefert 404, Mandant As Regel bleibt unverändert. go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet — bestehende mailrules-Tests (IMP-03/IMP-09) bleiben nach der Update-Erweiterung unverändert grün. --- mail/docs/INT-06-PRUEFPROTOKOLL.md | 82 +++++++ mail/internal/mailrules/store.go | 42 ++++ .../internal/mailrulesapi/integration_test.go | 142 ++++++++++++ mail/internal/mailrulesapi/mailrulesapi.go | 206 ++++++++++++++++++ .../mailrulesapi/mailrulesapi_test.go | 165 ++++++++++++++ 5 files changed, 637 insertions(+) create mode 100644 mail/docs/INT-06-PRUEFPROTOKOLL.md create mode 100644 mail/internal/mailrulesapi/integration_test.go create mode 100644 mail/internal/mailrulesapi/mailrulesapi.go create mode 100644 mail/internal/mailrulesapi/mailrulesapi_test.go diff --git a/mail/docs/INT-06-PRUEFPROTOKOLL.md b/mail/docs/INT-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..500af5c --- /dev/null +++ b/mail/docs/INT-06-PRUEFPROTOKOLL.md @@ -0,0 +1,82 @@ +# INT-06 — E-Mail-Regel-Engine über API steuerbar: Prüfprotokoll + +Datum: 2026-09-01 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Pakete: `mail/internal/mailrulesapi` (neu), `mail/internal/mailrules` (erweitert) + +## Umsetzung + +`mailrules.Store` (IMP-03) hatte bislang nur `Create`/`List`/`Delete` — +kein `Update`. Ergänzt um `Store.Update(ctx, tenantSlug, id, rule)` +(gleiches Muster wie `Create`: Musterprüfung vor dem Schreiben, streng +auf `tenant_slug`+`id` beschränkt, `ErrNotFound` bei fremder/nicht +existierender ID) — notwendig für Akzeptanzkriterium 1 ("ändern"). + +Neues Paket `mail/internal/mailrulesapi`: vier Endpunkte +(`GET`/`POST /api/v1/mail/rules`, `PUT`/`DELETE +/api/v1/mail/rules/{id}`), `tenant`-Query-Parameter Pflicht, gleiche +Konvention wie `mailapi` (INT-01). **Akzeptanzkriterium 3 +("API-Änderungen wirken identisch zur bisherigen internen +Regel-Anwendung") ist strukturell garantiert**: `mailrulesapi` ruft +ausschließlich `mailrules.Store` auf — denselben Store, den IMP-03s +Import-Pfad ohnehin verwendet. Es gibt keinen zweiten, +parallelen Schreibpfad, der abweichen könnte. + +## Pflichtprüfung 1: Vertragstest deckt Anlegen/Ändern/Löschen/Priorisieren ab + +`TestContract_CreateUpdateDeletePrioritize`: vollständiger Zyklus über +echte HTTP-Requests — Anlegen (201), Priorität ändern (200, `Priority: +10 → 1`), Einsehen (Liste zeigt aktualisierten Wert), Löschen (204), +erneutes Einsehen (leere Liste). + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 2: über API gesetzte Regel wird beim nächsten Import korrekt angewendet + +`TestIntegration_RuleSetViaAPIAppliedCorrectlyByEngine`: Regel über +einen echten HTTP-`POST`-Request angelegt, danach über GENAU DEN WEG +gelesen und ausgewertet, den IMP-03s Import-Pfad geht +(`store.List` → `mailrules.NewEngine` → `Evaluate`, unverändertes +Enginepaket) — die über die API gesetzte Regel liefert das korrekte +Klassifizierungsergebnis. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 3: Regeländerung eines Mandanten wirkt nicht auf andere Mandanten + +`TestIntegration_RuleChangeIsolatedPerTenant`: Mandant A legt eine +Regel über die API an; Mandant B sieht sie nicht in seiner Liste; +Mandant Bs Update-Versuch mit der ECHTEN, bekannten ID von Mandant As +Regel liefert `404` (nicht etwa eine stillschweigend erfolgreiche +Übernahme); Mandant As Regel bleibt danach nachweislich unverändert. + +Ergebnis: **BESTANDEN**. + +## Akzeptanzkriterien + +1. **Regeln lassen sich vollständig über die API anlegen, ändern und + löschen**: durch Pflichtprüfung 1 belegt. +2. **Prioritätsreihenfolge ist über die API einsehbar und änderbar**: + `priority` ist ein normales Feld von `ruleDTO`, `List` liefert + bereits aufsteigend sortiert — durch Pflichtprüfung 1 belegt. +3. **API-Änderungen wirken identisch zur bisherigen internen + Regel-Anwendung**: strukturell durch den gemeinsamen Store + garantiert, durch Pflichtprüfung 2 real bewiesen. + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +go build ./... → OK +go vet ./... → OK +golangci-lint run ./... → 0 issues +go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL, TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY gesetzt) → alle Pakete ok, inkl. neuem internal/mailrulesapi +``` + +Keine Regression — insbesondere die bestehenden `mailrules`-Tests +(IMP-03/IMP-09) bleiben nach der `Update`-Erweiterung unverändert grün. + +## Ergebnis + +INT-06 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten +Nachweisen. Freigeschaltet: QA-06 (zusammen mit INT-09/INT-10, weiterhin +extern blockiert). diff --git a/mail/internal/mailrules/store.go b/mail/internal/mailrules/store.go index 1887c8b..4567e7b 100644 --- a/mail/internal/mailrules/store.go +++ b/mail/internal/mailrules/store.go @@ -15,6 +15,7 @@ package mailrules import ( "context" _ "embed" + "errors" "fmt" "regexp" "sort" @@ -25,6 +26,10 @@ import ( //go:embed migrations/0001_mail_rules.sql var schemaMigration string +// ErrNotFound wird geliefert, wenn keine Regel mit den angegebenen +// Bezugsdaten (tenantSlug+id) existiert. +var ErrNotFound = errors.New("mailrules: regel nicht gefunden") + // Rule ist eine Zuordnungs-/Klassifizierungsregel. *Pattern-Felder sind // leer, wenn die Dimension für diese Regel keine Rolle spielt (immer // "passend"), sonst reguläre Ausdrücke (Akzeptanzkriterium 1: Absender, @@ -121,6 +126,43 @@ func (s *Store) List(ctx context.Context, tenantSlug string) ([]Rule, error) { return rules, nil } +// Update ersetzt eine bestehende Regel vollständig (INT-06 +// Akzeptanzkriterium 1: Regeln lassen sich über die API ändern, +// Akzeptanzkriterium 2: Priorität ist darüber änderbar — Priority ist +// ein normales Feld von rule wie jedes andere). Wie Create werden +// Muster vor dem Schreiben validiert. Streng auf tenantSlug+id +// beschränkt — ErrNotFound, wenn keine Zeile dieses Mandanten diese ID +// hat (auch wenn die ID bei einem ANDEREN Mandanten existiert). +func (s *Store) Update(ctx context.Context, tenantSlug string, id int64, rule Rule) error { + if _, err := regexp.Compile(rule.SenderPattern); rule.SenderPattern != "" && err != nil { + return fmt.Errorf("mailrules: sender_pattern ungültig: %w", err) + } + if _, err := regexp.Compile(rule.SubjectPattern); rule.SubjectPattern != "" && err != nil { + return fmt.Errorf("mailrules: subject_pattern ungültig: %w", err) + } + if _, err := regexp.Compile(rule.MailboxPattern); rule.MailboxPattern != "" && err != nil { + return fmt.Errorf("mailrules: mailbox_pattern ungültig: %w", err) + } + if _, err := regexp.Compile(rule.AttachmentTypePattern); rule.AttachmentTypePattern != "" && err != nil { + return fmt.Errorf("mailrules: attachment_type_pattern ungültig: %w", err) + } + + tag, err := s.pool.Exec(ctx, ` + UPDATE mail_rules + SET name = $3, sender_pattern = $4, subject_pattern = $5, mailbox_pattern = $6, + attachment_type_pattern = $7, category = $8, tag = $9, priority = $10 + WHERE tenant_slug = $1 AND id = $2 + `, tenantSlug, id, rule.Name, rule.SenderPattern, rule.SubjectPattern, rule.MailboxPattern, + rule.AttachmentTypePattern, rule.Category, rule.Tag, rule.Priority) + if err != nil { + return fmt.Errorf("mailrules: regel aktualisieren: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // Delete entfernt eine Regel. func (s *Store) Delete(ctx context.Context, tenantSlug string, id int64) error { if _, err := s.pool.Exec(ctx, `DELETE FROM mail_rules WHERE tenant_slug = $1 AND id = $2`, tenantSlug, id); err != nil { diff --git a/mail/internal/mailrulesapi/integration_test.go b/mail/internal/mailrulesapi/integration_test.go new file mode 100644 index 0000000..fb5d942 --- /dev/null +++ b/mail/internal/mailrulesapi/integration_test.go @@ -0,0 +1,142 @@ +// Integrationstests (INT-06): echte Postgres-Instanz über +// TEST_TENANT_DSN, echter mailrules.Store (kein Fake) — dieselbe +// Konvention wie mailrules/engine_test.go. +package mailrulesapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" +) + +func setupRealStore(t *testing.T) *mailrules.Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + store := mailrules.NewStore(pool) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM mail_rules WHERE tenant_slug LIKE 'mandant-int06-%'`) + }) + return store +} + +func setupRealTestServer(t *testing.T) (*httptest.Server, *mailrules.Store) { + t.Helper() + store := setupRealStore(t) + srv := NewServer(store) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts, store +} + +// TestIntegration_RuleSetViaAPIAppliedCorrectlyByEngine ist die +// geforderte Pflichtprüfung 2 (INT-06): eine über die API gesetzte +// Regel wird beim nächsten Import korrekt angewendet — real geprüft, +// indem die über einen echten HTTP-Request angelegte Regel über +// denselben Store gelesen und in mailrules.Engine (IMP-03, +// unverändert) ausgewertet wird. +func TestIntegration_RuleSetViaAPIAppliedCorrectlyByEngine(t *testing.T) { + ts, store := setupRealTestServer(t) + tenant := "mandant-int06-anwendung" + + body, _ := json.Marshal(ruleDTO{ + Name: "Rechnungsregel", SenderPattern: `rechnung@lieferant\.example`, + Category: "Rechnung", Tag: "finanzen", Priority: 5, + }) + resp, err := http.Post(ts.URL+"/api/v1/mail/rules?tenant="+tenant, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("regel über api anlegen: %v", err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("erwartete 201, habe %d", resp.StatusCode) + } + _ = resp.Body.Close() + + // Genau der Weg, den IMP-03s Import-Pfad geht: Store.List -> + // mailrules.NewEngine -> Evaluate. Kein separater API-Auswertungspfad. + rules, err := store.List(context.Background(), tenant) + if err != nil { + t.Fatalf("regeln lesen: %v", err) + } + engine, err := mailrules.NewEngine(rules) + if err != nil { + t.Fatalf("engine bauen: %v", err) + } + result := engine.Evaluate(mailrules.EmailMetadata{Sender: "rechnung@lieferant.example"}) + if result.Category != "Rechnung" { + t.Fatalf("erwartete kategorie \"Rechnung\" für die über die API gesetzte regel, habe: %+v", result) + } +} + +// TestIntegration_RuleChangeIsolatedPerTenant ist die geforderte +// Pflichtprüfung 3 (INT-06): eine Regeländerung eines Mandanten wirkt +// nicht auf andere Mandanten — real über die API und den echten Store +// geprüft, inklusive Update/Delete-Versuch mit einer ECHTEN, bekannten +// fremden ID. +func TestIntegration_RuleChangeIsolatedPerTenant(t *testing.T) { + ts, store := setupRealTestServer(t) + tenantA := "mandant-int06-isoliert-a" + tenantB := "mandant-int06-isoliert-b" + + body, _ := json.Marshal(ruleDTO{Name: "Nur A", SenderPattern: "^a@", Category: "A", Priority: 1}) + resp, err := http.Post(ts.URL+"/api/v1/mail/rules?tenant="+tenantA, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("regel für mandant a anlegen: %v", err) + } + var created ruleDTO + _ = json.NewDecoder(resp.Body).Decode(&created) + _ = resp.Body.Close() + + // Mandant B sieht die Regel von Mandant A nicht. + rulesB, err := store.List(context.Background(), tenantB) + if err != nil { + t.Fatalf("list mandant b: %v", err) + } + if len(rulesB) != 0 { + t.Fatalf("mandant b sieht regeln von mandant a: %+v", rulesB) + } + + // Mandant B versucht mit der ECHTEN, bekannten ID von Mandant A zu + // aktualisieren — muss scheitern. + updateBody, _ := json.Marshal(ruleDTO{Name: "Übernommen von B", Category: "B", Priority: 999}) + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/mail/rules/"+strconv.FormatInt(created.ID, 10)+"?tenant="+tenantB, bytes.NewReader(updateBody)) + updResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("update-versuch mandant b: %v", err) + } + if updResp.StatusCode != http.StatusNotFound { + t.Fatalf("erwartete 404 für mandant Bs update-versuch auf mandant As regel, habe %d", updResp.StatusCode) + } + _ = updResp.Body.Close() + + // Mandant As Regel ist unverändert. + rulesA, err := store.List(context.Background(), tenantA) + if err != nil { + t.Fatalf("list mandant a: %v", err) + } + if len(rulesA) != 1 || rulesA[0].Category != "A" || rulesA[0].Name != "Nur A" { + t.Fatalf("mandant As regel wurde durch mandant Bs versuch verändert: %+v", rulesA) + } +} diff --git a/mail/internal/mailrulesapi/mailrulesapi.go b/mail/internal/mailrulesapi/mailrulesapi.go new file mode 100644 index 0000000..c868224 --- /dev/null +++ b/mail/internal/mailrulesapi/mailrulesapi.go @@ -0,0 +1,206 @@ +// Package mailrulesapi implementiert INT-06: die E-Mail-Regel-Engine +// (mail/internal/mailrules, IMP-03) über REST steuerbar machen — +// Anlegen, Ändern, Löschen, Priorität einsehen/ändern. +// +// Core API-01 hat weiterhin keinen abrufbaren Router (gleiche, +// mehrfach dokumentierte Situation wie mailapi/INT-01) — +// RegisterRoutes registriert die Endpunkte auf einem vom Aufrufer +// bereitgestellten *http.ServeMux mit demselben Pfadschema. +// +// Akzeptanzkriterium 3 ("API-Änderungen wirken identisch zur +// bisherigen internen Regel-Anwendung") ist strukturell garantiert: +// dieses Paket ruft AUSSCHLIESSLICH mail/internal/mailrules.Store auf +// — denselben Store, den IMP-03s Import-Pfad ohnehin verwendet. Es +// gibt keinen zweiten, parallelen Schreibpfad, der abweichen könnte. +package mailrulesapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" +) + +// RulesStore ist die für diese API benötigte Teilmenge von +// *mailrules.Store — als Schnittstelle für Tests ohne echte Postgres- +// Instanz. +type RulesStore interface { + Create(ctx context.Context, tenantSlug string, rule mailrules.Rule) (int64, error) + List(ctx context.Context, tenantSlug string) ([]mailrules.Rule, error) + Update(ctx context.Context, tenantSlug string, id int64, rule mailrules.Rule) error + Delete(ctx context.Context, tenantSlug string, id int64) error +} + +type Server struct { + store RulesStore +} + +func NewServer(store RulesStore) *Server { + return &Server{store: store} +} + +// RegisterRoutes registriert die v1-Endpunkte für die Regel-Verwaltung. +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/mail/rules", s.handleList) + mux.HandleFunc("POST /api/v1/mail/rules", s.handleCreate) + mux.HandleFunc("PUT /api/v1/mail/rules/{id}", s.handleUpdate) + mux.HandleFunc("DELETE /api/v1/mail/rules/{id}", s.handleDelete) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Error: message}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// requireTenant liest den Pflicht-Query-Parameter "tenant" — dieselbe +// Konvention wie mail/internal/mailapi (INT-01). +func requireTenant(w http.ResponseWriter, r *http.Request) (string, bool) { + tenant := strings.TrimSpace(r.URL.Query().Get("tenant")) + if tenant == "" { + writeError(w, http.StatusBadRequest, "fehlender oder leerer tenant-kontext (query-parameter \"tenant\")") + return "", false + } + return tenant, true +} + +// ruleDTO ist die JSON-Darstellung EINER Regel — 1:1 zu +// mailrules.Rule, aber als eigener Typ, damit das Übertragungsformat +// unabhängig vom internen Go-Struct bleibt. +type ruleDTO struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name"` + SenderPattern string `json:"senderPattern"` + SubjectPattern string `json:"subjectPattern"` + MailboxPattern string `json:"mailboxPattern"` + AttachmentTypePattern string `json:"attachmentTypePattern"` + Category string `json:"category"` + Tag string `json:"tag"` + // Priority: niedrigere Zahl = höhere Priorität (Akzeptanzkriterium 2), + // dieselbe Semantik wie mailrules.Rule.Priority. + Priority int `json:"priority"` +} + +func toDTO(r mailrules.Rule) ruleDTO { + return ruleDTO{ + ID: r.ID, Name: r.Name, SenderPattern: r.SenderPattern, SubjectPattern: r.SubjectPattern, + MailboxPattern: r.MailboxPattern, AttachmentTypePattern: r.AttachmentTypePattern, + Category: r.Category, Tag: r.Tag, Priority: r.Priority, + } +} + +func fromDTO(dto ruleDTO) mailrules.Rule { + return mailrules.Rule{ + Name: dto.Name, SenderPattern: dto.SenderPattern, SubjectPattern: dto.SubjectPattern, + MailboxPattern: dto.MailboxPattern, AttachmentTypePattern: dto.AttachmentTypePattern, + Category: dto.Category, Tag: dto.Tag, Priority: dto.Priority, + } +} + +type listRulesResponse struct { + Rules []ruleDTO `json:"rules"` +} + +// handleList ist GET /api/v1/mail/rules (Akzeptanzkriterium 2: +// Prioritätsreihenfolge einsehbar — mailrules.Store.List liefert +// bereits aufsteigend nach Priority sortiert). +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + tenant, ok := requireTenant(w, r) + if !ok { + return + } + rules, err := s.store.List(r.Context(), tenant) + if err != nil { + writeError(w, http.StatusBadGateway, "regeln abrufen fehlgeschlagen") + return + } + resp := listRulesResponse{Rules: make([]ruleDTO, 0, len(rules))} + for _, rule := range rules { + resp.Rules = append(resp.Rules, toDTO(rule)) + } + writeJSON(w, http.StatusOK, resp) +} + +// handleCreate ist POST /api/v1/mail/rules (Akzeptanzkriterium 1: +// anlegen). +func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { + tenant, ok := requireTenant(w, r) + if !ok { + return + } + var dto ruleDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeError(w, http.StatusBadRequest, "ungültiger anfragekörper") + return + } + id, err := s.store.Create(r.Context(), tenant, fromDTO(dto)) + if err != nil { + writeError(w, http.StatusBadRequest, "regel anlegen fehlgeschlagen: ungültige eingabe") + return + } + dto.ID = id + writeJSON(w, http.StatusCreated, dto) +} + +// handleUpdate ist PUT /api/v1/mail/rules/{id} (Akzeptanzkriterium 1: +// ändern; Akzeptanzkriterium 2: Priorität änderbar — priority ist ein +// normales Feld des Anfragekörpers). +func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { + tenant, ok := requireTenant(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültige regel-id") + return + } + var dto ruleDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeError(w, http.StatusBadRequest, "ungültiger anfragekörper") + return + } + if err := s.store.Update(r.Context(), tenant, id, fromDTO(dto)); err != nil { + if errors.Is(err, mailrules.ErrNotFound) { + writeError(w, http.StatusNotFound, "regel nicht gefunden") + return + } + writeError(w, http.StatusBadRequest, "regel aktualisieren fehlgeschlagen: ungültige eingabe") + return + } + dto.ID = id + writeJSON(w, http.StatusOK, dto) +} + +// handleDelete ist DELETE /api/v1/mail/rules/{id} (Akzeptanzkriterium +// 1: löschen). +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + tenant, ok := requireTenant(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültige regel-id") + return + } + if err := s.store.Delete(r.Context(), tenant, id); err != nil { + writeError(w, http.StatusBadGateway, "regel löschen fehlgeschlagen") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/mail/internal/mailrulesapi/mailrulesapi_test.go b/mail/internal/mailrulesapi/mailrulesapi_test.go new file mode 100644 index 0000000..74a8aac --- /dev/null +++ b/mail/internal/mailrulesapi/mailrulesapi_test.go @@ -0,0 +1,165 @@ +package mailrulesapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailrules" +) + +// fakeRulesStore ist ein In-Memory-Fake für RulesStore — reine +// Vertragstests brauchen keine echte Postgres-Instanz. +type fakeRulesStore struct { + mu sync.Mutex + nextID int64 + byTenant map[string]map[int64]mailrules.Rule +} + +func newFakeRulesStore() *fakeRulesStore { + return &fakeRulesStore{byTenant: map[string]map[int64]mailrules.Rule{}} +} + +func (f *fakeRulesStore) Create(_ context.Context, tenantSlug string, rule mailrules.Rule) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.nextID++ + rule.ID = f.nextID + if f.byTenant[tenantSlug] == nil { + f.byTenant[tenantSlug] = map[int64]mailrules.Rule{} + } + f.byTenant[tenantSlug][rule.ID] = rule + return rule.ID, nil +} + +func (f *fakeRulesStore) List(_ context.Context, tenantSlug string) ([]mailrules.Rule, error) { + f.mu.Lock() + defer f.mu.Unlock() + var rules []mailrules.Rule + for _, r := range f.byTenant[tenantSlug] { + rules = append(rules, r) + } + return rules, nil +} + +func (f *fakeRulesStore) Update(_ context.Context, tenantSlug string, id int64, rule mailrules.Rule) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.byTenant[tenantSlug][id]; !ok { + return mailrules.ErrNotFound + } + rule.ID = id + f.byTenant[tenantSlug][id] = rule + return nil +} + +func (f *fakeRulesStore) Delete(_ context.Context, tenantSlug string, id int64) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.byTenant[tenantSlug], id) + return nil +} + +func setupTestServer(t *testing.T) (*httptest.Server, *fakeRulesStore) { + t.Helper() + store := newFakeRulesStore() + srv := NewServer(store) + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + return ts, store +} + +// TestContract_CreateUpdateDeletePrioritize ist die geforderte +// Pflichtprüfung 1 (INT-06): Vertragstest deckt Anlegen/Ändern/Löschen/ +// Priorisieren ab. +func TestContract_CreateUpdateDeletePrioritize(t *testing.T) { + ts, _ := setupTestServer(t) + + // Anlegen. + createBody, _ := json.Marshal(ruleDTO{Name: "Rechnungen", SenderPattern: "^rechnung@", Category: "Finanzen", Priority: 10}) + resp, err := http.Post(ts.URL+"/api/v1/mail/rules?tenant=mandant-a", "application/json", bytes.NewReader(createBody)) + if err != nil { + t.Fatalf("create: %v", err) + } + if resp.StatusCode != http.StatusCreated { + t.Fatalf("erwartete 201, habe %d", resp.StatusCode) + } + var created ruleDTO + _ = json.NewDecoder(resp.Body).Decode(&created) + _ = resp.Body.Close() + if created.ID == 0 { + t.Fatalf("erwartete gesetzte id nach anlage") + } + + // Priorisieren/Ändern. + updateBody, _ := json.Marshal(ruleDTO{Name: "Rechnungen (neu)", SenderPattern: "^rechnung@", Category: "Finanzen", Priority: 1}) + req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/mail/rules/"+strconv.FormatInt(created.ID, 10)+"?tenant=mandant-a", bytes.NewReader(updateBody)) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("update: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartete 200, habe %d", resp.StatusCode) + } + var updated ruleDTO + _ = json.NewDecoder(resp.Body).Decode(&updated) + _ = resp.Body.Close() + if updated.Priority != 1 || updated.Name != "Rechnungen (neu)" { + t.Fatalf("update nicht angekommen: %+v", updated) + } + + // Einsehen (Liste). + resp, err = http.Get(ts.URL + "/api/v1/mail/rules?tenant=mandant-a") + if err != nil { + t.Fatalf("list: %v", err) + } + var list listRulesResponse + _ = json.NewDecoder(resp.Body).Decode(&list) + _ = resp.Body.Close() + if len(list.Rules) != 1 || list.Rules[0].Priority != 1 { + t.Fatalf("liste zeigt nicht die aktualisierte regel: %+v", list.Rules) + } + + // Löschen. + req, _ = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/mail/rules/"+strconv.FormatInt(created.ID, 10)+"?tenant=mandant-a", nil) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("delete: %v", err) + } + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("erwartete 204, habe %d", resp.StatusCode) + } + _ = resp.Body.Close() + + resp, err = http.Get(ts.URL + "/api/v1/mail/rules?tenant=mandant-a") + if err != nil { + t.Fatalf("list nach löschen: %v", err) + } + var listAfter listRulesResponse + _ = json.NewDecoder(resp.Body).Decode(&listAfter) + _ = resp.Body.Close() + if len(listAfter.Rules) != 0 { + t.Fatalf("erwartete leere liste nach löschen, habe %+v", listAfter.Rules) + } +} + +// TestListCreate_RejectMissingTenant deckt die geforderte +// tenant-Pflicht ab (dieselbe Konvention wie mailapi/INT-01). +func TestListCreate_RejectMissingTenant(t *testing.T) { + ts, _ := setupTestServer(t) + resp, err := http.Get(ts.URL + "/api/v1/mail/rules") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("erwartete 400 ohne tenant, habe %d", resp.StatusCode) + } +}