Files
paliad/internal/handlers/approvals_test.go
mAi 0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00

153 lines
5.4 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"mgit.msbls.de/m/paliad/internal/services"
)
// Pins t-paliad-160 §B: mapApprovalError must surface ErrConcurrentPending
// as a 409 with code=awaiting_approval, and PendingApprovalError must
// additionally carry the request_id + required_role so the UI can offer a
// withdraw button.
func TestMapApprovalError_ConcurrentPending409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrConcurrentPending) {
t.Fatal("mapApprovalError returned false for ErrConcurrentPending")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if _, ok := body["request_id"]; ok {
t.Errorf("bare ErrConcurrentPending should not carry request_id, got %q", body["request_id"])
}
}
func TestMapApprovalError_PendingApprovalErrorCarriesRequestID(t *testing.T) {
w := httptest.NewRecorder()
pe := services.NewPendingApprovalError("11111111-2222-3333-4444-555555555555", "associate")
if !mapApprovalError(w, pe) {
t.Fatal("mapApprovalError returned false for PendingApprovalError")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if body["request_id"] != "11111111-2222-3333-4444-555555555555" {
t.Errorf("request_id = %q, want the wrapped uuid", body["request_id"])
}
if body["required_role"] != "associate" {
t.Errorf("required_role = %q, want associate", body["required_role"])
}
}
func TestMapApprovalError_NoQualifiedApprover409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrNoQualifiedApprover) {
t.Fatal("mapApprovalError returned false for ErrNoQualifiedApprover")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "no_qualified_approver" {
t.Errorf("code = %q, want no_qualified_approver", body["code"])
}
}
func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
w := httptest.NewRecorder()
if mapApprovalError(w, services.ErrInvalidInput) {
t.Error("mapApprovalError matched ErrInvalidInput; that's writeServiceError's job")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (recorder default — nothing written)", w.Code)
}
}
// TestMapApprovalError_SuggestionRequiresChange400 pins t-paliad-216:
// a no-op suggest-changes (no counter diff + no note) surfaces as a 400
// with code suggestion_requires_change so the frontend can disable the
// submit button instead of letting the user click into a dead-end alert.
func TestMapApprovalError_SuggestionRequiresChange400(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrSuggestionRequiresChange) {
t.Fatal("mapApprovalError returned false for ErrSuggestionRequiresChange")
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "suggestion_requires_change" {
t.Errorf("code = %q, want suggestion_requires_change", body["code"])
}
}
// TestMapApprovalError_SuggestionLifecycleInvalid400 pins t-paliad-216:
// suggest-changes on a create/delete lifecycle is rejected with a clean
// 400 + code suggestion_lifecycle_invalid so the frontend can hide the
// button for those rows.
func TestMapApprovalError_SuggestionLifecycleInvalid400(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrSuggestionLifecycleInvalid) {
t.Fatal("mapApprovalError returned false for ErrSuggestionLifecycleInvalid")
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "suggestion_lifecycle_invalid" {
t.Errorf("code = %q, want suggestion_lifecycle_invalid", body["code"])
}
}
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
// hardening: a stray ?status=foo from a stale frontend build (or an
// attacker scoping us out of our own list) must NOT shadow rows out of
// the result set. The handler silently drops anything not on the allowlist.
func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"", ""},
{"pending", "pending"},
{"approved", "approved"},
{"rejected", "rejected"},
{"revoked", "revoked"},
{"superseded", "superseded"},
{"changes_requested", "changes_requested"}, // t-paliad-216
{"foo", ""}, // unknown — dropped
{"DROP+TABLE", ""}, // hostile — dropped
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/inbox/mine?status="+tc.raw, nil)
f := parseInboxFilter(req)
if f.Status != tc.want {
t.Errorf("parseInboxFilter(%q).Status = %q, want %q", tc.raw, f.Status, tc.want)
}
})
}
}