Files
go-omni/examples/reference/main_test.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 *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))
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)
}
}