b64c002a33
apierr: - Add New() constructor, StatusFor() HTTP-status mapping - Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors - Add chi-compatible Middleware for panic(*Error) → JSON recovery - Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware) idgen: - Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0) - Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes) - Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check) codes: - Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen - All existing codes generator tests continue to pass unchanged server: - Add CONVENTIONS.md covering package structure, error handling, ID generation, database conventions, handler templates, auth context, testing, and logging rules - Move google/uuid from indirect to direct dependency in go.mod Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
6.0 KiB
Go
211 lines
6.0 KiB
Go
package apierr_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/apierr"
|
|
)
|
|
|
|
// TestErrorInterface verifies that *Error implements the error interface.
|
|
func TestErrorInterface(t *testing.T) {
|
|
var err error = apierr.ErrBadRequest
|
|
if err == nil {
|
|
t.Fatal("ErrBadRequest should not be nil")
|
|
}
|
|
if err.Error() == "" {
|
|
t.Error("Error() returned empty string")
|
|
}
|
|
}
|
|
|
|
// TestNew verifies that New constructs an *Error with the given fields.
|
|
func TestNew(t *testing.T) {
|
|
e := apierr.New("TEST_CODE", "测试消息", "test message")
|
|
if e.Code != "TEST_CODE" {
|
|
t.Errorf("Code = %q, want %q", e.Code, "TEST_CODE")
|
|
}
|
|
if e.MessageZH != "测试消息" {
|
|
t.Errorf("MessageZH = %q, want %q", e.MessageZH, "测试消息")
|
|
}
|
|
if e.MessageEn != "test message" {
|
|
t.Errorf("MessageEn = %q, want %q", e.MessageEn, "test message")
|
|
}
|
|
}
|
|
|
|
// TestStatusFor verifies HTTP status code mapping.
|
|
func TestStatusFor(t *testing.T) {
|
|
cases := []struct {
|
|
code string
|
|
wantStatus int
|
|
}{
|
|
{"UNAUTHORIZED", http.StatusUnauthorized},
|
|
{"FORBIDDEN", http.StatusForbidden},
|
|
{"NOT_FOUND", http.StatusNotFound},
|
|
{"CONFLICT", http.StatusConflict},
|
|
{"RATE_LIMITED", http.StatusTooManyRequests},
|
|
{"ACCOUNT_LOCKED", http.StatusTooManyRequests},
|
|
{"INTERNAL_ERROR", http.StatusInternalServerError},
|
|
{"BAD_REQUEST", http.StatusBadRequest},
|
|
{"INVALID_CODE", http.StatusBadRequest},
|
|
{"CODE_NOT_FOUND", http.StatusBadRequest},
|
|
{"UNKNOWN_CODE", http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
e := &apierr.Error{Code: tc.code}
|
|
got := apierr.StatusFor(e)
|
|
if got != tc.wantStatus {
|
|
t.Errorf("StatusFor({Code:%q}) = %d, want %d", tc.code, got, tc.wantStatus)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteJSON verifies that WriteJSON sets the correct Content-Type,
|
|
// status code, and JSON body.
|
|
func TestWriteJSON(t *testing.T) {
|
|
e := apierr.New("TEST", "中文", "english")
|
|
|
|
w := httptest.NewRecorder()
|
|
apierr.WriteJSON(w, http.StatusBadRequest, e)
|
|
|
|
resp := w.Result()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct != "application/json; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8")
|
|
}
|
|
|
|
var got apierr.Error
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got.Code != e.Code {
|
|
t.Errorf("body.code = %q, want %q", got.Code, e.Code)
|
|
}
|
|
if got.MessageZH != e.MessageZH {
|
|
t.Errorf("body.message_zh = %q, want %q", got.MessageZH, e.MessageZH)
|
|
}
|
|
if got.MessageEn != e.MessageEn {
|
|
t.Errorf("body.message_en = %q, want %q", got.MessageEn, e.MessageEn)
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareCatchesAPIError verifies that the middleware intercepts
|
|
// a panic(*Error) and writes the correct JSON response.
|
|
func TestMiddlewareCatchesAPIError(t *testing.T) {
|
|
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
panic(apierr.ErrUnauthorized)
|
|
})
|
|
|
|
handler := apierr.Middleware(panicHandler)
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
resp := w.Result()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
|
}
|
|
|
|
var got apierr.Error
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if got.Code != apierr.ErrUnauthorized.Code {
|
|
t.Errorf("body.code = %q, want %q", got.Code, apierr.ErrUnauthorized.Code)
|
|
}
|
|
}
|
|
|
|
// TestMiddlewareReRaisesNonAPIError verifies that the middleware re-raises
|
|
// panics that are not of type *Error.
|
|
func TestMiddlewareReRaisesNonAPIError(t *testing.T) {
|
|
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
panic("unexpected string panic")
|
|
})
|
|
|
|
handler := apierr.Middleware(panicHandler)
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
defer func() {
|
|
if rv := recover(); rv == nil {
|
|
t.Error("expected panic to be re-raised, but it was not")
|
|
}
|
|
}()
|
|
handler.ServeHTTP(w, req)
|
|
}
|
|
|
|
// TestMiddlewarePassesthrough verifies that the middleware is a no-op when
|
|
// the handler does not panic.
|
|
func TestMiddlewarePassesthrough(t *testing.T) {
|
|
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
})
|
|
|
|
handler := apierr.Middleware(okHandler)
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// TestPredefinedErrors checks that all predefined errors have non-empty fields
|
|
// and comply with the desensitisation rule (no forbidden words in messages).
|
|
func TestPredefinedErrors(t *testing.T) {
|
|
forbidden := []string{"VPN", "翻墙", "科学上网"}
|
|
errors := []*apierr.Error{
|
|
apierr.ErrBadRequest,
|
|
apierr.ErrUnauthorized,
|
|
apierr.ErrForbidden,
|
|
apierr.ErrNotFound,
|
|
apierr.ErrConflict,
|
|
apierr.ErrRateLimited,
|
|
apierr.ErrInternal,
|
|
apierr.ErrInvalidCode,
|
|
apierr.ErrCodeNotFound,
|
|
apierr.ErrCodeRedeemed,
|
|
apierr.ErrCodeVoid,
|
|
apierr.ErrLocked,
|
|
apierr.ErrWebhookSignature,
|
|
apierr.ErrWebhookTimestamp,
|
|
apierr.ErrWebhookReplay,
|
|
}
|
|
|
|
for _, e := range errors {
|
|
if e.Code == "" {
|
|
t.Errorf("error %+v has empty Code", e)
|
|
}
|
|
if e.MessageZH == "" {
|
|
t.Errorf("error %q has empty MessageZH", e.Code)
|
|
}
|
|
if e.MessageEn == "" {
|
|
t.Errorf("error %q has empty MessageEn", e.Code)
|
|
}
|
|
for _, f := range forbidden {
|
|
if contains(e.MessageZH, f) || contains(e.MessageEn, f) {
|
|
t.Errorf("error %q contains forbidden word %q", e.Code, f)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
|
}
|
|
|
|
func containsStr(s, sub string) bool {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|