package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "git.yuntuo.hk/k2safe/go-omni" "git.yuntuo.hk/k2safe/go-omni/auth" "git.yuntuo.hk/k2safe/go-omni/examples/reference/model" "git.yuntuo.hk/k2safe/go-omni/examples/reference/repository" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/logx" ginrouter "git.yuntuo.hk/k2safe/go-omni/transport/gin" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" ) type envelope struct { Code string `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` RequestID string `json:"requestId"` } func testApp() *omni.App { return newApp(repository.NewMemoryUsers(), testLogger(io.Discard), omni.WithMode("test")) } func testLogger(writer io.Writer) *logx.Logger { logger, err := logx.New(logx.Config{Format: "json", Writer: writer}) if err != nil { panic(err) } return logger } func request(t *testing.T, app http.Handler, method, path, body string, status int, code string) envelope { return requestWithAuthorization(t, app, method, path, body, "", status, code) } func requestWithAuthorization(t *testing.T, app http.Handler, method, path, body, authorization string, status int, code string) envelope { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Request-ID", "reference-test") if authorization != "" { req.Header.Set("Authorization", authorization) } w := httptest.NewRecorder() app.ServeHTTP(w, req) if w.Code != status { t.Fatalf("%s %s: want status %d, got %d: %s", method, path, status, w.Code, w.Body.String()) } if w.Header().Get("X-Request-ID") != "reference-test" { t.Fatalf("request ID header was not propagated: %v", w.Header()) } if status == http.StatusNoContent { if w.Body.Len() != 0 { t.Fatalf("204 must have no body: %s", w.Body.String()) } return envelope{} } if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") { t.Fatalf("expected JSON content type: %v", w.Header()) } var result envelope if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { t.Fatalf("invalid JSON response: %v: %s", err, w.Body.String()) } if result.Code != code || result.RequestID != "reference-test" { t.Fatalf("unexpected envelope: %+v", result) } return result } func TestProtectedWriteRoutes(t *testing.T) { app := newProtectedApp( repository.NewMemoryUsers(), testLogger(io.Discard), auth.StaticAuthenticator{"user-token": {UserID: "user-1", Roles: []string{"user"}}}, omni.WithMode("test"), ) request(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, 401, "UNAUTHENTICATED") created := dataAs[model.User](t, requestWithAuthorization(t, app, "POST", "/api/v1/users", `{"name":"Alice","email":"alice@example.com"}`, "Bearer user-token", 201, "CREATED")) path := "/api/v1/users/" + strconv.FormatInt(created.ID, 10) requestWithAuthorization(t, app, "DELETE", path, "", "Bearer user-token", 403, "FORBIDDEN") requestWithAuthorization(t, app, "PUT", path, `{"name":"Alice Updated","email":"alice.updated@example.com"}`, "Bearer user-token", 200, "OK") } func dataAs[T any](t *testing.T, value envelope) T { t.Helper() var data T if err := json.Unmarshal(value.Data, &data); err != nil { t.Fatalf("decode data: %v", err) } return data } func TestUserCRUD(t *testing.T) { for _, adapter := range []struct { name string newRouter func() httpx.Router }{ {"gin", func() httpx.Router { return ginrouter.New("test") }}, {"nethttp", func() httpx.Router { return netrouter.New() }}, } { t.Run(adapter.name, func(t *testing.T) { application := omni.NewWithRouter(adapter.newRouter()) registerRoutes(application, repository.NewMemoryUsers(), testLogger(io.Discard), nil, nil) checkUserCRUD(t, application) }) } } func checkUserCRUD(t *testing.T, app *omni.App) { t.Helper() health := dataAs[map[string]string](t, request(t, app, "GET", "/health", "", 200, "OK")) if health["status"] != "ok" { t.Fatalf("unexpected health: %v", health) } empty := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) if empty.Items == nil || len(empty.Items) != 0 || empty.Total != 0 || empty.Page != 1 || empty.PageSize != 20 { t.Fatalf("unexpected empty page: %+v", empty) } alice := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":" Alice ","email":"ALICE@example.com"}`, 201, "CREATED")) if alice.ID <= 0 || alice.Name != "Alice" || alice.Email != "alice@example.com" || alice.CreatedAt.IsZero() || !alice.CreatedAt.Equal(alice.UpdatedAt) { t.Fatalf("unexpected created user: %+v", alice) } path := "/api/v1/users/" + strconv.FormatInt(alice.ID, 10) got := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK")) if got != alice { t.Fatalf("get did not preserve user: %+v", got) } request(t, app, "POST", "/api/v1/users", `{"name":"Duplicate","email":"Alice@EXAMPLE.com"}`, 409, "EMAIL_TAKEN") bob := dataAs[model.User](t, request(t, app, "POST", "/api/v1/users", `{"name":"Bob","email":"bob@example.com"}`, 201, "CREATED")) page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page=2&pageSize=1", "", 200, "OK")) if page.Total != 2 || page.Page != 2 || page.PageSize != 1 || len(page.Items) != 1 || page.Items[0] != bob { t.Fatalf("unexpected page: %+v", page) } // An extreme page must return an empty list without an integer overflow. hugePage := strconv.Itoa(int(^uint(0) >> 1)) outOfRange := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users?page="+hugePage+"&pageSize=100", "", 200, "OK")) if outOfRange.Items == nil || len(outOfRange.Items) != 0 || outOfRange.Total != 2 { t.Fatalf("unexpected out-of-range page: %+v", outOfRange) } // A conflicting update must not partially modify either user or email index. request(t, app, "PUT", path, `{"name":"Changed","email":"BOB@example.com"}`, 409, "EMAIL_TAKEN") unchanged := dataAs[model.User](t, request(t, app, "GET", path, "", 200, "OK")) if unchanged != alice { t.Fatalf("failed update changed data: %+v", unchanged) } updated := dataAs[model.User](t, request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK")) if updated.ID != alice.ID || updated.Name != "Alice Updated" || updated.Email != "new@example.com" || !updated.CreatedAt.Equal(alice.CreatedAt) || updated.UpdatedAt.Before(alice.UpdatedAt) { t.Fatalf("unexpected update: %+v", updated) } // Retaining one's own email is allowed; the old email can be reused. request(t, app, "PUT", path, `{"name":"Alice Updated","email":"new@example.com"}`, 200, "OK") request(t, app, "POST", "/api/v1/users", `{"name":"Reused","email":"alice@example.com"}`, 201, "CREATED") request(t, app, "DELETE", path, "", 204, "") request(t, app, "GET", path, "", 404, "USER_NOT_FOUND") request(t, app, "DELETE", path, "", 404, "USER_NOT_FOUND") request(t, app, "POST", "/api/v1/users", `{"name":"Reused Again","email":"new@example.com"}`, 201, "CREATED") } func TestUserValidation(t *testing.T) { app := testApp() for _, test := range []struct { name, method, path, body, code string }{ {"malformed JSON", "POST", "/api/v1/users", `{"name":`, "INVALID_BODY"}, {"missing email", "POST", "/api/v1/users", `{"name":"Alice"}`, "INVALID_BODY"}, {"invalid email", "POST", "/api/v1/users", `{"name":"Alice","email":"invalid"}`, "INVALID_BODY"}, {"blank name", "POST", "/api/v1/users", `{"name":" ","email":"alice@example.com"}`, "INVALID_USER"}, {"negative ID", "GET", "/api/v1/users/-1", "", "INVALID_PATH"}, {"zero ID", "DELETE", "/api/v1/users/0", "", "INVALID_PATH"}, {"text ID", "PUT", "/api/v1/users/abc", `{}`, "INVALID_PATH"}, {"overflow ID", "GET", "/api/v1/users/99999999999999999999999", "", "INVALID_PATH"}, {"zero page", "GET", "/api/v1/users?page=0", "", "INVALID_QUERY"}, {"negative page", "GET", "/api/v1/users?page=-1", "", "INVALID_QUERY"}, {"text page", "GET", "/api/v1/users?page=abc", "", "INVALID_QUERY"}, {"zero page size", "GET", "/api/v1/users?pageSize=0", "", "INVALID_QUERY"}, {"large page size", "GET", "/api/v1/users?pageSize=101", "", "INVALID_QUERY"}, {"incomplete replacement", "PUT", "/api/v1/users/1", `{"name":"Alice"}`, "INVALID_BODY"}, } { t.Run(test.name, func(t *testing.T) { request(t, app, test.method, test.path, test.body, 400, test.code) }) } request(t, app, "GET", "/api/v1/users/123", "", 404, "USER_NOT_FOUND") request(t, app, "PUT", "/api/v1/users/123", `{"name":"Alice","email":"alice@example.com"}`, 404, "USER_NOT_FOUND") page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) if page.Total != 0 { t.Fatalf("invalid requests mutated storage: %+v", page) } } type failingUsers struct { *repository.MemoryUsers err error } func (r failingUsers) Get(context.Context, int64) (model.User, error) { return model.User{}, r.err } func TestErrorTranslationAndRequestLogging(t *testing.T) { for _, test := range []struct { name, code string err error status int }{ {"internal failure", "INTERNAL_ERROR", errors.New("private database diagnostic"), 500}, {"wrapped domain error", "USER_NOT_FOUND", fmt.Errorf("lookup failed: %w", model.ErrUserNotFound), 404}, } { t.Run(test.name, func(t *testing.T) { var logs bytes.Buffer app := newApp(failingUsers{repository.NewMemoryUsers(), test.err}, testLogger(&logs), omni.WithMode("test")) result := request(t, app, "GET", "/api/v1/users/1", "", test.status, test.code) if strings.Contains(result.Message, test.err.Error()) || len(result.Data) != 0 { t.Fatalf("internal error details leaked: %+v", result) } var logEntry map[string]any if err := json.Unmarshal(logs.Bytes(), &logEntry); err != nil { t.Fatalf("missing or invalid request log: %v: %s", err, logs.String()) } if logEntry["requestId"] != "reference-test" || logEntry["path"] != "/api/v1/users/1" || !strings.Contains(fmt.Sprint(logEntry["error"]), test.err.Error()) { t.Fatalf("missing request/error context in log: %v", logEntry) } }) } } func TestConcurrentEmailUniqueness(t *testing.T) { app := testApp() server := httptest.NewServer(app) defer server.Close() const count = 24 results := make(chan int, count) errors := make(chan error, count) var workers sync.WaitGroup for i := 0; i < count; i++ { workers.Add(1) go func(i int) { defer workers.Done() email := "same@example.com" if i%2 == 0 { email = "SAME@example.com" } body := fmt.Sprintf(`{"name":"User %d","email":%q}`, i, email) response, err := server.Client().Post(server.URL+"/api/v1/users", "application/json", strings.NewReader(body)) if err != nil { errors <- err return } io.Copy(io.Discard, response.Body) response.Body.Close() results <- response.StatusCode }(i) } workers.Wait() close(results) close(errors) for err := range errors { t.Errorf("request failed: %v", err) } created, conflicts := 0, 0 for status := range results { switch status { case 201: created++ case 409: conflicts++ default: t.Errorf("unexpected status: %d", status) } } if created != 1 || conflicts != count-1 { t.Fatalf("want one create and %d conflicts, got %d creates and %d conflicts", count-1, created, conflicts) } page := dataAs[model.UserPage](t, request(t, app, "GET", "/api/v1/users", "", 200, "OK")) if page.Total != 1 { t.Fatalf("duplicate users persisted: %+v", page) } }