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 }