Files
pangolin/server/internal/apierr/apierr_test.go
T
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
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>
2026-06-13 11:58:38 +08:00

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
}