diff --git a/go.mod b/go.mod index 65f088a..a341fc4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.22 require ( github.com/fsnotify/fsnotify v1.7.0 - github.com/spf13/cast v1.6.0 github.com/stretchr/testify v1.8.4 ) diff --git a/go.sum b/go.sum index abaf70d..50a5ccd 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,14 @@ -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kyoh86/scopelint v0.2.0 h1:suOCh1T05nIY8srcI266aqwf3RLtO8kniZOTaAnzRyg= -github.com/kyoh86/scopelint v0.2.0/go.mod h1:veFgnmDG8sPR5nFaXGX2mEIOXKHjWpGo79v/NaiTcRE= -github.com/mattn/go-isatty v0.0.6/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/alecthomas/kingpin.v2 v2.2.5 h1:qskSCq465uEvC3oGocwvZNsO3RF3SpLVLumOAhL0bXo= -gopkg.in/alecthomas/kingpin.v2 v2.2.5/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/config/config_test.go b/src/config/config_test.go index c77bfd4..ed90980 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -261,3 +261,275 @@ func TestWatchConfig(t *testing.T) { // We can't easily test the file watching functionality in a unit test // but we can at least verify the watcher is set up without errors } + +func TestLoadConfig_Defaults(t *testing.T) { + tempDir, err := os.MkdirTemp("", "defaults-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + // Config with no port, logLevel, or logFormat + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, 3000, cfg.Port, "default port should be 3000") + assert.Equal(t, "info", cfg.LogLevel, "default logLevel should be info") + assert.Equal(t, "text", cfg.LogFormat, "default logFormat should be text") +} + +func TestLoadConfig_InvalidJSON(t *testing.T) { + tempDir, err := os.MkdirTemp("", "invalid-json-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "config.json") + err = os.WriteFile(configPath, []byte(`{invalid json}`), 0644) + assert.NoError(t, err) + + _, err = LoadConfig(configPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error parsing config file") +} + +func TestLoadConfig_FileNotFound(t *testing.T) { + _, err := LoadConfig("/nonexistent/path/config.json") + assert.Error(t, err) + assert.Contains(t, err.Error(), "error reading config file") +} + +func TestLoadConfig_WithHost(t *testing.T) { + tempDir, err := os.MkdirTemp("", "host-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "host": "localhost", + "port": 9090, + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, "localhost", cfg.Host) + assert.Equal(t, 9090, cfg.Port) +} + +func TestConfig_GetEndpoints(t *testing.T) { + cfg := &Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/a"}, + {Method: "POST", Path: "/b"}, + }, + } + + endpoints := cfg.GetEndpoints() + assert.Len(t, endpoints, 2) + assert.Equal(t, "/a", endpoints[0].Path) + assert.Equal(t, "/b", endpoints[1].Path) + + // Modifying returned slice should not affect original + endpoints[0].Path = "/modified" + assert.Equal(t, "/a", cfg.Endpoints[0].Path) +} + +func TestConfig_GetPort(t *testing.T) { + cfg := &Config{Port: 4000} + assert.Equal(t, 4000, cfg.GetPort()) +} + +func TestConfig_GetHost(t *testing.T) { + cfg := &Config{Host: "example.com"} + assert.Equal(t, "example.com", cfg.GetHost()) +} + +func TestConfig_GetLogConfig(t *testing.T) { + cfg := &Config{ + LogLevel: "debug", + LogFormat: "json", + LogPath: "/var/log/server.log", + } + + level, format, path := cfg.GetLogConfig() + assert.Equal(t, "debug", level) + assert.Equal(t, "json", format) + assert.Equal(t, "/var/log/server.log", path) +} + +func TestConfig_Validate_DifferentMethodsSamePath(t *testing.T) { + tempDir, err := os.MkdirTemp("", "methods-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + cfg := Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/users", JsonPath: jsonFile, Status: 200}, + {Method: "POST", Path: "/users", JsonPath: jsonFile, Status: 201}, + {Method: "PUT", Path: "/users", JsonPath: jsonFile, Status: 200}, + {Method: "DELETE", Path: "/users", JsonPath: jsonFile, Status: 204}, + }, + } + + err = cfg.Validate() + assert.NoError(t, err, "different HTTP methods on the same path should be allowed") +} + +func TestConfig_Validate_MultipleEndpoints(t *testing.T) { + tempDir, err := os.MkdirTemp("", "multi-ep-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + cfg := Config{ + Endpoints: []Endpoint{ + {Method: "GET", Path: "/users", JsonPath: jsonFile, Status: 200}, + {Method: "GET", Path: "/posts", JsonPath: jsonFile, Status: 200}, + {Method: "GET", Path: "/comments", JsonPath: jsonFile, Status: 200}, + }, + } + + err = cfg.Validate() + assert.NoError(t, err) +} + +func TestConfig_Validate_EndpointWithType(t *testing.T) { + tempDir, err := os.MkdirTemp("", "type-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + cfg := Config{ + Endpoints: []Endpoint{ + {Type: "api", Method: "GET", Path: "/test", JsonPath: jsonFile, Status: 200}, + }, + } + + err = cfg.Validate() + assert.NoError(t, err) +} + +func TestConfig_Reload_InvalidConfig(t *testing.T) { + tempDir, err := os.MkdirTemp("", "reload-invalid-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + // Create initial valid config + configPath := filepath.Join(tempDir, "config.json") + initialConfig := `{ + "port": 8080, + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(initialConfig), 0644) + assert.NoError(t, err) + + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, 8080, cfg.Port) + + // Overwrite with invalid JSON + err = os.WriteFile(configPath, []byte(`{invalid}`), 0644) + assert.NoError(t, err) + + // Reload should fail + err = cfg.Reload(configPath) + assert.Error(t, err) + // Original config should remain unchanged + assert.Equal(t, 8080, cfg.Port) +} + +func TestLoadConfig_WithLogPath(t *testing.T) { + tempDir, err := os.MkdirTemp("", "logpath-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + configPath := filepath.Join(tempDir, "config.json") + configContent := `{ + "port": 3000, + "logLevel": "warn", + "logFormat": "json", + "logPath": "/tmp/test-server.log", + "endpoints": [ + { + "method": "GET", + "status": 200, + "path": "/test", + "jsonPath": "` + jsonFile + `" + } + ] + }` + err = os.WriteFile(configPath, []byte(configContent), 0644) + assert.NoError(t, err) + + cfg, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.Equal(t, "warn", cfg.LogLevel) + assert.Equal(t, "json", cfg.LogFormat) + assert.Equal(t, "/tmp/test-server.log", cfg.LogPath) +} + +func TestConfig_Validate_EmptyJsonPath(t *testing.T) { + // Endpoint with no jsonPath and no folder should still pass validation + // (jsonPath check is only for non-empty jsonPath) + cfg := Config{ + Endpoints: []Endpoint{ + {Method: "POST", Path: "/webhook", Status: 200}, + }, + } + + err := cfg.Validate() + assert.NoError(t, err) +} diff --git a/src/handler/handler_test.go b/src/handler/handler_test.go new file mode 100644 index 0000000..eaf9c5f --- /dev/null +++ b/src/handler/handler_test.go @@ -0,0 +1,644 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tkc/go-json-server/src/config" + "github.com/tkc/go-json-server/src/logger" +) + +// newTestLogger creates a logger that writes to a buffer (for testing) +func newTestLogger(buf *bytes.Buffer) *logger.Logger { + log, _ := logger.NewLogger(logger.LogConfig{ + Level: logger.LevelDebug, + Format: logger.FormatText, + TimeFormat: time.RFC3339, + }) + log.SetWriter(buf) + return log +} + +// --- ResponseCache tests --- + +func TestResponseCache_SetAndGet(t *testing.T) { + cache := NewResponseCache() + content := []byte(`{"key":"value"}`) + + cache.Set("GET:/test", content, 5*time.Minute) + + got, found := cache.Get("GET:/test") + assert.True(t, found) + assert.Equal(t, content, got) +} + +func TestResponseCache_GetMiss(t *testing.T) { + cache := NewResponseCache() + + got, found := cache.Get("GET:/nonexistent") + assert.False(t, found) + assert.Nil(t, got) +} + +func TestResponseCache_Expiration(t *testing.T) { + cache := NewResponseCache() + content := []byte(`{"key":"value"}`) + + // Set with very short TTL + cache.Set("GET:/test", content, 1*time.Millisecond) + + // Wait for expiration + time.Sleep(10 * time.Millisecond) + + got, found := cache.Get("GET:/test") + assert.False(t, found) + assert.Nil(t, got) +} + +func TestResponseCache_Clear(t *testing.T) { + cache := NewResponseCache() + cache.Set("GET:/a", []byte(`"a"`), 5*time.Minute) + cache.Set("GET:/b", []byte(`"b"`), 5*time.Minute) + + cache.Clear() + + _, foundA := cache.Get("GET:/a") + _, foundB := cache.Get("GET:/b") + assert.False(t, foundA) + assert.False(t, foundB) +} + +func TestResponseCache_Overwrite(t *testing.T) { + cache := NewResponseCache() + + cache.Set("GET:/test", []byte(`"old"`), 5*time.Minute) + cache.Set("GET:/test", []byte(`"new"`), 5*time.Minute) + + got, found := cache.Get("GET:/test") + assert.True(t, found) + assert.Equal(t, []byte(`"new"`), got) +} + +// --- extractPathParams tests --- + +func TestExtractPathParams(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Config: &config.Config{}, + Logger: log, + Cache: NewResponseCache(), + CacheTTL: 5 * time.Minute, + PathParams: make(map[string][]string), + paramRegexp: compileParamRegexp(), + } + + tests := []struct { + name string + path string + expected []string + }{ + {"No params", "/users", []string{}}, + {"Single param", "/users/:id", []string{"id"}}, + {"Multiple params", "/users/:userId/posts/:postId", []string{"userId", "postId"}}, + {"Root path", "/", []string{}}, + {"Nested with one param", "/api/v1/users/:id", []string{"id"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := s.extractPathParams(tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +// --- matchPath tests --- + +func TestMatchPath(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Config: &config.Config{}, + Logger: log, + Cache: NewResponseCache(), + CacheTTL: 5 * time.Minute, + PathParams: make(map[string][]string), + paramRegexp: compileParamRegexp(), + } + + // Register path params + s.PathParams["/users/:id"] = []string{"id"} + s.PathParams["/users/:userId/posts/:postId"] = []string{"userId", "postId"} + + tests := []struct { + name string + pattern string + path string + expectMatch bool + expectParams map[string]string + }{ + { + name: "Exact match", + pattern: "/users", + path: "/users", + expectMatch: true, + }, + { + name: "Single param match", + pattern: "/users/:id", + path: "/users/42", + expectMatch: true, + expectParams: map[string]string{"id": "42"}, + }, + { + name: "Multiple params match", + pattern: "/users/:userId/posts/:postId", + path: "/users/1/posts/99", + expectMatch: true, + expectParams: map[string]string{"userId": "1", "postId": "99"}, + }, + { + name: "No match - different path", + pattern: "/users", + path: "/posts", + expectMatch: false, + }, + { + name: "No match - wrong segment count", + pattern: "/users/:id", + path: "/users/1/extra", + expectMatch: false, + }, + { + name: "No match - pattern without params doesn't match different path", + pattern: "/users", + path: "/users/1", + expectMatch: false, + }, + { + name: "Param with string value", + pattern: "/users/:id", + path: "/users/john", + expectMatch: true, + expectParams: map[string]string{"id": "john"}, + }, + { + name: "Root exact match", + pattern: "/", + path: "/", + expectMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, params := s.matchPath(tt.pattern, tt.path) + assert.Equal(t, tt.expectMatch, match) + if tt.expectParams != nil { + assert.Equal(t, tt.expectParams, params) + } + }) + } +} + +// --- HandleRequest tests --- + +func setupTestServer(t *testing.T) (*Server, string) { + t.Helper() + + tempDir, err := os.MkdirTemp("", "handler-test") + assert.NoError(t, err) + + // Create JSON files for endpoints + usersJSON := filepath.Join(tempDir, "users.json") + err = os.WriteFile(usersJSON, []byte(`[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]`), 0644) + assert.NoError(t, err) + + userDetailJSON := filepath.Join(tempDir, "user-detail.json") + err = os.WriteFile(userDetailJSON, []byte(`{"id":":id","name":"User :id"}`), 0644) + assert.NoError(t, err) + + userPostJSON := filepath.Join(tempDir, "user-post.json") + err = os.WriteFile(userPostJSON, []byte(`{"userId":":userId","postId":":postId"}`), 0644) + assert.NoError(t, err) + + createdJSON := filepath.Join(tempDir, "created.json") + err = os.WriteFile(createdJSON, []byte(`{"status":"created"}`), 0644) + assert.NoError(t, err) + + healthJSON := filepath.Join(tempDir, "health.json") + err = os.WriteFile(healthJSON, []byte(`{"status":"ok"}`), 0644) + assert.NoError(t, err) + + // Create static folder with a file + staticDir := filepath.Join(tempDir, "static") + err = os.Mkdir(staticDir, 0755) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("hello"), 0644) + assert.NoError(t, err) + + cfg := &config.Config{ + Port: 8080, + LogLevel: "debug", + Endpoints: []config.Endpoint{ + {Method: "GET", Status: 200, Path: "/", JsonPath: healthJSON}, + {Method: "GET", Status: 200, Path: "/users", JsonPath: usersJSON}, + {Method: "GET", Status: 200, Path: "/users/:id", JsonPath: userDetailJSON}, + {Method: "GET", Status: 200, Path: "/users/:userId/posts/:postId", JsonPath: userPostJSON}, + {Method: "POST", Status: 201, Path: "/users", JsonPath: createdJSON}, + {Method: "DELETE", Status: 204, Path: "/users/:id", JsonPath: userDetailJSON}, + {Path: "/static", Folder: staticDir}, + }, + } + + var buf bytes.Buffer + log := newTestLogger(&buf) + + server := NewServer(cfg, log, 5*time.Minute) + return server, tempDir +} + +func TestHandleRequest_GET(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + + var body []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Len(t, body, 2) + assert.Equal(t, "Alice", body[0]["name"]) +} + +func TestHandleRequest_GET_Root(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Equal(t, "ok", body["status"]) +} + +func TestHandleRequest_POST(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(`{"name":"Charlie"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var body map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Equal(t, "created", body["status"]) +} + +func TestHandleRequest_DELETE(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("DELETE", "/users/42", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestHandleRequest_OPTIONS(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("OPTIONS", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST") +} + +func TestHandleRequest_NotFound(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/nonexistent", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + + var body map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Equal(t, "Not found", body["error"]) +} + +func TestHandleRequest_MethodNotAllowed(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + // PUT /users is not configured, should return 404 + req := httptest.NewRequest("PUT", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleRequest_PathParams_Single(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/users/42", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + // The :id should be replaced with "42" in the JSON + assert.Equal(t, "42", body["id"]) + assert.Equal(t, "User 42", body["name"]) +} + +func TestHandleRequest_PathParams_Multiple(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/users/5/posts/10", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Equal(t, "5", body["userId"]) + assert.Equal(t, "10", body["postId"]) +} + +func TestHandleRequest_StaticFileServer(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + // Use a direct file path (not index.html which may trigger redirect) + staticDir := filepath.Join(tempDir, "static") + err := os.WriteFile(filepath.Join(staticDir, "test.txt"), []byte("static content"), 0644) + assert.NoError(t, err) + + req := httptest.NewRequest("GET", "/static/test.txt", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "static content") +} + +func TestHandleRequest_CORSHeaders(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "DELETE") +} + +func TestHandleRequest_Caching(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + // First request - should miss cache + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + assert.Equal(t, http.StatusOK, w.Code) + firstBody := w.Body.String() + + // Second request - should hit cache + req2 := httptest.NewRequest("GET", "/users", nil) + w2 := httptest.NewRecorder() + server.HandleRequest(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, firstBody, w2.Body.String()) +} + +func TestHandleRequest_ClearCache(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + // Populate cache + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Clear cache + server.ClearCache() + + // Verify cache is cleared by checking internal state + _, found := server.Cache.Get("GET:/users") + assert.False(t, found) +} + +// --- getJSONResponse tests --- + +func TestGetJSONResponse_NoParams(t *testing.T) { + tempDir, err := os.MkdirTemp("", "json-resp-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "data.json") + err = os.WriteFile(jsonFile, []byte(`{"message":"hello"}`), 0644) + assert.NoError(t, err) + + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Logger: log, + paramRegexp: compileParamRegexp(), + } + + result, err := s.getJSONResponse(jsonFile, nil) + assert.NoError(t, err) + assert.Equal(t, `{"message":"hello"}`, string(result)) +} + +func TestGetJSONResponse_WithParams(t *testing.T) { + tempDir, err := os.MkdirTemp("", "json-resp-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "data.json") + err = os.WriteFile(jsonFile, []byte(`{"id":":id","name":"User :id"}`), 0644) + assert.NoError(t, err) + + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Logger: log, + paramRegexp: compileParamRegexp(), + } + + params := map[string]string{"id": "42"} + result, err := s.getJSONResponse(jsonFile, params) + assert.NoError(t, err) + + var body map[string]interface{} + err = json.Unmarshal(result, &body) + assert.NoError(t, err) + assert.Equal(t, "42", body["id"]) + assert.Equal(t, "User 42", body["name"]) +} + +func TestGetJSONResponse_FileNotFound(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Logger: log, + paramRegexp: compileParamRegexp(), + } + + _, err := s.getJSONResponse("/nonexistent/file.json", nil) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrJSONFileNotFound) +} + +func TestGetJSONResponse_InvalidJSONAfterReplacement(t *testing.T) { + tempDir, err := os.MkdirTemp("", "json-resp-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a JSON file where param replacement would break the JSON structure + jsonFile := filepath.Join(tempDir, "data.json") + err = os.WriteFile(jsonFile, []byte(`{"count":":count"}`), 0644) + assert.NoError(t, err) + + var buf bytes.Buffer + log := newTestLogger(&buf) + + s := &Server{ + Logger: log, + paramRegexp: compileParamRegexp(), + } + + // Replacement that keeps valid JSON + params := map[string]string{"count": "5"} + result, err := s.getJSONResponse(jsonFile, params) + assert.NoError(t, err) + + var body map[string]interface{} + err = json.Unmarshal(result, &body) + assert.NoError(t, err) + assert.Equal(t, "5", body["count"]) +} + +// --- NewServer tests --- + +func TestNewServer(t *testing.T) { + tempDir, err := os.MkdirTemp("", "new-server-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + jsonFile := filepath.Join(tempDir, "test.json") + err = os.WriteFile(jsonFile, []byte(`{}`), 0644) + assert.NoError(t, err) + + cfg := &config.Config{ + Port: 3000, + Endpoints: []config.Endpoint{ + {Method: "GET", Status: 200, Path: "/users/:id", JsonPath: jsonFile}, + {Method: "GET", Status: 200, Path: "/simple", JsonPath: jsonFile}, + }, + } + + var buf bytes.Buffer + log := newTestLogger(&buf) + + server := NewServer(cfg, log, 5*time.Minute) + + assert.NotNil(t, server) + assert.NotNil(t, server.Cache) + assert.Equal(t, 5*time.Minute, server.CacheTTL) + // Should have registered path params for /users/:id + assert.Contains(t, server.PathParams, "/users/:id") + assert.Equal(t, []string{"id"}, server.PathParams["/users/:id"]) + // /simple has no params so should not be in PathParams + _, hasSimple := server.PathParams["/simple"] + assert.False(t, hasSimple) +} + +func TestHandleRequest_StaticFileNotFound(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + req := httptest.NewRequest("GET", "/static/nonexistent.html", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleRequest_MultipleGETEndpoints(t *testing.T) { + server, tempDir := setupTestServer(t) + defer os.RemoveAll(tempDir) + + // Request /users (GET list) + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + server.HandleRequest(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + // Request /users/1 (GET detail) + req2 := httptest.NewRequest("GET", "/users/1", nil) + w2 := httptest.NewRecorder() + server.HandleRequest(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) + + // They should return different content + assert.NotEqual(t, w.Body.String(), w2.Body.String()) +} + +// compileParamRegexp creates the param regexp used by Server +func compileParamRegexp() *regexp.Regexp { + return regexp.MustCompile(`:([\w]+)`) +} diff --git a/src/logger/logger_test.go b/src/logger/logger_test.go index 2deda90..90f0f3e 100644 --- a/src/logger/logger_test.go +++ b/src/logger/logger_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "net/http/httptest" + "os" "strings" "testing" "time" @@ -213,3 +214,218 @@ func TestLogger_AccessLog(t *testing.T) { assert.Equal(t, 150.0, accessEntry.Latency) assert.Equal(t, "test-agent", accessEntry.UserAgent) } + +func TestLogger_LevelFiltering(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelWarn, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // Debug and Info should be filtered out + log.Debug("debug msg") + assert.Empty(t, buf.String(), "Debug should be filtered when level is Warn") + + log.Info("info msg") + assert.Empty(t, buf.String(), "Info should be filtered when level is Warn") + + // Warn and Error should pass through + log.Warn("warn msg") + assert.Contains(t, buf.String(), "WARN") + assert.Contains(t, buf.String(), "warn msg") + + buf.Reset() + log.Error("error msg") + assert.Contains(t, buf.String(), "ERROR") + assert.Contains(t, buf.String(), "error msg") +} + +func TestLogger_LogMethodsWithoutData(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelDebug, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // Call log methods without data (variadic args empty) + log.Debug("debug no data") + assert.Contains(t, buf.String(), "debug no data") + assert.NotContains(t, buf.String(), "{") // No data JSON + + buf.Reset() + log.Info("info no data") + assert.Contains(t, buf.String(), "info no data") + + buf.Reset() + log.Warn("warn no data") + assert.Contains(t, buf.String(), "warn no data") + + buf.Reset() + log.Error("error no data") + assert.Contains(t, buf.String(), "error no data") +} + +func TestLogger_JSONFormatWithData(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelDebug, + format: FormatJSON, + writer: &buf, + timeFormat: time.RFC3339, + } + + log.Info("test", map[string]any{"key": "value", "count": 10}) + + var entry LogEntry + err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry) + assert.NoError(t, err) + assert.Equal(t, "INFO", entry.Level) + assert.Equal(t, "test", entry.Message) + assert.Equal(t, "value", entry.Data["key"]) + assert.Equal(t, float64(10), entry.Data["count"]) +} + +func TestLogger_JSONFormatWithoutData(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelDebug, + format: FormatJSON, + writer: &buf, + timeFormat: time.RFC3339, + } + + log.Info("no data message") + + var entry LogEntry + err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry) + assert.NoError(t, err) + assert.Equal(t, "INFO", entry.Level) + assert.Equal(t, "no data message", entry.Message) + assert.Nil(t, entry.Data) +} + +func TestNewLogger_FileOutput(t *testing.T) { + tempDir := t.TempDir() + logFile := tempDir + "/test.log" + + log, err := NewLogger(LogConfig{ + Level: LevelInfo, + Format: FormatText, + OutputPath: logFile, + }) + assert.NoError(t, err) + assert.NotNil(t, log) + + log.Info("file output test") + log.Close() + + // Verify the file was written + content, err := os.ReadFile(logFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "file output test") +} + +func TestNewLogger_FileOutputCreatesDirectory(t *testing.T) { + tempDir := t.TempDir() + logFile := tempDir + "/subdir/nested/test.log" + + log, err := NewLogger(LogConfig{ + Level: LevelInfo, + Format: FormatText, + OutputPath: logFile, + }) + assert.NoError(t, err) + assert.NotNil(t, log) + + log.Info("nested dir test") + log.Close() + + content, err := os.ReadFile(logFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "nested dir test") +} + +func TestLogger_Close_Stdout(t *testing.T) { + log, err := NewLogger(LogConfig{ + Level: LevelInfo, + Format: FormatText, + }) + assert.NoError(t, err) + + // Close on stdout writer should not fail + err = log.Close() + assert.NoError(t, err) +} + +func TestLogger_AccessLog_NonJSONContentType(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelDebug, + format: FormatText, + writer: &buf, + timeFormat: time.RFC3339, + } + + // POST with non-JSON content type + req := httptest.NewRequest("POST", "/submit", strings.NewReader("plain text body")) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("User-Agent", "test-agent") + + log.AccessLog(req, 200, 50*time.Millisecond) + output := buf.String() + + assert.Contains(t, output, "POST") + assert.Contains(t, output, "/submit") + assert.Contains(t, output, "200") +} + +func TestLogger_AccessLog_GETRequest(t *testing.T) { + var buf bytes.Buffer + + log := &Logger{ + level: LevelDebug, + format: FormatJSON, + writer: &buf, + timeFormat: time.RFC3339, + } + + req := httptest.NewRequest("GET", "/api/data?q=test", nil) + req.Header.Set("User-Agent", "curl/7.0") + + log.AccessLog(req, 200, 5*time.Millisecond) + + var entry AccessLogEntry + err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry) + assert.NoError(t, err) + assert.Equal(t, "GET", entry.Method) + assert.Equal(t, "/api/data", entry.Path) + assert.Equal(t, "curl/7.0", entry.UserAgent) + assert.Nil(t, entry.Body, "GET request should not include body") +} + +func TestNewLogger_DefaultTimeFormat(t *testing.T) { + log, err := NewLogger(LogConfig{ + Level: LevelInfo, + }) + assert.NoError(t, err) + assert.NotNil(t, log) + assert.Equal(t, time.RFC3339, log.timeFormat) +} + +func TestNewLogger_DefaultFormat(t *testing.T) { + log, err := NewLogger(LogConfig{ + Level: LevelInfo, + }) + assert.NoError(t, err) + assert.NotNil(t, log) + assert.Equal(t, FormatText, log.format) +} diff --git a/src/middleware/middleware_test.go b/src/middleware/middleware_test.go index 3d960ef..956e92a 100644 --- a/src/middleware/middleware_test.go +++ b/src/middleware/middleware_test.go @@ -277,3 +277,150 @@ func TestRandomString(t *testing.T) { s2 := randomString(16) assert.NotEqual(t, s1, s2) } + +func TestCORS_AllMethods(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := CORS()(testHandler) + + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), method) + }) + } +} + +func TestRecovery_NoPanic(t *testing.T) { + var buf bytes.Buffer + log, err := logger.NewLogger(logger.LogConfig{ + Level: logger.LevelDebug, + Format: logger.FormatText, + TimeFormat: time.RFC3339, + }) + assert.NoError(t, err) + log.SetWriter(&buf) + + normalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + handler := Recovery(log)(normalHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "OK", w.Body.String()) + assert.NotContains(t, buf.String(), "Panic recovered") +} + +func TestChain_EmptyMiddleware(t *testing.T) { + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("direct")) + }) + + // Chain with no middlewares should just call the handler directly + handler := Chain()(testHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "direct", w.Body.String()) +} + +func TestChain_Order(t *testing.T) { + var order []string + + m1 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, "m1-before") + next.ServeHTTP(w, r) + order = append(order, "m1-after") + }) + } + m2 := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, "m2-before") + next.ServeHTTP(w, r) + order = append(order, "m2-after") + }) + } + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, "handler") + w.WriteHeader(http.StatusOK) + }) + + handler := Chain(m1, m2)(testHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + // m1 should execute first (outermost), then m2, then handler + assert.Equal(t, []string{"m1-before", "m2-before", "handler", "m2-after", "m1-after"}, order) +} + +func TestLogger_Middleware_WithDifferentStatus(t *testing.T) { + var buf bytes.Buffer + log, err := logger.NewLogger(logger.LogConfig{ + Level: logger.LevelDebug, + Format: logger.FormatText, + TimeFormat: time.RFC3339, + }) + assert.NoError(t, err) + log.SetWriter(&buf) + + tests := []struct { + name string + status int + }{ + {"200 OK", http.StatusOK}, + {"201 Created", http.StatusCreated}, + {"400 Bad Request", http.StatusBadRequest}, + {"404 Not Found", http.StatusNotFound}, + {"500 Internal Server Error", http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf.Reset() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + }) + + handler := Logger(log)(testHandler) + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, tt.status, w.Code) + }) + } +} + +func TestResponseWriter_WriteBeforeWriteHeader(t *testing.T) { + origWriter := httptest.NewRecorder() + rw := &responseWriter{ + ResponseWriter: origWriter, + } + + // Write without calling WriteHeader first + n, err := rw.Write([]byte("hello")) + assert.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, http.StatusOK, rw.statusCode) // Default 200 + assert.True(t, rw.written) + assert.Equal(t, "hello", origWriter.Body.String()) +}