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>
309 lines
7.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|