rdev/pkg/api/error_test.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
Weeks 1-7 of the template upgrade plan:
- pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders
- skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client
- skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware)
- components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth
- cookbooks/feature-development.md with test and validation scripts
- Handler tests for components, project management, and woodpecker webhook
- 3 rounds of code review fixes applied

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:46:51 -07:00

309 lines
7.1 KiB
Go

package api
import (
"errors"
"net/http"
"testing"
)
func TestHTTPError_Error(t *testing.T) {
tests := []struct {
name string
err *HTTPError
expected string
}{
{
name: "simple error",
err: &HTTPError{Status: 404, Code: "NOT_FOUND", Message: "user not found"},
expected: "user not found",
},
{
name: "error with cause",
err: &HTTPError{
Status: 500,
Code: "INTERNAL_ERROR",
Message: "database error",
cause: errors.New("connection refused"),
},
expected: "database error: connection refused",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("HTTPError.Error() = %q, want %q", got, tt.expected)
}
})
}
}
func TestHTTPError_Is(t *testing.T) {
tests := []struct {
name string
err error
target error
expected bool
}{
{
name: "matches ErrNotFound",
err: NotFound("user not found"),
target: ErrNotFound,
expected: true,
},
{
name: "matches ErrBadRequest",
err: BadRequest("invalid input"),
target: ErrBadRequest,
expected: true,
},
{
name: "does not match different status",
err: NotFound("not found"),
target: ErrBadRequest,
expected: false,
},
{
name: "wrapped error matches",
err: WrapError(ErrNotFound, errors.New("db error")),
target: ErrNotFound,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errors.Is(tt.err, tt.target); got != tt.expected {
t.Errorf("errors.Is() = %v, want %v", got, tt.expected)
}
})
}
}
func TestHTTPError_Unwrap(t *testing.T) {
cause := errors.New("underlying error")
err := WrapError(ErrInternal, cause)
unwrapped := errors.Unwrap(err)
if unwrapped != cause {
t.Errorf("Unwrap() = %v, want %v", unwrapped, cause)
}
}
func TestFactoryFunctions(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantCode string
wantContains string
}{
{
name: "BadRequest",
err: BadRequest("invalid input"),
wantStatus: http.StatusBadRequest,
wantCode: "BAD_REQUEST",
wantContains: "invalid input",
},
{
name: "BadRequestf",
err: BadRequestf("field %s is invalid", "email"),
wantStatus: http.StatusBadRequest,
wantCode: "BAD_REQUEST",
wantContains: "field email is invalid",
},
{
name: "Unauthorized",
err: Unauthorized("token expired"),
wantStatus: http.StatusUnauthorized,
wantCode: "UNAUTHORIZED",
wantContains: "token expired",
},
{
name: "Forbidden",
err: Forbidden("access denied"),
wantStatus: http.StatusForbidden,
wantCode: "FORBIDDEN",
wantContains: "access denied",
},
{
name: "NotFound",
err: NotFound("user not found"),
wantStatus: http.StatusNotFound,
wantCode: "NOT_FOUND",
wantContains: "user not found",
},
{
name: "NotFoundf",
err: NotFoundf("user %s not found", "123"),
wantStatus: http.StatusNotFound,
wantCode: "NOT_FOUND",
wantContains: "user 123 not found",
},
{
name: "Conflict",
err: Conflict("already exists"),
wantStatus: http.StatusConflict,
wantCode: "CONFLICT",
wantContains: "already exists",
},
{
name: "Internal",
err: Internal("something went wrong"),
wantStatus: http.StatusInternalServerError,
wantCode: "INTERNAL_ERROR",
wantContains: "something went wrong",
},
{
name: "Validation",
err: Validation("validation failed"),
wantStatus: http.StatusBadRequest,
wantCode: "VALIDATION_ERROR",
wantContains: "validation failed",
},
{
name: "UnprocessableEntity",
err: UnprocessableEntity("semantic error"),
wantStatus: http.StatusUnprocessableEntity,
wantCode: "UNPROCESSABLE_ENTITY",
wantContains: "semantic error",
},
{
name: "TooManyRequests",
err: TooManyRequests("rate limited"),
wantStatus: http.StatusTooManyRequests,
wantCode: "TOO_MANY_REQUESTS",
wantContains: "rate limited",
},
{
name: "ServiceUnavailable",
err: ServiceUnavailable("maintenance"),
wantStatus: http.StatusServiceUnavailable,
wantCode: "SERVICE_UNAVAILABLE",
wantContains: "maintenance",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpErr := AsHTTPError(tt.err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Status != tt.wantStatus {
t.Errorf("Status = %d, want %d", httpErr.Status, tt.wantStatus)
}
if httpErr.Code != tt.wantCode {
t.Errorf("Code = %q, want %q", httpErr.Code, tt.wantCode)
}
if httpErr.Message != tt.wantContains {
t.Errorf("Message = %q, want %q", httpErr.Message, tt.wantContains)
}
})
}
}
func TestWithDetails(t *testing.T) {
details := map[string]string{"field": "email", "error": "invalid format"}
err := WithDetails(BadRequest("validation failed"), details)
httpErr := AsHTTPError(err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Details == nil {
t.Fatal("expected Details to be set")
}
detailsMap, ok := httpErr.Details.(map[string]string)
if !ok {
t.Fatalf("expected map[string]string, got %T", httpErr.Details)
}
if detailsMap["field"] != "email" {
t.Errorf("Details[field] = %q, want %q", detailsMap["field"], "email")
}
}
func TestWithCode(t *testing.T) {
err := WithCode(Forbidden("access denied"), "KEY_REVOKED")
httpErr := AsHTTPError(err)
if httpErr == nil {
t.Fatal("expected HTTPError")
}
if httpErr.Code != "KEY_REVOKED" {
t.Errorf("Code = %q, want %q", httpErr.Code, "KEY_REVOKED")
}
if httpErr.Status != http.StatusForbidden {
t.Errorf("Status = %d, want %d", httpErr.Status, http.StatusForbidden)
}
}
func TestStatusCode(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
}{
{
name: "HTTPError",
err: NotFound("not found"),
wantStatus: http.StatusNotFound,
},
{
name: "regular error",
err: errors.New("some error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "nil error returns 200 OK",
err: nil,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StatusCode(tt.err); got != tt.wantStatus {
t.Errorf("StatusCode() = %d, want %d", got, tt.wantStatus)
}
})
}
}
func TestIsHTTPError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "HTTPError",
err: NotFound("not found"),
expected: true,
},
{
name: "regular error",
err: errors.New("some error"),
expected: false,
},
{
name: "nil",
err: nil,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsHTTPError(tt.err); got != tt.expected {
t.Errorf("IsHTTPError() = %v, want %v", got, tt.expected)
}
})
}
}