294 lines
11 KiB
Go
294 lines
11 KiB
Go
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"
|
|
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))
|
|
}
|
|
|
|
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"}}},
|
|
)
|
|
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
|
|
}{
|
|
{"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 http.Handler) {
|
|
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))
|
|
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)
|
|
}
|
|
}
|