feat: bootstrap reusable GoOmni framework
This commit is contained in:
277
examples/reference/main_test.go
Normal file
277
examples/reference/main_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
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/logx"
|
||||
)
|
||||
|
||||
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) {
|
||||
app := testApp()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user