Merge remote-tracking branch 'origin/feature/tsk-03-erinnerungen-eskalationsregeln' into merge-batch-4
This commit is contained in:
commit
01c60539df
5 files changed
+576
No files matched your search
@@ -0,0 +1,37 @@
|
||||
# TSK-03 – Prüfprotokoll: Erinnerungen & Eskalationsregeln
|
||||
|
||||
Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204, Wegwerf-Cluster, Datenbank über `TEST_TENANT_DSN` (aus `/etc/nexarch-dev/dms-dev.env`).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Neues Paket `workflow/internal/escalation`, baut auf `task` (TSK-01) und `calendar`/`tasks.due_date` (TSK-02) auf, ohne deren Code zu ändern (additive Migration `0013_task_reminders_escalation`):
|
||||
|
||||
- `task_escalation_rules`: je Prozesstyp konfigurierbare Liste von Erinnerungs-Vorlaufzeiten (`reminder_days_before`, Tage vor Fälligkeit) und eine Eskalations-Aktion (`escalation_action`, freier Bezeichner — der tatsächliche Versand über den Core-Dispatcher ist TSK-05, hier nur die Regel).
|
||||
- `task_reminders` / `task_escalations`: je Aufgabe geplante, noch nicht ausgelöste Termine (`fired_at IS NULL`).
|
||||
- `tasks.completed_at` (neue Spalte, additiv): markiert eine erledigte Aufgabe. `escalation.Store.Complete` setzt sie, `task.Store` bleibt unverändert.
|
||||
- `ProcessDue(ctx, now)` ist der zeitgesteuerte Job: markiert fällige, noch nicht ausgelöste Erinnerungen/Eskalationen offener (nicht erledigter) Aufgaben als ausgelöst und gibt sie zurück. Kein In-Memory-Zustand, jeder Lauf liest den aktuellen Stand aus Postgres (gleiches Muster wie `stepretry`/`syncjob`).
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Erinnerungszeitpunkte sind konfigurierbar | **Bestanden.** `TestSetRule_ReminderDaysConfigurable`: Regel je Prozesstyp mit beliebiger Liste von Vorlauftagen gesetzt und durch erneutes `SetRule` vollständig ersetzt (nicht additiv). |
|
||||
| 2 | Eskalation erfolgt nach definierter Regel bei Fristablauf | **Bestanden.** `TestProcessDue_EscalatesPastDueTask`: überfällige Aufgabe löst `ProcessDue` mit der in der Regel hinterlegten `EscalationAction` aus. |
|
||||
| 3 | Erledigte Aufgabe löst keine Eskalation mehr aus | **Bestanden.** `TestProcessDue_CompletedTaskNeverEscalates`: nach `Complete` bleibt die Eskalation trotz überfälliger Frist aus; erneutes `Complete` liefert `ErrTaskNotFound` (Aufgabe bereits erledigt). |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Zeitraffer-Test über simulierte Fristen | **Bestanden.** `TestProcessDue_ReminderFiresOnlyAtConfiguredOffset`: Aufgabe fällig in 10 Tagen, Regel mit 3 Tagen Vorlauf. Simulierter Zeitpunkt +8 Tage löst die Erinnerung aus, die Eskalation (fällig bei +10 Tagen) noch nicht. |
|
||||
| 2 | Erledigte Aufgabe vor Fristablauf löst nachweislich keine Eskalation aus | **Bestanden.** Siehe AC3 oben — direkter Test, kein Zufallsergebnis. |
|
||||
| 3 | Wiederholter Joblauf bleibt idempotent | **Bestanden.** `TestProcessDue_Idempotent`: zwei `ProcessDue`-Aufrufe mit identischem `now`; die Eskalation wird nur beim ersten Lauf zurückgegeben (`fired_at` verhindert den zweiten). |
|
||||
|
||||
## Ergebnis
|
||||
|
||||
7/7 neue Tests grün, volle Modul-Regression (18 Pakete mit Tests, 3 ohne) grün, `go build`/`go vet` sauber. Kein bestehender Code (`task`, `calendar`, `reminder`) verändert.
|
||||
|
||||
## Nicht Bestandteil (per Ticket)
|
||||
|
||||
- `TSK-05` (tatsächlicher Versand über den Core-Dispatcher) — `ProcessDue` liefert nur die fällige Liste inklusive `EscalationAction`, kein eigener Versandweg.
|
||||
- `QA-03` (Prüfgate Genehmigungen & Aufgaben).
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package escalation ist Erinnerungen & Eskalationsregeln (TSK-03): baut auf
|
||||
// task (TSK-01) und calendar (TSK-02) auf, ohne deren Code zu aendern.
|
||||
// Ein Prozesstyp (z.B. "approval", "case") bekommt eine konfigurierbare
|
||||
// Liste von Erinnerungs-Vorlaufzeiten (in Arbeitstagen vor Faelligkeit) und
|
||||
// eine Eskalations-Aktion, die bei Fristablauf ausgeloest wird. Der Versand
|
||||
// selbst (TSK-05, Core-Dispatcher) ist nicht Teil dieser Kachel -- ProcessDue
|
||||
// liefert nur, welche Erinnerungen/Eskalationen faellig sind.
|
||||
package escalation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound wird zurueckgegeben, wenn fuer einen Prozesstyp keine Regel
|
||||
// hinterlegt ist.
|
||||
var ErrNotFound = errors.New("escalation: keine regel fuer diesen prozesstyp")
|
||||
|
||||
// ErrTaskNotFound wird zurueckgegeben, wenn die referenzierte Aufgabe nicht
|
||||
// existiert oder keine Faelligkeit gesetzt hat.
|
||||
var ErrTaskNotFound = errors.New("escalation: aufgabe nicht gefunden oder ohne faelligkeit")
|
||||
|
||||
// Rule ist die je Prozesstyp konfigurierbare Regel (Akzeptanzkriterium 1/2).
|
||||
type Rule struct {
|
||||
ProcessType string
|
||||
ReminderDaysBefore []int
|
||||
EscalationAction string
|
||||
}
|
||||
|
||||
// Fired ist eine ausgeloeste Erinnerung oder Eskalation (Rueckgabe von
|
||||
// ProcessDue, zur Weiterverarbeitung durch einen Aufrufer wie TSK-05).
|
||||
type Fired struct {
|
||||
TaskID string
|
||||
ProcessType string
|
||||
EscalationAction string
|
||||
}
|
||||
|
||||
// Store bindet Regeln, Erinnerungen und Eskalationen an den Datenbank-Pool.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore erzeugt den Store.
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// SetRule legt eine Regel an oder ersetzt sie (Akzeptanzkriterium 1: die
|
||||
// Erinnerungszeitpunkte sind konfigurierbar).
|
||||
func (s *Store) SetRule(ctx context.Context, r Rule) error {
|
||||
if r.ProcessType == "" {
|
||||
return fmt.Errorf("escalation: process_type darf nicht leer sein")
|
||||
}
|
||||
if r.EscalationAction == "" {
|
||||
return fmt.Errorf("escalation: escalation_action darf nicht leer sein")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO task_escalation_rules (process_type, reminder_days_before, escalation_action)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (process_type) DO UPDATE
|
||||
SET reminder_days_before = EXCLUDED.reminder_days_before,
|
||||
escalation_action = EXCLUDED.escalation_action,
|
||||
updated_at = now()
|
||||
`, r.ProcessType, r.ReminderDaysBefore, r.EscalationAction)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escalation: regel speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRule liest eine Regel.
|
||||
func (s *Store) GetRule(ctx context.Context, processType string) (Rule, error) {
|
||||
var r Rule
|
||||
r.ProcessType = processType
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT reminder_days_before, escalation_action
|
||||
FROM task_escalation_rules WHERE process_type = $1
|
||||
`, processType).Scan(&r.ReminderDaysBefore, &r.EscalationAction)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
return Rule{}, fmt.Errorf("escalation: regel lesen: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Schedule berechnet aus der Regel des Prozesstyps und der Faelligkeit der
|
||||
// Aufgabe (aus tasks.due_date, gesetzt ueber TSK-02) die Erinnerungs- und
|
||||
// Eskalationstermine und legt sie an. Ruft man Schedule erneut fuer
|
||||
// dieselbe Aufgabe auf, werden die alten, noch nicht ausgeloesten Termine
|
||||
// verworfen und neu berechnet (z.B. nach einer Fristverlaengerung).
|
||||
func (s *Store) Schedule(ctx context.Context, taskID, processType string) error {
|
||||
rule, err := s.GetRule(ctx, processType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var dueDate *time.Time
|
||||
if err := s.pool.QueryRow(ctx, `SELECT due_date FROM tasks WHERE id = $1`, taskID).Scan(&dueDate); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
return fmt.Errorf("escalation: faelligkeit lesen: %w", err)
|
||||
}
|
||||
if dueDate == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escalation: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM task_reminders WHERE task_id = $1 AND fired_at IS NULL`, taskID); err != nil {
|
||||
return fmt.Errorf("escalation: alte erinnerungen loeschen: %w", err)
|
||||
}
|
||||
for _, days := range rule.ReminderDaysBefore {
|
||||
remindAt := dueDate.AddDate(0, 0, -days)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO task_reminders (task_id, remind_at) VALUES ($1, $2)
|
||||
`, taskID, remindAt); err != nil {
|
||||
return fmt.Errorf("escalation: erinnerung anlegen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO task_escalations (task_id, process_type, escalate_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (task_id) DO UPDATE
|
||||
SET process_type = EXCLUDED.process_type, escalate_at = EXCLUDED.escalate_at, fired_at = NULL
|
||||
`, taskID, processType, *dueDate); err != nil {
|
||||
return fmt.Errorf("escalation: eskalation anlegen: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("escalation: transaktion committen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete markiert eine Aufgabe als erledigt (Akzeptanzkriterium 3: eine
|
||||
// erledigte Aufgabe loest keine Eskalation mehr aus -- ProcessDue prueft
|
||||
// completed_at IS NULL). Dieses Paket besitzt die Spalte (eigene Migration),
|
||||
// task.Store bleibt unveraendert.
|
||||
func (s *Store) Complete(ctx context.Context, taskID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE tasks SET completed_at = now() WHERE id = $1 AND completed_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escalation: aufgabe abschliessen: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessDue ist der zeitgesteuerte Job (Postgres-Jobqueue-Muster wie an
|
||||
// anderer Stelle im Modul: kein In-Memory-Zustand, jeder Lauf liest den
|
||||
// aktuellen Stand aus der Datenbank). Faellige, noch nicht ausgeloeste
|
||||
// Erinnerungen/Eskalationen offener (nicht erledigter) Aufgaben werden als
|
||||
// ausgeloest markiert und zurueckgegeben. Ein wiederholter Aufruf mit
|
||||
// demselben now liefert beim zweiten Mal keine der bereits markierten
|
||||
// Eintraege mehr (Akzeptanzkriterium/Pruefung 3: Idempotenz).
|
||||
func (s *Store) ProcessDue(ctx context.Context, now time.Time) (reminders []Fired, escalations []Fired, err error) {
|
||||
today := now.In(time.UTC)
|
||||
|
||||
reminderRows, err := s.pool.Query(ctx, `
|
||||
UPDATE task_reminders r
|
||||
SET fired_at = $1
|
||||
FROM tasks t
|
||||
WHERE r.task_id = t.id
|
||||
AND r.fired_at IS NULL
|
||||
AND r.remind_at <= $1
|
||||
AND t.completed_at IS NULL
|
||||
RETURNING r.task_id
|
||||
`, today)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("escalation: erinnerungen verarbeiten: %w", err)
|
||||
}
|
||||
var reminderTaskIDs []string
|
||||
for reminderRows.Next() {
|
||||
var id string
|
||||
if err := reminderRows.Scan(&id); err != nil {
|
||||
reminderRows.Close()
|
||||
return nil, nil, fmt.Errorf("escalation: erinnerung scannen: %w", err)
|
||||
}
|
||||
reminderTaskIDs = append(reminderTaskIDs, id)
|
||||
}
|
||||
reminderRows.Close()
|
||||
if err := reminderRows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("escalation: erinnerungen iterieren: %w", err)
|
||||
}
|
||||
for _, id := range reminderTaskIDs {
|
||||
reminders = append(reminders, Fired{TaskID: id})
|
||||
}
|
||||
|
||||
escalationRows, err := s.pool.Query(ctx, `
|
||||
UPDATE task_escalations e
|
||||
SET fired_at = $1
|
||||
FROM tasks t
|
||||
WHERE e.task_id = t.id
|
||||
AND e.fired_at IS NULL
|
||||
AND e.escalate_at <= $1
|
||||
AND t.completed_at IS NULL
|
||||
RETURNING e.task_id, e.process_type
|
||||
`, today)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("escalation: eskalationen verarbeiten: %w", err)
|
||||
}
|
||||
for escalationRows.Next() {
|
||||
var f Fired
|
||||
if err := escalationRows.Scan(&f.TaskID, &f.ProcessType); err != nil {
|
||||
escalationRows.Close()
|
||||
return nil, nil, fmt.Errorf("escalation: eskalation scannen: %w", err)
|
||||
}
|
||||
escalations = append(escalations, f)
|
||||
}
|
||||
escalationRows.Close()
|
||||
if err := escalationRows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("escalation: eskalationen iterieren: %w", err)
|
||||
}
|
||||
for i, f := range escalations {
|
||||
rule, err := s.GetRule(ctx, f.ProcessType)
|
||||
if err == nil {
|
||||
escalations[i].EscalationAction = rule.EscalationAction
|
||||
}
|
||||
}
|
||||
|
||||
return reminders, escalations, nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package escalation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/task"
|
||||
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb"
|
||||
)
|
||||
|
||||
func findModuleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("go.mod nicht gefunden")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStores(t *testing.T) (*Store, *task.Store) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool := testdb.Pool(t, dsn)
|
||||
|
||||
migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant"))
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
return NewStore(pool), task.NewStore(pool)
|
||||
}
|
||||
|
||||
func newTaskWithDueDate(t *testing.T, ts *task.Store, dueDate time.Time) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
id, err := ts.Create(ctx, task.Task{
|
||||
Title: "Testaufgabe",
|
||||
AssigneeType: task.AssigneePerson,
|
||||
AssigneeID: "user-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := ts.SetDueDate(ctx, id, dueDate); err != nil {
|
||||
t.Fatalf("SetDueDate: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// AC1: Erinnerungszeitpunkte sind konfigurierbar.
|
||||
func TestSetRule_ReminderDaysConfigurable(t *testing.T) {
|
||||
es, _ := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac1", ReminderDaysBefore: []int{7, 3, 1}, EscalationAction: "notify-manager"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
r, err := es.GetRule(ctx, "approval-ac1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRule: %v", err)
|
||||
}
|
||||
if len(r.ReminderDaysBefore) != 3 || r.ReminderDaysBefore[0] != 7 || r.ReminderDaysBefore[1] != 3 || r.ReminderDaysBefore[2] != 1 {
|
||||
t.Fatalf("erwartet [7 3 1], bekommen %v", r.ReminderDaysBefore)
|
||||
}
|
||||
|
||||
// erneutes Setzen ersetzt die Konfiguration (nicht nur additiv).
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac1", ReminderDaysBefore: []int{2}, EscalationAction: "notify-manager-v2"}); err != nil {
|
||||
t.Fatalf("SetRule (Update): %v", err)
|
||||
}
|
||||
r2, err := es.GetRule(ctx, "approval-ac1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRule (nach Update): %v", err)
|
||||
}
|
||||
if len(r2.ReminderDaysBefore) != 1 || r2.ReminderDaysBefore[0] != 2 {
|
||||
t.Fatalf("erwartet [2] nach Update, bekommen %v", r2.ReminderDaysBefore)
|
||||
}
|
||||
if r2.EscalationAction != "notify-manager-v2" {
|
||||
t.Fatalf("erwartet aktualisierte escalation_action, bekommen %q", r2.EscalationAction)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: Eskalation erfolgt nach definierter Regel bei Fristablauf.
|
||||
func TestProcessDue_EscalatesPastDueTask(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac2", ReminderDaysBefore: []int{1}, EscalationAction: "escalate-to-lead"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
|
||||
past := time.Now().AddDate(0, 0, -2)
|
||||
taskID := newTaskWithDueDate(t, ts, past)
|
||||
if err := es.Schedule(ctx, taskID, "approval-ac2"); err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
|
||||
_, escalations, err := es.ProcessDue(ctx, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDue: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range escalations {
|
||||
if e.TaskID == taskID {
|
||||
found = true
|
||||
if e.EscalationAction != "escalate-to-lead" {
|
||||
t.Fatalf("erwartete Aktion escalate-to-lead, bekommen %q", e.EscalationAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("erwartete Eskalation fuer ueberfaellige Aufgabe %s nicht ausgeloest", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 / Pruefung 2: erledigte Aufgabe vor Fristablauf loest keine Eskalation aus.
|
||||
func TestProcessDue_CompletedTaskNeverEscalates(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-ac3", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
|
||||
past := time.Now().AddDate(0, 0, -1)
|
||||
taskID := newTaskWithDueDate(t, ts, past)
|
||||
if err := es.Schedule(ctx, taskID, "approval-ac3"); err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
if err := es.Complete(ctx, taskID); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
|
||||
_, escalations, err := es.ProcessDue(ctx, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDue: %v", err)
|
||||
}
|
||||
for _, e := range escalations {
|
||||
if e.TaskID == taskID {
|
||||
t.Fatalf("erledigte Aufgabe %s hat trotzdem eine Eskalation ausgeloest", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
// erneutes Completen einer bereits erledigten Aufgabe ist ein klarer Fehler.
|
||||
if err := es.Complete(ctx, taskID); err != ErrTaskNotFound {
|
||||
t.Fatalf("erwartet ErrTaskNotFound bei erneutem Complete, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 1: Zeitraffer-Test ueber simulierte Fristen.
|
||||
func TestProcessDue_ReminderFiresOnlyAtConfiguredOffset(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-zeitraffer", ReminderDaysBefore: []int{3}, EscalationAction: "escalate"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
|
||||
due := time.Now().AddDate(0, 0, 10)
|
||||
taskID := newTaskWithDueDate(t, ts, due)
|
||||
if err := es.Schedule(ctx, taskID, "approval-zeitraffer"); err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
|
||||
// Simulierter "jetzt"-Zeitpunkt 8 Tage in der Zukunft: Erinnerung (faellig
|
||||
// bei due-3 Tage = +7 Tage relativ zu heute) muss ausgeloest sein, Eskalation
|
||||
// (faellig bei +10 Tage) noch nicht.
|
||||
simulatedNow := time.Now().AddDate(0, 0, 8)
|
||||
reminders, escalations, err := es.ProcessDue(ctx, simulatedNow)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDue: %v", err)
|
||||
}
|
||||
reminderFound := false
|
||||
for _, r := range reminders {
|
||||
if r.TaskID == taskID {
|
||||
reminderFound = true
|
||||
}
|
||||
}
|
||||
if !reminderFound {
|
||||
t.Fatalf("erwartete Erinnerung im Zeitraffer nicht gefunden")
|
||||
}
|
||||
for _, e := range escalations {
|
||||
if e.TaskID == taskID {
|
||||
t.Fatalf("Eskalation zu frueh ausgeloest (simulierter Zeitpunkt vor Faelligkeit)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 3: wiederholter Joblauf bleibt idempotent.
|
||||
func TestProcessDue_Idempotent(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-idempotent", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
|
||||
past := time.Now().AddDate(0, 0, -1)
|
||||
taskID := newTaskWithDueDate(t, ts, past)
|
||||
if err := es.Schedule(ctx, taskID, "approval-idempotent"); err != nil {
|
||||
t.Fatalf("Schedule: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, first, err := es.ProcessDue(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDue (erster Lauf): %v", err)
|
||||
}
|
||||
firstCount := 0
|
||||
for _, e := range first {
|
||||
if e.TaskID == taskID {
|
||||
firstCount++
|
||||
}
|
||||
}
|
||||
if firstCount != 1 {
|
||||
t.Fatalf("erwartete genau 1 Eskalation im ersten Lauf, bekommen %d", firstCount)
|
||||
}
|
||||
|
||||
_, second, err := es.ProcessDue(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDue (zweiter Lauf): %v", err)
|
||||
}
|
||||
for _, e := range second {
|
||||
if e.TaskID == taskID {
|
||||
t.Fatalf("zweiter Lauf hat dieselbe Eskalation erneut ausgeloest -- nicht idempotent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_UnknownProcessType(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
taskID := newTaskWithDueDate(t, ts, time.Now().AddDate(0, 0, 5))
|
||||
if err := es.Schedule(ctx, taskID, "unbekannter-typ"); err != ErrNotFound {
|
||||
t.Fatalf("erwartet ErrNotFound, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_TaskWithoutDueDate(t *testing.T) {
|
||||
es, ts := newTestStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := es.SetRule(ctx, Rule{ProcessType: "approval-nodue", ReminderDaysBefore: []int{1}, EscalationAction: "escalate"}); err != nil {
|
||||
t.Fatalf("SetRule: %v", err)
|
||||
}
|
||||
id, err := ts.Create(ctx, task.Task{Title: "Ohne Faelligkeit", AssigneeType: task.AssigneePerson, AssigneeID: "user-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := es.Schedule(ctx, id, "approval-nodue"); err != ErrTaskNotFound {
|
||||
t.Fatalf("erwartet ErrTaskNotFound, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE task_escalations;
|
||||
DROP TABLE task_reminders;
|
||||
DROP TABLE task_escalation_rules;
|
||||
ALTER TABLE tasks DROP COLUMN completed_at;
|
||||
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE tasks ADD COLUMN completed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE task_escalation_rules (
|
||||
process_type TEXT PRIMARY KEY,
|
||||
reminder_days_before INTEGER[] NOT NULL DEFAULT '{}',
|
||||
escalation_action TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE task_reminders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id UUID NOT NULL REFERENCES tasks(id),
|
||||
remind_at DATE NOT NULL,
|
||||
fired_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_task_reminders_due ON task_reminders (remind_at) WHERE fired_at IS NULL;
|
||||
|
||||
CREATE TABLE task_escalations (
|
||||
task_id UUID PRIMARY KEY REFERENCES tasks(id),
|
||||
process_type TEXT NOT NULL,
|
||||
escalate_at DATE NOT NULL,
|
||||
fired_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_task_escalations_due ON task_escalations (escalate_at) WHERE fired_at IS NULL;
|
||||
Reference in new issue
Block a user