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.
153 lines
5.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|