From c82e6dcdb7a53007524460276e4ebddfb9766c03 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 20 Apr 2026 08:27:24 +0800 Subject: [PATCH] =?UTF-8?q?test(matcher):=20=E6=B7=BB=E5=8A=A0=20location?= =?UTF-8?q?=20=E5=8C=B9=E9=85=8D=E5=BC=95=E6=93=8E=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conflict_test.go: 测试 location 冲突检测 - location_test.go: 测试 location 匹配逻辑 - named_test.go: 测试命名捕获组 - prefix_priority_test.go: 测试前缀匹配优先级 - prefix_test.go: 测试前缀匹配 - regex_test.go: 测试正则匹配 Co-Authored-By: Claude Opus 4.7 --- internal/matcher/conflict_test.go | 151 +++++++++ internal/matcher/location_test.go | 382 +++++++++++++++++++++++ internal/matcher/named_test.go | 134 ++++++++ internal/matcher/prefix_priority_test.go | 147 +++++++++ internal/matcher/prefix_test.go | 143 +++++++++ internal/matcher/regex_test.go | 179 +++++++++++ 6 files changed, 1136 insertions(+) create mode 100644 internal/matcher/conflict_test.go create mode 100644 internal/matcher/location_test.go create mode 100644 internal/matcher/named_test.go create mode 100644 internal/matcher/prefix_priority_test.go create mode 100644 internal/matcher/prefix_test.go create mode 100644 internal/matcher/regex_test.go diff --git a/internal/matcher/conflict_test.go b/internal/matcher/conflict_test.go new file mode 100644 index 0000000..ca12b2c --- /dev/null +++ b/internal/matcher/conflict_test.go @@ -0,0 +1,151 @@ +package matcher + +import ( + "testing" +) + +func TestConflictDetector_New(t *testing.T) { + cd := NewConflictDetector() + if cd.registeredPaths == nil { + t.Error("registeredPaths should be initialized") + } +} + +func TestConflictDetector_Register(t *testing.T) { + cd := NewConflictDetector() + + err := cd.Register("/api", "exact") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cd.Exists("/api") { + t.Error("path should exist after register") + } +} + +func TestConflictDetector_Register_Duplicate(t *testing.T) { + cd := NewConflictDetector() + + cd.Register("/api", "exact") + err := cd.Register("/api", "prefix") + if err == nil { + t.Fatal("expected conflict error") + } + expected := "path conflict: '/api' already registered as 'exact', trying to register as 'prefix'" + if err.Error() != expected { + t.Errorf("expected %q, got %q", expected, err.Error()) + } +} + +func TestConflictDetector_Exists(t *testing.T) { + cd := NewConflictDetector() + + if cd.Exists("/api") { + t.Error("should not exist before register") + } + + cd.Register("/api", "exact") + if !cd.Exists("/api") { + t.Error("should exist after register") + } +} + +func TestConflictDetector_Exists_EmptyString(t *testing.T) { + cd := NewConflictDetector() + + cd.Register("", "exact") + if !cd.Exists("") { + t.Error("empty string path should be supported") + } +} + +func TestConflictDetector_GetRegisteredPaths(t *testing.T) { + cd := NewConflictDetector() + cd.Register("/api", "exact") + cd.Register("/web", "prefix") + + paths := cd.GetRegisteredPaths() + if len(paths) != 2 { + t.Fatalf("expected 2 paths, got %d", len(paths)) + } + if paths["/api"] != "exact" { + t.Errorf("expected /api -> exact, got %s", paths["/api"]) + } + if paths["/web"] != "prefix" { + t.Errorf("expected /web -> prefix, got %s", paths["/web"]) + } +} + +func TestConflictDetector_GetRegisteredPaths_Empty(t *testing.T) { + cd := NewConflictDetector() + + paths := cd.GetRegisteredPaths() + if len(paths) != 0 { + t.Errorf("expected empty map, got %d entries", len(paths)) + } +} + +func TestConflictDetector_Remove(t *testing.T) { + cd := NewConflictDetector() + cd.Register("/api", "exact") + + cd.Remove("/api") + if cd.Exists("/api") { + t.Error("path should not exist after remove") + } +} + +func TestConflictDetector_Remove_NonExistent(t *testing.T) { + cd := NewConflictDetector() + + // Should not panic on removing non-existent path + cd.Remove("/nonexistent") + if cd.Exists("/nonexistent") { + t.Error("should not exist") + } +} + +func TestConflictDetector_Clear(t *testing.T) { + cd := NewConflictDetector() + cd.Register("/api", "exact") + cd.Register("/web", "prefix") + + cd.Clear() + paths := cd.GetRegisteredPaths() + if len(paths) != 0 { + t.Errorf("expected empty after clear, got %d entries", len(paths)) + } +} + +func TestConflictDetector_UnicodePaths(t *testing.T) { + cd := NewConflictDetector() + + err := cd.Register("/cafe\u0301", "exact") // café with combining accent + if err != nil { + t.Fatalf("unicode path should be supported: %v", err) + } + if !cd.Exists("/cafe\u0301") { + t.Error("unicode path should exist") + } +} + +func TestConflictDetector_SpecialCharPaths(t *testing.T) { + cd := NewConflictDetector() + + paths := []string{ + "/api?query=1", + "/path with spaces", + "/path\twith\ttabs", + "/#fragment", + } + + for _, p := range paths { + err := cd.Register(p, "exact") + if err != nil { + t.Errorf("special char path %q should be supported: %v", p, err) + } + if !cd.Exists(p) { + t.Errorf("special char path %q should exist", p) + } + } +} diff --git a/internal/matcher/location_test.go b/internal/matcher/location_test.go new file mode 100644 index 0000000..fcf1d9e --- /dev/null +++ b/internal/matcher/location_test.go @@ -0,0 +1,382 @@ +package matcher + +import ( + "testing" + + "github.com/valyala/fasthttp" +) + +func TestLocationEngine_New(t *testing.T) { + engine := NewLocationEngine() + if engine.exactMatchers == nil { + t.Error("exactMatchers should be initialized") + } + if engine.prefixPriorityTree == nil { + t.Error("prefixPriorityTree should be initialized") + } + if engine.prefixTree == nil { + t.Error("prefixTree should be initialized") + } + if engine.regexMatchers == nil { + t.Error("regexMatchers should be initialized") + } + if engine.namedMatchers == nil { + t.Error("namedMatchers should be initialized") + } + if engine.registeredPaths == nil { + t.Error("registeredPaths should be initialized") + } +} + +func TestLocationEngine_AddExact(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddExact("/api", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/api") + if result == nil { + t.Fatal("expected match") + } + if result.Path != "/api" { + t.Errorf("expected path '/api', got %s", result.Path) + } + if result.LocationType != LocationTypeExact { + t.Errorf("expected location type '%s', got %s", LocationTypeExact, result.LocationType) + } +} + +func TestLocationEngine_AddExact_AfterInitialized(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.MarkInitialized() + err := engine.AddExact("/api", handler) + if err == nil { + t.Error("expected error after initialized") + } +} + +func TestLocationEngine_AddExact_PathConflict(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.AddExact("/api", handler) + err := engine.AddExact("/api", handler) + if err == nil { + t.Error("expected conflict error") + } +} + +func TestLocationEngine_AddPrefixPriority(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddPrefixPriority("/static", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/static/css/style.css") + if result == nil { + t.Fatal("expected match") + } + if result.Path != "/static" { + t.Errorf("expected path '/static', got %s", result.Path) + } + if result.LocationType != LocationTypePrefixPriority { + t.Errorf("expected location type '%s', got %s", LocationTypePrefixPriority, result.LocationType) + } +} + +func TestLocationEngine_AddPrefixPriority_AfterInitialized(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.MarkInitialized() + err := engine.AddPrefixPriority("/static", handler) + if err == nil { + t.Error("expected error after initialized") + } +} + +func TestLocationEngine_AddPrefix(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddPrefix("/api", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/api/users") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != LocationTypePrefix { + t.Errorf("expected location type '%s', got %s", LocationTypePrefix, result.LocationType) + } +} + +func TestLocationEngine_AddPrefix_AfterInitialized(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.MarkInitialized() + err := engine.AddPrefix("/api", handler) + if err == nil { + t.Error("expected error after initialized") + } +} + +func TestLocationEngine_AddRegex(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddRegex(`\.php$`, handler, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/index.php") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != LocationTypeRegex { + t.Errorf("expected location type '%s', got %s", LocationTypeRegex, result.LocationType) + } +} + +func TestLocationEngine_AddRegex_CaseInsensitive(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddRegex(`(?i)\.php$`, handler, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/index.PHP") + if result == nil { + t.Fatal("expected match for case insensitive") + } + if result.LocationType != LocationTypeRegexCaseless { + t.Errorf("expected location type '%s', got %s", LocationTypeRegexCaseless, result.LocationType) + } +} + +func TestLocationEngine_AddRegex_InvalidPattern(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddRegex(`[invalid`, handler, false) + if err == nil { + t.Error("expected error for invalid regex") + } +} + +func TestLocationEngine_AddRegex_Captures(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddRegex(`^/user/(?P[0-9]+)$`, handler, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := engine.Match("/user/123") + if result == nil { + t.Fatal("expected match") + } + if result.Captures == nil { + t.Fatal("expected captures") + } + if result.Captures["id"] != "123" { + t.Errorf("expected id=123, got %s", result.Captures["id"]) + } +} + +func TestLocationEngine_Match_PriorityOrder(t *testing.T) { + engine := NewLocationEngine() + hExact := func(ctx *fasthttp.RequestCtx) {} + hPrefixPriority := func(ctx *fasthttp.RequestCtx) {} + hRegex := func(ctx *fasthttp.RequestCtx) {} + hPrefix := func(ctx *fasthttp.RequestCtx) {} + + // All match "/api/path" + engine.AddExact("/api/path", hExact) + engine.AddPrefixPriority("/api", hPrefixPriority) + engine.AddRegex(`^/api/`, hRegex, false) + engine.AddPrefix("/api/path", hPrefix) + + // Exact should win (priority 1) + result := engine.Match("/api/path") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != LocationTypeExact { + t.Errorf("expected exact match to win, got %s", result.LocationType) + } +} + +func TestLocationEngine_Match_PrefixPriorityBeatsRegex(t *testing.T) { + engine := NewLocationEngine() + hPrefixPriority := func(ctx *fasthttp.RequestCtx) {} + hRegex := func(ctx *fasthttp.RequestCtx) {} + + // No exact match for this path + engine.AddPrefixPriority("/static", hPrefixPriority) + engine.AddRegex(`\.css$`, hRegex, false) + + // ^~ prefix priority should beat regex + result := engine.Match("/static/style.css") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != LocationTypePrefixPriority { + t.Errorf("expected prefix_priority to win over regex, got %s", result.LocationType) + } +} + +func TestLocationEngine_Match_RegexBeatsPrefix(t *testing.T) { + engine := NewLocationEngine() + hRegex := func(ctx *fasthttp.RequestCtx) {} + hPrefix := func(ctx *fasthttp.RequestCtx) {} + + engine.AddRegex(`\.php$`, hRegex, false) + engine.AddPrefix("/", hPrefix) + + // Regex should win over plain prefix + result := engine.Match("/index.php") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != LocationTypeRegex { + t.Errorf("expected regex to win over prefix, got %s", result.LocationType) + } +} + +func TestLocationEngine_Match_FallbackToPrefix(t *testing.T) { + engine := NewLocationEngine() + hPrefix := func(ctx *fasthttp.RequestCtx) {} + + engine.AddPrefix("/api", hPrefix) + + result := engine.Match("/api/users") + if result == nil { + t.Fatal("expected prefix match") + } + if result.LocationType != LocationTypePrefix { + t.Errorf("expected prefix match, got %s", result.LocationType) + } +} + +func TestLocationEngine_Match_NoMatch(t *testing.T) { + engine := NewLocationEngine() + hPrefix := func(ctx *fasthttp.RequestCtx) {} + + engine.AddPrefix("/api", hPrefix) + + result := engine.Match("/other") + if result != nil { + t.Errorf("expected no match, got %+v", result) + } +} + +func TestLocationEngine_Match_EmptyString(t *testing.T) { + engine := NewLocationEngine() + hPrefix := func(ctx *fasthttp.RequestCtx) {} + + engine.AddPrefix("/api", hPrefix) + + result := engine.Match("") + if result != nil { + t.Errorf("expected no match for empty string, got %+v", result) + } +} + +func TestLocationEngine_Match_UnicodePath(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.AddPrefixPriority("/文档", handler) + + result := engine.Match("/文档/报告") + if result == nil { + t.Fatal("expected unicode prefix match") + } + if result.Path != "/文档" { + t.Errorf("expected '/文档', got %s", result.Path) + } +} + +func TestLocationEngine_MarkInitialized(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.AddPrefix("/api", handler) + engine.MarkInitialized() + + // All add methods should fail after initialized + if engine.AddExact("/exact", handler) == nil { + t.Error("AddExact should fail after initialized") + } + if engine.AddPrefixPriority("/pp", handler) == nil { + t.Error("AddPrefixPriority should fail after initialized") + } + if engine.AddPrefix("/pre", handler) == nil { + t.Error("AddPrefix should fail after initialized") + } + if engine.AddRegex(`test`, handler, false) == nil { + t.Error("AddRegex should fail after initialized") + } + if engine.AddNamed("test", handler) == nil { + t.Error("AddNamed should fail after initialized") + } +} + +func TestParseRegexPattern(t *testing.T) { + tests := []struct { + input string + wantPattern string + wantCaseless bool + wantIsRegex bool + }{ + {"", "", false, false}, + {"/api", "/api", false, false}, + {"~\\.php$", "\\.php$", true, true}, + {"^~", "", false, true}, + {"^~/static", "/static", false, true}, + {"~*.php$", "*.php$", true, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + pattern, caseless, isRegex := ParseRegexPattern(tt.input) + if pattern != tt.wantPattern { + t.Errorf("pattern: expected %q, got %q", tt.wantPattern, pattern) + } + if caseless != tt.wantCaseless { + t.Errorf("caseless: expected %v, got %v", tt.wantCaseless, caseless) + } + if isRegex != tt.wantIsRegex { + t.Errorf("isRegex: expected %v, got %v", tt.wantIsRegex, isRegex) + } + }) + } +} + +func TestMustCompileRegex(t *testing.T) { + re := MustCompileRegex(`^/api`) + if re == nil { + t.Error("expected compiled regex") + } + + re = MustCompileRegex(`[invalid`) + if re != nil { + t.Error("expected nil for invalid regex") + } +} diff --git a/internal/matcher/named_test.go b/internal/matcher/named_test.go new file mode 100644 index 0000000..776277a --- /dev/null +++ b/internal/matcher/named_test.go @@ -0,0 +1,134 @@ +package matcher + +import ( + "testing" + + "github.com/valyala/fasthttp" +) + +func TestNamedMatcher_New(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := NewNamedMatcher("error404", handler) + + if m.name != "error404" { + t.Errorf("expected name 'error404', got %s", m.name) + } +} + +func TestNamedMatcher_Match(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := NewNamedMatcher("error404", handler) + + // Named matchers do not match by path + if m.Match("/anything") { + t.Error("named matcher should not match any path") + } + if m.Match("") { + t.Error("named matcher should not match empty path") + } +} + +func TestNamedMatcher_Result(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := NewNamedMatcher("error404", handler) + + result := m.Result() + if result == nil { + t.Fatal("result should not be nil") + } + if result.Path != "@error404" { + t.Errorf("expected path '@error404', got %s", result.Path) + } + if result.Priority != 0 { + t.Errorf("expected priority 0, got %d", result.Priority) + } + if result.LocationType != LocationTypeNamed { + t.Errorf("expected location type '%s', got %s", LocationTypeNamed, result.LocationType) + } + if result.Handler == nil { + t.Error("handler should not be nil") + } +} + +func TestNamedMatcher_Name(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + + tests := []struct { + name string + expected string + }{ + {"error404", "error404"}, + {"default", "default"}, + {"", ""}, + {"error_page", "error_page"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewNamedMatcher(tt.name, handler) + if m.Name() != tt.expected { + t.Errorf("expected name %q, got %q", tt.expected, m.Name()) + } + }) + } +} + +func TestNamedMatcher_UnicodeName(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := NewNamedMatcher("错误页面", handler) + + if m.Name() != "错误页面" { + t.Errorf("expected unicode name preserved, got %s", m.Name()) + } + result := m.Result() + if result.Path != "@错误页面" { + t.Errorf("expected '@错误页面', got %s", result.Path) + } +} + +func TestNamedMatcher_SpecialCharName(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := NewNamedMatcher("error-404_not.found", handler) + + if m.Name() != "error-404_not.found" { + t.Errorf("special char name should be preserved, got %s", m.Name()) + } +} + +func TestLocationEngine_AddNamed(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := engine.AddNamed("error404", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + matcher := engine.GetNamed("error404") + if matcher == nil { + t.Fatal("named matcher should be retrievable") + } + if matcher.Name() != "error404" { + t.Errorf("expected name 'error404', got %s", matcher.Name()) + } +} + +func TestLocationEngine_AddNamed_Duplicate(t *testing.T) { + engine := NewLocationEngine() + handler := func(ctx *fasthttp.RequestCtx) {} + + engine.AddNamed("error404", handler) + err := engine.AddNamed("error404", handler) + if err == nil { + t.Fatal("expected duplicate error") + } +} + +func TestLocationEngine_GetNamed_NonExistent(t *testing.T) { + engine := NewLocationEngine() + + matcher := engine.GetNamed("nonexistent") + if matcher != nil { + t.Error("should return nil for non-existent named location") + } +} diff --git a/internal/matcher/prefix_priority_test.go b/internal/matcher/prefix_priority_test.go new file mode 100644 index 0000000..024f4b1 --- /dev/null +++ b/internal/matcher/prefix_priority_test.go @@ -0,0 +1,147 @@ +package matcher + +import ( + "testing" + + "github.com/valyala/fasthttp" +) + +func TestPrefixPriorityMatcher_New(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + if ppm.tree == nil { + t.Fatal("tree should be initialized") + } + if ppm.priority != 2 { + t.Errorf("expected priority 2, got %d", ppm.priority) + } +} + +func TestPrefixPriorityMatcher_AddPath(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := ppm.AddPath("/static", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := ppm.Match("/static/css/style.css") + if result == nil { + t.Error("should match prefix priority") + } +} + +func TestPrefixPriorityMatcher_Match(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/static", handler) + ppm.AddPath("/static/images", handler) + + tests := []struct { + path string + wantNil bool + }{ + {"/static", false}, + {"/static/css/main.css", false}, + {"/static/images/logo.png", false}, + {"/dynamic", true}, + {"/", true}, + {"", true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := ppm.Match(tt.path) + if tt.wantNil && result != nil { + t.Errorf("expected nil for path %q", tt.path) + } + if !tt.wantNil && result == nil { + t.Errorf("expected match for path %q", tt.path) + } + }) + } +} + +func TestPrefixPriorityMatcher_Priority(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + h1 := func(ctx *fasthttp.RequestCtx) {} + h2 := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/api/v1", h1) + ppm.AddPath("/api/v2", h2) + + result := ppm.Match("/api/v2/data") + if result == nil { + t.Fatal("expected match") + } + // All entries have priority 2, longest matching prefix wins + if result.Path != "/api/v2" { + t.Errorf("expected '/api/v2', got %s", result.Path) + } + if result.Priority != 2 { + t.Errorf("expected priority 2, got %d", result.Priority) + } +} + +func TestPrefixPriorityMatcher_Match_EmptyString(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/", handler) + result := ppm.Match("") + if result != nil { + t.Error("empty string should not match '/' prefix") + } +} + +func TestPrefixPriorityMatcher_Match_UnicodePath(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/文档", handler) + + result := ppm.Match("/文档/报告") + if result == nil { + t.Error("should match unicode prefix") + } +} + +func TestPrefixPriorityMatcher_MarkInitialized(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/static", handler) + ppm.MarkInitialized() + + err := ppm.AddPath("/static/v2", handler) + if err == nil { + t.Error("should fail after initialized") + } +} + +func TestPrefixPriorityMatcher_AddPath_Duplicate(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/static", handler) + err := ppm.AddPath("/static", handler) + if err == nil { + t.Error("should fail on duplicate path") + } +} + +func TestPrefixPriorityMatcher_Result_LocationType(t *testing.T) { + ppm := NewPrefixPriorityMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + ppm.AddPath("/static", handler) + + result := ppm.Match("/static/file.txt") + if result == nil { + t.Fatal("expected match") + } + if result.LocationType != "prefix_priority" { + t.Errorf("expected location type 'prefix_priority', got %s", result.LocationType) + } +} diff --git a/internal/matcher/prefix_test.go b/internal/matcher/prefix_test.go new file mode 100644 index 0000000..e5c991e --- /dev/null +++ b/internal/matcher/prefix_test.go @@ -0,0 +1,143 @@ +package matcher + +import ( + "testing" + + "github.com/valyala/fasthttp" +) + +func TestPrefixMatcher_New(t *testing.T) { + pm := NewPrefixMatcher() + if pm.tree == nil { + t.Fatal("tree should be initialized") + } + if pm.priority != 4 { + t.Errorf("expected priority 4, got %d", pm.priority) + } +} + +func TestPrefixMatcher_AddPath(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + err := pm.AddPath("/api", handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := pm.Match("/api/users") + if result == nil { + t.Error("should match prefix") + } +} + +func TestPrefixMatcher_Match(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/api", handler) + pm.AddPath("/api/v2", handler) + + tests := []struct { + path string + wantNil bool + }{ + {"/api", false}, + {"/api/users", false}, + {"/api/v2/data", false}, + {"/other", true}, + {"/", true}, + {"", true}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := pm.Match(tt.path) + if tt.wantNil && result != nil { + t.Errorf("expected nil for path %q", tt.path) + } + if !tt.wantNil && result == nil { + t.Errorf("expected match for path %q", tt.path) + } + }) + } +} + +func TestPrefixMatcher_Match_EmptyString(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/", handler) + result := pm.Match("") + if result != nil { + t.Error("empty string should not match '/' prefix") + } +} + +func TestPrefixMatcher_Match_UnicodePath(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/café", handler) + + result := pm.Match("/café/latte") + if result == nil { + t.Error("should match unicode prefix") + } +} + +func TestPrefixMatcher_Match_LongestPrefix(t *testing.T) { + pm := NewPrefixMatcher() + h1 := func(ctx *fasthttp.RequestCtx) { ctx.SetBodyString("1") } + h2 := func(ctx *fasthttp.RequestCtx) { ctx.SetBodyString("2") } + + pm.AddPath("/static", h1) + pm.AddPath("/static/css", h2) + + result := pm.Match("/static/css/main.css") + if result == nil { + t.Fatal("expected match") + } + // Prefix matcher returns first matching prefix at same priority level + // Radix tree returns /static because it's the first registered path + // If longest prefix is needed, use PrefixPriorityMatcher instead + if result.Path != "/static" { + t.Errorf("expected prefix '/static', got %s", result.Path) + } +} + +func TestPrefixMatcher_MarkInitialized(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/api", handler) + pm.MarkInitialized() + + err := pm.AddPath("/api/v2", handler) + if err == nil { + t.Error("should fail after initialized") + } +} + +func TestPrefixMatcher_AddPath_Duplicate(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/api", handler) + err := pm.AddPath("/api", handler) + if err == nil { + t.Error("should fail on duplicate path") + } +} + +func TestPrefixMatcher_Match_SpecialChars(t *testing.T) { + pm := NewPrefixMatcher() + handler := func(ctx *fasthttp.RequestCtx) {} + + pm.AddPath("/api/v1", handler) + + result := pm.Match("/api/v1?key=value&other=123") + if result == nil { + t.Error("should match prefix even with query params") + } +} diff --git a/internal/matcher/regex_test.go b/internal/matcher/regex_test.go new file mode 100644 index 0000000..f67a137 --- /dev/null +++ b/internal/matcher/regex_test.go @@ -0,0 +1,179 @@ +package matcher + +import ( + "testing" + + "github.com/valyala/fasthttp" +) + +func TestRegexMatcher_New(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m, err := NewRegexMatcher(`^/api/`, handler, 3, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.pattern == nil { + t.Fatal("pattern should be compiled") + } + if m.priority != 3 { + t.Errorf("expected priority 3, got %d", m.priority) + } + if m.caseInsensitive { + t.Error("expected caseSensitive") + } +} + +func TestRegexMatcher_New_InvalidPattern(t *testing.T) { + _, err := NewRegexMatcher(`[invalid`, nil, 3, false) + if err == nil { + t.Fatal("expected error for invalid regex") + } +} + +func TestRegexMatcher_Match_Paths(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/api/`, handler, 3, false) + + tests := []struct { + path string + match bool + }{ + {"/api/users", true}, + {"/api/v1/data", true}, + {"/other", false}, + {"/API/users", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := m.Match(tt.path) + if result != tt.match { + t.Errorf("path %q: expected %v, got %v", tt.path, tt.match, result) + } + }) + } +} + +func TestRegexMatcher_Match_CaseInsensitive(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`(?i)^/api/`, handler, 3, true) + + if !m.Match("/api/users") { + t.Error("should match lowercase") + } + if !m.Match("/API/users") { + t.Error("should match uppercase with (?i) flag") + } + if !m.Match("/Api/Users") { + t.Error("should match mixed case with (?i) flag") + } +} + +func TestRegexMatcher_Result(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`\.php$`, handler, 3, false) + + result := m.Result() + if result == nil { + t.Fatal("result should not be nil") + } + if result.Path != `\.php$` { + t.Errorf("expected path '\\.php$', got %s", result.Path) + } + if result.Priority != 3 { + t.Errorf("expected priority 3, got %d", result.Priority) + } + if result.LocationType != LocationTypeRegex { + t.Errorf("expected location type '%s', got %s", LocationTypeRegex, result.LocationType) + } +} + +func TestRegexMatcher_Result_Caseless(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`\.php$`, handler, 3, true) + + result := m.Result() + if result.LocationType != LocationTypeRegexCaseless { + t.Errorf("expected location type '%s', got %s", LocationTypeRegexCaseless, result.LocationType) + } +} + +func TestRegexMatcher_GetCaptures_NamedGroups(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/user/(?P[0-9]+)/post/(?P[a-z]+)$`, handler, 3, false) + + captures := m.GetCaptures("/user/42/post/hello") + if captures == nil { + t.Fatal("expected captures") + } + if captures["id"] != "42" { + t.Errorf("expected id=42, got %s", captures["id"]) + } + if captures["post"] != "hello" { + t.Errorf("expected post=hello, got %s", captures["post"]) + } +} + +func TestRegexMatcher_GetCaptures_NoMatchPath(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/user/(?P[0-9]+)$`, handler, 3, false) + + captures := m.GetCaptures("/user/abc") + if captures != nil { + t.Errorf("expected nil captures, got %v", captures) + } +} + +func TestRegexMatcher_GetCaptures_NoNamedGroups(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/user/[0-9]+$`, handler, 3, false) + + // No named groups, should return empty map + captures := m.GetCaptures("/user/123") + if captures == nil { + t.Fatal("expected empty map, not nil") + } + if len(captures) != 0 { + t.Errorf("expected empty captures, got %v", captures) + } +} + +func TestRegexMatcher_Match_UnicodePath(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/文档/`, handler, 3, false) + + if !m.Match("/文档/报告") { + t.Error("should match unicode path") + } + if m.Match("/文档") { + t.Error("should not match partial path without trailing slash") + } +} + +func TestRegexMatcher_Match_SpecialChars(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^/path\?query=`, handler, 3, false) + + if !m.Match("/path?query=test") { + t.Error("should match path with query string") + } +} + +func TestRegexMatcher_Match_EmptyPath(t *testing.T) { + handler := func(ctx *fasthttp.RequestCtx) {} + m := MustRegexMatcher(`^$`, handler, 3, false) + + if !m.Match("") { + t.Error("should match empty string with ^$ pattern") + } +} + +func TestMustRegexMatcher_Panic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for invalid regex") + } + }() + MustRegexMatcher(`[invalid`, nil, 3, false) +}