BREAKING CHANGES: - Guard interface: Guard(ctx Ctx) error; returning error short-circuits (subsequent guards and the method no longer execute) - Wired[T]() (*T, error); New() may return error; failed wiring is sticky - BindService/BindGuard/BindRoute return error; routes accept guards (guards receive merged query/form params as Ctx.State) - registration panics after Start; Ctx.Ip honors X-Forwarded-For/X-Real-IP - internal errors sanitized to fixed client messages FIXES: - int64 precision loss: /cell data decoded from raw JSON bytes and responses serialized with json.Number (no float64 round-trip) - enum values range-checked before uint8 conversion (256 no longer truncates to 0 and slips through) - logger: files failing name parsing are no longer deleted; log channel never blocks request goroutines; ConfigLogger is race-free; logWriterWorker unlock bug fixed - stream writer panics recovered (process no longer crashes); streamDone closed exactly once - generated Go client emits definitions for pointer-to-enum/struct fields - anonymous structs rejected at registration; isPrivate safe on empty names - removed dead Result.Id and RequestInfo.Type ADDITIONS: - graceful shutdown (Shutdown), StartOn, server timeouts by default (Read 60s/Idle 120s/Write off), SetTimeouts/SetMaxConcurrency - big-int-safe JSON in the generated TS client (>2^53 as BigInt) - example/ demo services and cmd/genexample artifact generator
236 lines
6.1 KiB
Go
236 lines
6.1 KiB
Go
package fun
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
type BugStatus uint8
|
||
|
||
func (BugStatus) Names() []string { return []string{"A", "B"} }
|
||
|
||
type BugDto struct {
|
||
Status *BugStatus
|
||
}
|
||
|
||
type BugSvc struct{}
|
||
|
||
func (s *BugSvc) Ping() error { return nil }
|
||
|
||
func (s *BugSvc) Save(dto BugDto) (string, error) { return "ok", nil }
|
||
|
||
func (s *BugSvc) Ticker() (string, *Stream, error) {
|
||
st := Stream{}
|
||
go func() {
|
||
st.Send("tick")
|
||
st.Close()
|
||
}()
|
||
return "first", &st, nil
|
||
}
|
||
|
||
func bugInvoke(t *testing.T, method string, data map[string]any) (*Result[any], error) {
|
||
t.Helper()
|
||
f := New()
|
||
if err := f.BindService(&BugSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if data == nil {
|
||
data = map[string]any{}
|
||
}
|
||
c := &Ctx{Ip: "1", MethodName: method, ServiceName: "BugSvc", Data: &data}
|
||
var streamCh chan any
|
||
var streamDone chan struct{}
|
||
return f.invoke(c, &streamCh, &streamDone)
|
||
}
|
||
|
||
// bug1: () error 签名的方法 invoke 应返回空数据结果而不是越界 panic
|
||
func TestBugErrorOnlyInvoke(t *testing.T) {
|
||
res, err := bugInvoke(t, "Ping", nil)
|
||
if err != nil {
|
||
t.Fatalf("err: %v", err)
|
||
}
|
||
if res.Data != nil {
|
||
t.Fatalf("expect nil data, got %v", *res.Data)
|
||
}
|
||
}
|
||
|
||
// bug2: 指针枚举字段传 null 应放行;越界值仍要报错
|
||
func TestBugNullableEnum(t *testing.T) {
|
||
if _, err := bugInvoke(t, "Save", map[string]any{"status": nil}); err != nil {
|
||
t.Fatalf("nullable enum should pass: %v", err)
|
||
}
|
||
if _, err := bugInvoke(t, "Save", map[string]any{"status": 1}); err != nil {
|
||
t.Fatalf("valid enum should pass: %v", err)
|
||
}
|
||
if _, err := bugInvoke(t, "Save", map[string]any{"status": 5}); err == nil {
|
||
t.Fatal("out-of-range enum should fail")
|
||
}
|
||
}
|
||
|
||
// bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void
|
||
func TestBugGenErrorOnly(t *testing.T) {
|
||
isolateGeneratorGlobals(t)
|
||
if err := GetFun().BindService(&BugSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
SetOutput(t.TempDir())
|
||
GenCode(GenGo{}, GenTs{})
|
||
goSrc, err := os.ReadFile(filepath.Join(getDirectory(), "go", "bug_svc.go"))
|
||
if err != nil {
|
||
t.Fatalf("go file missing: %v", err)
|
||
}
|
||
if !strings.Contains(string(goSrc), "Result[Void]") {
|
||
t.Fatalf("go: expect Result[Void], got:\n%s", goSrc)
|
||
}
|
||
tsSrc, err := os.ReadFile(filepath.Join(getDirectory(), "ts", "bugSvc.ts"))
|
||
if err != nil {
|
||
t.Fatalf("ts file missing: %v", err)
|
||
}
|
||
if !strings.Contains(string(tsSrc), "result<void>") {
|
||
t.Fatalf("ts: expect result<void>, got:\n%s", tsSrc)
|
||
}
|
||
}
|
||
|
||
// bug4+5: 响应键应为小写;(T, stream, error) 的 T 应作为流的第一条消息下发
|
||
func TestBugJsonKeysAndStreamFirst(t *testing.T) {
|
||
f := New()
|
||
if err := f.BindService(&BugSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
go f.Start(39003)
|
||
time.Sleep(300 * time.Millisecond)
|
||
|
||
resp, err := http.Post("http://127.0.0.1:39003/cell", "application/json",
|
||
strings.NewReader(`{"serviceName":"BugSvc","methodName":"Ping"}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var buf bytes.Buffer
|
||
_, _ = buf.ReadFrom(resp.Body)
|
||
_ = resp.Body.Close()
|
||
body := buf.String()
|
||
if !strings.Contains(body, `"status"`) || strings.Contains(body, `"Status"`) {
|
||
t.Fatalf("keys not lowercase: %s", body)
|
||
}
|
||
|
||
resp2, err := http.Post("http://127.0.0.1:39003/cell", "application/json",
|
||
bytes.NewReader([]byte(`{"serviceName":"BugSvc","methodName":"Ticker"}`)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer resp2.Body.Close()
|
||
var got []string
|
||
scanner := bufio.NewScanner(resp2.Body)
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var msg string
|
||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||
t.Fatalf("bad ndjson line %q: %v", line, err)
|
||
}
|
||
got = append(got, msg)
|
||
}
|
||
if len(got) != 2 || got[0] != "first" || got[1] != "tick" {
|
||
t.Fatalf("stream: %v", got)
|
||
}
|
||
}
|
||
|
||
// bug7: 非指针切片字段传 null 应放行(JSON null -> nil slice)
|
||
type NullSlicDto struct {
|
||
Tags []string
|
||
}
|
||
|
||
type NullSlicSvc struct{}
|
||
|
||
func (s *NullSlicSvc) Save(dto NullSlicDto) (string, error) { return "ok", nil }
|
||
|
||
func TestBugSliceNull(t *testing.T) {
|
||
f := New()
|
||
if err := f.BindService(&NullSlicSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data := map[string]any{"tags": nil}
|
||
c := &Ctx{Ip: "1", MethodName: "Save", ServiceName: "NullSlicSvc", Data: &data}
|
||
var streamCh chan any
|
||
var streamDone chan struct{}
|
||
if _, err := f.invoke(c, &streamCh, &streamDone); err != nil {
|
||
t.Fatalf("slice null should pass: %v", err)
|
||
}
|
||
}
|
||
|
||
// bug8: 业务方法返回 error 但已启动 goroutine 调 Send,框架应注入取消流让 goroutine 退出而非挂死
|
||
var leakDone chan struct{}
|
||
|
||
type LeakSvc struct{}
|
||
|
||
func (s *LeakSvc) Fail() (*Stream, error) {
|
||
st := Stream{}
|
||
leakDone = make(chan struct{})
|
||
go func() {
|
||
st.Send("never")
|
||
close(leakDone)
|
||
}()
|
||
return &st, errors.New("boom")
|
||
}
|
||
|
||
func TestBugStreamLeak(t *testing.T) {
|
||
f := New()
|
||
if err := f.BindService(&LeakSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
c := &Ctx{Ip: "1", MethodName: "Fail", ServiceName: "LeakSvc"}
|
||
var streamCh chan any
|
||
var streamDone chan struct{}
|
||
_, err := f.invoke(c, &streamCh, &streamDone)
|
||
if err == nil {
|
||
t.Fatal("expected error")
|
||
}
|
||
// Send goroutine 必须解除阻塞(有超时保护,防挂死)
|
||
select {
|
||
case <-leakDone:
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("Send goroutine blocked forever: stream leak")
|
||
}
|
||
}
|
||
|
||
// bug6: 与 fasthttp.RequestCtx 方法重名的用户方法不应被静默丢弃
|
||
type CollideSvc struct {
|
||
Ctx
|
||
}
|
||
|
||
func (s *CollideSvc) Cookie() (string, error) { return "cookie", nil }
|
||
|
||
func TestBugMethodNameCollision(t *testing.T) {
|
||
f := New()
|
||
if err := f.BindService(&CollideSvc{}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, ok := f.methods["CollideSvc.Cookie"]; !ok {
|
||
t.Fatal("Cookie method dropped due to name collision with fasthttp.RequestCtx")
|
||
}
|
||
res, err := func() (*Result[any], error) {
|
||
c := &Ctx{Ip: "1", MethodName: "Cookie", ServiceName: "CollideSvc"}
|
||
data := map[string]any{}
|
||
c.Data = &data
|
||
var streamCh chan any
|
||
var streamDone chan struct{}
|
||
return f.invoke(c, &streamCh, &streamDone)
|
||
}()
|
||
if err != nil {
|
||
t.Fatalf("invoke Cookie err: %v", err)
|
||
}
|
||
if (*res.Data).(string) != "cookie" {
|
||
t.Fatalf("unexpected: %v", *res.Data)
|
||
}
|
||
}
|