Add web.FieldErrorsFromDB — a constraint-name-to-field translator that converts PostgreSQL constraint violations into 422 FieldErrors (per the §6 error contract) instead of leaking raw driver text to users. - New web/dberrors.go: FieldErrorsFromDB + ConstraintMessages map - New web/dberrors_test.go: constraint hits, per-class fallbacks (23505/23503/23514/22001), nil map, non-Postgres, 23P01 exclusion requires named entry - Fix 9 leak sites: workspace creation, enrollment (IssueGrant/ExtendGrant/ CreateNonPlanGrant/RevokeGrantAndTransition), org type default-plan update, entitlement set CRUD, product create/update - New lint rule raw-error-render: flags err.Error() on any line touching a UI sink (render*, fireErrorToast, fireSuccessToast, http.Error), exempts slog lines - Docs: add §4 db-write-failure contract and §9 anti-pattern row Closes: openspec/changes/db-error-translation
121 lines
4.0 KiB
Go
121 lines
4.0 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func TestFieldErrorsFromDB_ConstraintNameHit(t *testing.T) {
|
|
msgs := ConstraintMessages{
|
|
"workspaces_org_id_slug_key": {Field: "slug", Message: "A workspace with this slug already exists in this organization."},
|
|
}
|
|
// Wrapped, as sqlc/database/sql returns it in practice.
|
|
err := fmt.Errorf("exec insert: %w", &pgconn.PgError{
|
|
Code: "23505",
|
|
ConstraintName: "workspaces_org_id_slug_key",
|
|
Message: `duplicate key value violates unique constraint "workspaces_org_id_slug_key"`,
|
|
})
|
|
|
|
fe, ok := FieldErrorsFromDB(err, msgs)
|
|
if !ok {
|
|
t.Fatalf("FieldErrorsFromDB ok = false, want true")
|
|
}
|
|
if got := fe.Get("slug"); got != "A workspace with this slug already exists in this organization." {
|
|
t.Errorf("fe.Get(slug) = %q, want the mapped message", got)
|
|
}
|
|
if len(fe) != 1 {
|
|
t.Errorf("len(fe) = %d, want exactly one entry", len(fe))
|
|
}
|
|
// The named match must win over the per-class fallback.
|
|
if fe.Has("") {
|
|
t.Errorf("form-level fallback populated alongside a named constraint hit: %q", fe.Get(""))
|
|
}
|
|
}
|
|
|
|
func TestFieldErrorsFromDB_ClassFallback(t *testing.T) {
|
|
cases := []struct {
|
|
code string
|
|
want string
|
|
}{
|
|
{"23505", "A record with these values already exists."},
|
|
{"23503", "A referenced record no longer exists or is still in use."},
|
|
{"23514", "This combination of values is not allowed."},
|
|
{"22001", "One of the values is too long."},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.code, func(t *testing.T) {
|
|
err := &pgconn.PgError{Code: tc.code, ConstraintName: "not_in_the_map"}
|
|
fe, ok := FieldErrorsFromDB(err, ConstraintMessages{
|
|
"some_other_constraint": {Field: "name", Message: "unrelated"},
|
|
})
|
|
if !ok {
|
|
t.Fatalf("ok = false, want true for SQLSTATE %s", tc.code)
|
|
}
|
|
if got := fe.Get(""); got != tc.want {
|
|
t.Errorf("fe.Get(\"\") = %q, want %q", got, tc.want)
|
|
}
|
|
if fe.Has("name") {
|
|
t.Errorf("unrelated named mapping leaked into the result")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldErrorsFromDB_NilMapStillFallsBack(t *testing.T) {
|
|
fe, ok := FieldErrorsFromDB(&pgconn.PgError{Code: "23503"}, nil)
|
|
if !ok {
|
|
t.Fatalf("ok = false, want true — nil ConstraintMessages must still allow class fallback")
|
|
}
|
|
if fe.Get("") == "" {
|
|
t.Errorf("expected a form-level fallback message, got none")
|
|
}
|
|
}
|
|
|
|
func TestFieldErrorsFromDB_NotOK(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{"non-pg error", errors.New("dial tcp 127.0.0.1:5432: connection refused")},
|
|
{"wrapped non-pg error", fmt.Errorf("query: %w", errors.New("context canceled"))},
|
|
{"pg error with unrecognized class", &pgconn.PgError{Code: "42703", Message: `column "nope" does not exist`}},
|
|
{"nil-ish sql error text mentioning SQLSTATE", errors.New("ERROR: something (SQLSTATE 23505)")},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fe, ok := FieldErrorsFromDB(tc.err, ConstraintMessages{
|
|
"chk_grants_recipient": {Field: "", Message: "A grant must name exactly one recipient."},
|
|
})
|
|
if ok {
|
|
t.Fatalf("ok = true, want false (fe=%v) — caller must slog + render a generic message", fe)
|
|
}
|
|
if fe != nil {
|
|
t.Errorf("fe = %v, want nil on ok=false", fe)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldErrorsFromDB_ExclusionConstraintNeedsNamedEntry(t *testing.T) {
|
|
// SQLSTATE 23P01 (exclusion_violation) has no per-class fallback: without
|
|
// a named entry it must return ok=false, and with one the name must win.
|
|
exclErr := &pgconn.PgError{Code: "23P01", ConstraintName: "excl_pool_provision_ladders_active_overlap"}
|
|
|
|
if _, ok := FieldErrorsFromDB(exclErr, nil); ok {
|
|
t.Fatalf("ok = true for unmapped 23P01, want false")
|
|
}
|
|
|
|
fe, ok := FieldErrorsFromDB(exclErr, ConstraintMessages{
|
|
"excl_pool_provision_ladders_active_overlap": {Field: "product_id", Message: "This pool already has an active plan on that ladder."},
|
|
})
|
|
if !ok {
|
|
t.Fatalf("ok = false for mapped 23P01, want true")
|
|
}
|
|
if got := fe.Get("product_id"); got != "This pool already has an active plan on that ladder." {
|
|
t.Errorf("fe.Get(product_id) = %q, want the mapped message", got)
|
|
}
|
|
}
|