From 9330ed8e74074967c920d8004c013eeb93e1ba7d Mon Sep 17 00:00:00 2001 From: Zeni Kim Date: Sun, 13 Sep 2026 00:09:50 -0500 Subject: [PATCH] Upgrade and add new testing functions --- cache_test.go | 118 +++++++ context_test.go | 112 ++++++- cookies_test.go | 294 +++++++++++++++++ core_test.go | 88 +++--- events-manager_test.go | 246 ++++++++++----- hashing_test.go | 109 +++++++ jwt_test.go | 73 +++++ response_test.go | 169 ++++++++++ router_test.go | 14 + session_test.go | 469 ++++++++++++++++++++++++++++ templates_test.go | 303 ++++++++++++++++++ testingdata/templates/funcmap.html | 1 + testingdata/templates/greeting.html | 1 + 13 files changed, 1867 insertions(+), 130 deletions(-) create mode 100644 cache_test.go create mode 100644 cookies_test.go create mode 100644 hashing_test.go create mode 100644 session_test.go create mode 100644 templates_test.go create mode 100644 testingdata/templates/funcmap.html create mode 100644 testingdata/templates/greeting.html diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..0a81ae6 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "fmt" + "os" + "testing" + "time" +) + +func TestNewCacheNilDoesNotPanic(t *testing.T) { + // With caching disabled, a failed Redis ping must NOT panic. + prevHost := os.Getenv("REDIS_HOST") + prevPort := os.Getenv("REDIS_PORT") + prevDB := os.Getenv("REDIS_DB") + os.Setenv("REDIS_HOST", "127.0.0.1") + os.Setenv("REDIS_PORT", "6379") + os.Setenv("REDIS_DB", "0") + t.Cleanup(func() { + os.Setenv("REDIS_HOST", prevHost) + os.Setenv("REDIS_PORT", prevPort) + os.Setenv("REDIS_DB", prevDB) + }) + + defer func() { + if r := recover(); r != nil { + t.Errorf("NewCache should not panic when cache is disabled: %v", r) + } + }() + c := NewCache(CacheConfig{EnableCache: false}) + if c == nil { + t.Errorf("expected a non-nil Cache instance") + } +} + +func TestNewCacheInvalidDBPanics(t *testing.T) { + prevDB := os.Getenv("REDIS_DB") + os.Setenv("REDIS_DB", "not-a-number") + t.Cleanup(func() { os.Setenv("REDIS_DB", prevDB) }) + + defer func() { + if r := recover(); r == nil { + t.Errorf("expected NewCache to panic on an invalid REDIS_DB value") + } + }() + _ = NewCache(CacheConfig{EnableCache: false}) +} + +func TestCacheSetGetDelete(t *testing.T) { + c := newTestCache(t) + key := fmt.Sprintf("goffee_cache_test_%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = c.Delete(key) }) + + if err := c.Set(key, "hello"); err != nil { + t.Fatalf("failed cache set: %v", err) + } + got, err := c.Get(key) + if err != nil { + t.Fatalf("failed cache get: %v", err) + } + if got != "hello" { + t.Errorf("expected 'hello', got %q", got) + } + + if err := c.Delete(key); err != nil { + t.Fatalf("failed cache delete: %v", err) + } + if _, err := c.Get(key); err == nil { + t.Errorf("expected error getting a deleted key") + } +} + +func TestCacheGetMissingKey(t *testing.T) { + c := newTestCache(t) + if _, err := c.Get(fmt.Sprintf("goffee_missing_%d", time.Now().UnixNano())); err == nil { + t.Errorf("expected error getting a missing key") + } +} + +func TestCacheSetWithExpiration(t *testing.T) { + c := newTestCache(t) + key := fmt.Sprintf("goffee_cache_exp_%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = c.Delete(key) }) + + if err := c.SetWithExpiration(key, "expiring", 2*time.Second); err != nil { + t.Fatalf("failed cache set with expiration: %v", err) + } + got, err := c.Get(key) + if err != nil { + t.Fatalf("failed cache get: %v", err) + } + if got != "expiring" { + t.Errorf("expected 'expiring', got %q", got) + } +} + +func TestCacheOverwrite(t *testing.T) { + c := newTestCache(t) + key := fmt.Sprintf("goffee_cache_overwrite_%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = c.Delete(key) }) + + if err := c.Set(key, "first"); err != nil { + t.Fatalf("failed first set: %v", err) + } + if err := c.Set(key, "second"); err != nil { + t.Fatalf("failed second set: %v", err) + } + got, err := c.Get(key) + if err != nil { + t.Fatalf("failed get: %v", err) + } + if got != "second" { + t.Errorf("expected 'second', got %q", got) + } +} diff --git a/context_test.go b/context_test.go index 9cc4287..e293a08 100644 --- a/context_test.go +++ b/context_test.go @@ -157,7 +157,7 @@ func TestGetPathParams(t *testing.T) { } a := New() h := a.makeHTTPRouterHandlerFunc( - Handler(func(c *Context) *Response { + Controller(func(c *Context) *Response { rsp := fmt.Sprintf("param1: %v | param2: %v", c.GetPathParam("param1"), c.GetPathParam("param2")) return c.Response.Text(rsp) }), nil) @@ -178,11 +178,13 @@ func TestGetRequestParams(t *testing.T) { app.SetBasePath(pwd) hr := httprouter.New() gcr := NewRouter() - gcr.Post("/pt", Handler(func(c *Context) *Response { + + gcr.Post("/pt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Get("/gt", Handler(func(c *Context) *Response { + + gcr.Get("/gt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -218,11 +220,13 @@ func TestRequestParamsExists(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Post("/pt", Handler(func(c *Context) *Response { + + gcr.Post("/pt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.RequestParamExists("param")) return nil })) - gcr.Get("/gt", Handler(func(c *Context) *Response { + + gcr.Get("/gt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.RequestParamExists("param")) return nil })) @@ -259,11 +263,13 @@ func TestGetHeader(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Post("/pt", Handler(func(c *Context) *Response { + + gcr.Post("/pt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetHeader("headerkey")) return nil })) - gcr.Get("/gt", Handler(func(c *Context) *Response { + + gcr.Get("/gt", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetHeader("headerkey")) return nil })) @@ -309,8 +315,13 @@ func TestGetUploadedFile(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Post("/pt", Handler(func(c *Context) *Response { - uploadedFile := c.GetUploadedFile("myfile") + + gcr.Post("/pt", Controller(func(c *Context) *Response { + uploadedFile, err := c.GetUploadedFile("myfile") + if err != nil { + fmt.Fprintln(c.Response.HttpResponseWriter, "error: "+err.Error()) + return nil + } rs := fmt.Sprintf("file name: %v | size: %v", uploadedFile.Name, uploadedFile.Size) fmt.Fprintln(c.Response.HttpResponseWriter, rs) return nil @@ -534,6 +545,89 @@ func TestGetBaseDirPath(t *testing.T) { } } +func TestGetUserAgent(t *testing.T) { + r := httptest.NewRequest(GET, LOCALHOST, nil) + r.Header.Set("User-Agent", "goffee-test-agent") + c := makeCTX(t) + c.Request.httpRequest = r + if got := c.GetUserAgent(); got != "goffee-test-agent" { + t.Errorf("expected 'goffee-test-agent', got %q", got) + } +} + +func TestGetRequesBodyMap(t *testing.T) { + r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"alice","age":"30"}`)) + c := makeCTX(t) + c.Request.httpRequest = r + m := c.GetRequesBodyMap() + if m["name"] != "alice" { + t.Errorf("expected name 'alice', got %v", m["name"]) + } +} + +func TestGetRequesBodyStruct(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"bob"}`)) + c := makeCTX(t) + c.Request.httpRequest = r + var p payload + if err := c.GetRequesBodyStruct(&p); err != nil { + t.Fatalf("failed binding body struct: %v", err) + } + if p.Name != "bob" { + t.Errorf("expected name 'bob', got %q", p.Name) + } +} + +func TestGetRequesBodyStructNonPointer(t *testing.T) { + type payload struct { + Name string `json:"name"` + } + r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"bob"}`)) + c := makeCTX(t) + c.Request.httpRequest = r + var p payload + err := c.GetRequesBodyStruct(p) + if err == nil { + t.Errorf("expected error when dest is not a pointer") + } +} + +func TestMapToJson(t *testing.T) { + c := makeCTX(t) + got := c.MapToJson(map[string]interface{}{"a": 1}) + if got != `{"a":1}` { + t.Errorf("expected {\"a\":1}, got %q", got) + } +} + +func TestMapToJsonPanicsOnNonMap(t *testing.T) { + c := makeCTX(t) + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic for non-map input") + } + }() + c.MapToJson("not a map") +} + +func TestGetRequesForm(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, LOCALHOST, strings.NewReader("param=value")) + r.Header.Set(CONTENT_TYPE, "application/x-www-form-urlencoded") + c := makeCTX(t) + c.Request.httpRequest = r + got := c.GetRequesForm("param") + vals, ok := got.([]string) + if !ok { + t.Fatalf("expected []string form values, got %T", got) + } + if len(vals) != 1 || vals[0] != "value" { + t.Errorf("expected form value 'value', got %v", vals) + } +} + func makeCTXLogTestCTX(t *testing.T, w http.ResponseWriter, r *http.Request, tmpFilePath string) *Context { t.Helper() return &Context{ diff --git a/cookies_test.go b/cookies_test.go new file mode 100644 index 0000000..a97084b --- /dev/null +++ b/cookies_test.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// testCookieSecret is a 32 byte (AES-256) key expressed as a hex string, suitable +// for the COOKIE_SECRET env var used by SetCookie/ClearCookie. +const testCookieSecret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// enableTemplateCookieEnv enables the template engine (required for cookie handling) +// and sets a deterministic cookie secret, restoring both env vars on cleanup. +func enableTemplateCookieEnv(t *testing.T) { + t.Helper() + prevTemplate := os.Getenv("TEMPLATE_ENABLE") + prevSecret := os.Getenv("COOKIE_SECRET") + prevSecure := os.Getenv("COOKIE_SECURE") + os.Setenv("TEMPLATE_ENABLE", "true") + os.Setenv("COOKIE_SECRET", testCookieSecret) + os.Setenv("COOKIE_SECURE", "false") + t.Cleanup(func() { + os.Setenv("TEMPLATE_ENABLE", prevTemplate) + os.Setenv("COOKIE_SECRET", prevSecret) + os.Setenv("COOKIE_SECURE", prevSecure) + }) +} + +func decodeSecret(t *testing.T) []byte { + t.Helper() + key, err := hex.DecodeString(testCookieSecret) + if err != nil { + t.Fatalf("failed decoding test cookie secret: %v", err) + } + return key +} + +func TestCookieWrite(t *testing.T) { + w := httptest.NewRecorder() + cookie := http.Cookie{Name: "goffee", Value: "hello", Path: "/"} + if err := CookieWrite(w, cookie); err != nil { + t.Fatalf("failed testing cookie write: %v", err) + } + rsp := w.Result() + cookies := rsp.Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + if cookies[0].Name != "goffee" { + t.Errorf("expected cookie name 'goffee', got %q", cookies[0].Name) + } +} + +func TestCookieWriteTooLong(t *testing.T) { + w := httptest.NewRecorder() + // A value large enough that the base64-encoded cookie string exceeds 4096 bytes. + cookie := http.Cookie{Name: "goffee", Value: strings.Repeat("a", 5000), Path: "/"} + err := CookieWrite(w, cookie) + if err == nil { + t.Errorf("expected ErrValueTooLong, got nil") + } +} + +func TestCookieRead(t *testing.T) { + // Build a request carrying a base64-encoded cookie value. + w := httptest.NewRecorder() + cookie := http.Cookie{Name: "goffee", Value: "hello-world", Path: "/"} + if err := CookieWrite(w, cookie); err != nil { + t.Fatalf("failed writing cookie: %v", err) + } + r := httptest.NewRequest(GET, LOCALHOST, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + val, err := CookieRead(r, "goffee") + if err != nil { + t.Fatalf("failed testing cookie read: %v", err) + } + if val != "hello-world" { + t.Errorf("expected 'hello-world', got %q", val) + } +} + +func TestCookieReadMissing(t *testing.T) { + r := httptest.NewRequest(GET, LOCALHOST, nil) + _, err := CookieRead(r, "goffee") + if err == nil { + t.Errorf("expected error reading missing cookie") + } +} + +func TestCookieReadInvalidBase64(t *testing.T) { + r := httptest.NewRequest(GET, LOCALHOST, nil) + r.AddCookie(&http.Cookie{Name: "goffee", Value: "!!!not-base64!!!"}) + _, err := CookieRead(r, "goffee") + if err == nil { + t.Errorf("expected error reading invalid base64 cookie") + } +} + +func TestCookieWriteReadEncrypted(t *testing.T) { + key := decodeSecret(t) + w := httptest.NewRecorder() + cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"} + if err := CookieWriteEncrypted(w, cookie, key); err != nil { + t.Fatalf("failed writing encrypted cookie: %v", err) + } + + r := httptest.NewRequest(GET, LOCALHOST, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + val, err := CookieReadEncrypted(r, "goffee", key) + if err != nil { + t.Fatalf("failed reading encrypted cookie: %v", err) + } + if val != "secret-value" { + t.Errorf("expected 'secret-value', got %q", val) + } +} + +func TestCookieReadEncryptedWrongKey(t *testing.T) { + key := decodeSecret(t) + w := httptest.NewRecorder() + cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"} + if err := CookieWriteEncrypted(w, cookie, key); err != nil { + t.Fatalf("failed writing encrypted cookie: %v", err) + } + + // A different but valid AES key must fail decryption. + wrongKey, _ := hex.DecodeString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + r := httptest.NewRequest(GET, LOCALHOST, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + if _, err := CookieReadEncrypted(r, "goffee", wrongKey); err == nil { + t.Errorf("expected error decrypting with wrong key") + } +} + +func TestCookieReadEncryptedWrongName(t *testing.T) { + key := decodeSecret(t) + w := httptest.NewRecorder() + cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"} + if err := CookieWriteEncrypted(w, cookie, key); err != nil { + t.Fatalf("failed writing encrypted cookie: %v", err) + } + r := httptest.NewRequest(GET, LOCALHOST, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + // Reading with a different name must fail the authenticated name check. + if _, err := CookieReadEncrypted(r, "other", key); err == nil { + t.Errorf("expected error decrypting with wrong cookie name") + } +} + +func TestSetCookie(t *testing.T) { + enableTemplateCookieEnv(t) + w := httptest.NewRecorder() + if err := SetCookie(w, "user@example.com", "token-123"); err != nil { + t.Fatalf("failed testing set cookie: %v", err) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + if cookies[0].Name != "goffee" { + t.Errorf("expected cookie name 'goffee', got %q", cookies[0].Name) + } + if !cookies[0].HttpOnly { + t.Errorf("expected cookie to be HttpOnly") + } + if cookies[0].MaxAge <= 0 { + t.Errorf("expected a positive MaxAge, got %d", cookies[0].MaxAge) + } +} + +func TestSetCookieUsesJWT_Lifetime(t *testing.T) { + enableTemplateCookieEnv(t) + prev := os.Getenv("JWT_LIFESPAN_MINUTES") + os.Setenv("JWT_LIFESPAN_MINUTES", "5") + t.Cleanup(func() { os.Setenv("JWT_LIFESPAN_MINUTES", prev) }) + + w := httptest.NewRecorder() + if err := SetCookie(w, "user@example.com", "token-123"); err != nil { + t.Fatalf("failed testing set cookie: %v", err) + } + cookies := w.Result().Cookies() + if cookies[0].MaxAge != 5*60 { + t.Errorf("expected MaxAge of %d, got %d", 5*60, cookies[0].MaxAge) + } +} + +func TestSetCookieWithMaxAge(t *testing.T) { + enableTemplateCookieEnv(t) + w := httptest.NewRecorder() + if err := SetCookieWithMaxAge(w, "user@example.com", "token-abc", 120); err != nil { + t.Fatalf("failed testing set cookie with max age: %v", err) + } + cookies := w.Result().Cookies() + if cookies[0].MaxAge != 120 { + t.Errorf("expected MaxAge of 120, got %d", cookies[0].MaxAge) + } +} + +func TestSetCookieTemplatesDisabledPanics(t *testing.T) { + prev := os.Getenv("TEMPLATE_ENABLE") + os.Setenv("TEMPLATE_ENABLE", "false") + t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) }) + + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic when templates are disabled") + } + }() + w := httptest.NewRecorder() + _ = SetCookieWithMaxAge(w, "user@example.com", "token", 60) +} + +func TestClearCookie(t *testing.T) { + prevSecure := os.Getenv("COOKIE_SECURE") + os.Setenv("COOKIE_SECURE", "false") + t.Cleanup(func() { os.Setenv("COOKIE_SECURE", prevSecure) }) + + w := httptest.NewRecorder() + if err := ClearCookie(w); err != nil { + t.Fatalf("failed testing clear cookie: %v", err) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + c := cookies[0] + if c.Name != "goffee" { + t.Errorf("expected cookie name 'goffee', got %q", c.Name) + } + if c.Value != "" { + t.Errorf("expected empty cookie value, got %q", c.Value) + } + if c.MaxAge != -1 { + t.Errorf("expected MaxAge of -1 to delete the cookie, got %d", c.MaxAge) + } + if !c.HttpOnly { + t.Errorf("expected cleared cookie to be HttpOnly") + } +} + +func TestGetCookieRoundTrip(t *testing.T) { + enableTemplateCookieEnv(t) + + // Set a cookie and feed it back into a request, then decrypt it. + w := httptest.NewRecorder() + if err := SetCookie(w, "user@example.com", "token-xyz"); err != nil { + t.Fatalf("failed setting cookie: %v", err) + } + r := httptest.NewRequest(GET, LOCALHOST, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + user, err := GetCookie(r) + if err != nil { + t.Fatalf("failed testing get cookie: %v", err) + } + if user.Email != "user@example.com" { + t.Errorf("expected email 'user@example.com', got %q", user.Email) + } + if user.Token != "token-xyz" { + t.Errorf("expected token 'token-xyz', got %q", user.Token) + } +} + +func TestGetCookieTemplatesDisabledPanics(t *testing.T) { + prev := os.Getenv("TEMPLATE_ENABLE") + os.Setenv("TEMPLATE_ENABLE", "false") + t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) }) + + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic when templates are disabled") + } + }() + r := httptest.NewRequest(GET, LOCALHOST, nil) + _, _ = GetCookie(r) +} diff --git a/core_test.go b/core_test.go index fc146af..08dffdc 100644 --- a/core_test.go +++ b/core_test.go @@ -50,7 +50,8 @@ func TestMakeHTTPHandlerFunc(t *testing.T) { app.SetLogsDriver(&logger.LogFileDriver{ FilePath: filepath.Join(t.TempDir(), uuid.NewString()), }) - hdlr := Handler(func(c *Context) *Response { + app.Bootstrap() + hdlr := Controller(func(c *Context) *Response { f, _ := os.Create(tmpFile) f.WriteString("DFT2V56H") c.Response.SetHeader("header-key", "header-val") @@ -76,7 +77,8 @@ func TestMakeHTTPHandlerFuncVerifyJson(t *testing.T) { app.SetLogsDriver(&logger.LogFileDriver{ FilePath: filepath.Join(t.TempDir(), uuid.NewString()), }) - hdlr := Handler(func(c *Context) *Response { + app.Bootstrap() + hdlr := Controller(func(c *Context) *Response { f, _ := os.Create(tmpFile) f.WriteString("DFT2V56H") c.Response.SetHeader("header-key", "header-val") @@ -130,18 +132,19 @@ func TestNotFoundHandler(t *testing.T) { } -func TestUseMiddleware(t *testing.T) { +func TestUseHook(t *testing.T) { app := createNewApp(t) - UseMiddleware(Middleware(func(c *Context) { c.GetLogger().Info("Testing!") })) - if len(app.middlewares.GetMiddlewares()) != 1 { - t.Errorf("failed testing use middleware") + app.Bootstrap() + UseHook(Hook(func(c *Context) { c.GetLogger().Info("Testing!") })) + if len(ResolveHooks().GetHooks()) != 1 { + t.Errorf("failed testing use hook") } } func TestChainReset(t *testing.T) { c := &chain{} - c.nodes = append(c.nodes, Middleware(func(c *Context) { c.GetLogger().Info("Testing1!") })) - c.nodes = append(c.nodes, Middleware(func(c *Context) { c.GetLogger().Info("Testing2!") })) + c.nodes = append(c.nodes, Hook(func(c *Context) { c.GetLogger().Info("Testing1!") })) + c.nodes = append(c.nodes, Hook(func(c *Context) { c.GetLogger().Info("Testing2!") })) c.reset() if len(c.nodes) != 0 { @@ -151,11 +154,11 @@ func TestChainReset(t *testing.T) { func TestNext(t *testing.T) { app := createNewApp(t) - app.t = 0 + app.Bootstrap() tfPath := filepath.Join(t.TempDir(), uuid.NewString()) var hs []interface{} - hs = append(hs, Middleware(func(c *Context) { c.Next() })) - hs = append(hs, Handler(func(c *Context) *Response { + hs = append(hs, Hook(func(c *Context) { c.Next() })) + hs = append(hs, Controller(func(c *Context) *Response { f, _ := os.Create(tfPath) f.WriteString("DFT2V56H") return nil @@ -165,7 +168,7 @@ func TestNext(t *testing.T) { app.chain.execute(makeCTX(t)) cnt, _ := os.ReadFile(tfPath) if string(cnt) != "DFT2V56H" { - // t.Errorf("failed testing next") + t.Errorf("failed testing next") } } @@ -173,14 +176,14 @@ func TestChainGetByIndex(t *testing.T) { c := &chain{} tf := filepath.Join(t.TempDir(), uuid.NewString()) var hs []interface{} - hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing!") })) - hs = append(hs, Middleware(func(c *Context) { + hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing!") })) + hs = append(hs, Hook(func(c *Context) { f, _ := os.Create(tf) f.WriteString("DFT2V56H") })) c.nodes = hs pf := c.getByIndex(1) - f, ok := pf.(Middleware) + f, ok := pf.(Hook) if ok { f(makeCTX(t)) } @@ -192,10 +195,11 @@ func TestChainGetByIndex(t *testing.T) { func TestPrepareChain(t *testing.T) { app := createNewApp(t) - UseMiddleware(Middleware(func(c *Context) { c.GetLogger().Info("Testing!") })) + app.Bootstrap() + UseHook(Hook(func(c *Context) { c.GetLogger().Info("Testing!") })) var hs []interface{} - hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing1!") })) - hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing2!") })) + hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing1!") })) + hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing2!") })) app.prepareChain(hs) if len(app.chain.nodes) != 3 { t.Errorf("failed preparing chain") @@ -207,7 +211,7 @@ func TestChainExecute(t *testing.T) { f1Path := filepath.Join(tmpDir, uuid.NewString()) c := &chain{} c.nodes = []interface{}{ - Handler(func(c *Context) *Response { + Controller(func(c *Context) *Response { tf, _ := os.Create(f1Path) defer tf.Close() tf.WriteString("DFT2V56H") @@ -239,19 +243,23 @@ func makeCTX(t *testing.T) *Context { } } -func TestcombHndlers(t *testing.T) { +func TestCombHandlers(t *testing.T) { app := createNewApp(t) - t1 := Handler(func(c *Context) *Response { c.GetLogger().Info("Testing1!"); return nil }) - t2 := Middleware(func(c *Context) { c.GetLogger().Info("Testing2!") }) + t1 := Controller(func(c *Context) *Response { c.GetLogger().Info("Testing1!"); return nil }) + t2 := Hook(func(c *Context) { c.GetLogger().Info("Testing2!") }) - mw := []Middleware{t2} + mw := []Hook{t2} comb := app.combHandlers(t1, mw) - if reflect.ValueOf(t1).Pointer() != reflect.ValueOf(comb[0]).Pointer() { - t.Errorf("failed testing reverse handlers") + // combHandlers builds the slice as [hooks..., controller] + if len(comb) != 2 { + t.Errorf("failed testing comb handlers: unexpected length %d", len(comb)) + } + if reflect.ValueOf(t2).Pointer() != reflect.ValueOf(comb[0]).Pointer() { + t.Errorf("failed testing comb handlers: hook should come first") } - if reflect.ValueOf(t2).Pointer() != reflect.ValueOf(comb[1]).Pointer() { - t.Errorf("failed testing reverse handlers") + if reflect.ValueOf(t1).Pointer() != reflect.ValueOf(comb[1]).Pointer() { + t.Errorf("failed testing comb handlers: controller should come last") } } @@ -259,31 +267,31 @@ func TestRegisterGetRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Get("/", Handler(func(c *Context) *Response { + gcr.Get("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Post("/", Handler(func(c *Context) *Response { + gcr.Post("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Delete("/", Handler(func(c *Context) *Response { + gcr.Delete("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Patch("/", Handler(func(c *Context) *Response { + gcr.Patch("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Put("/", Handler(func(c *Context) *Response { + gcr.Put("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Options("/", Handler(func(c *Context) *Response { + gcr.Options("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) - gcr.Head("/", Handler(func(c *Context) *Response { + gcr.Head("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -313,7 +321,7 @@ func TestRegisterPostRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Post("/", Handler(func(c *Context) *Response { + gcr.Post("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -343,7 +351,7 @@ func TestRegisterDeleteRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Delete("/", Handler(func(c *Context) *Response { + gcr.Delete("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -373,7 +381,7 @@ func TestRegisterPatchRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Patch("/", Handler(func(c *Context) *Response { + gcr.Patch("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -403,7 +411,7 @@ func TestRegisterPutRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Put("/", Handler(func(c *Context) *Response { + gcr.Put("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -433,7 +441,7 @@ func TestRegisterOptionsRoute(t *testing.T) { app := New() hr := httprouter.New() gcr := NewRouter() - gcr.Options("/", Handler(func(c *Context) *Response { + gcr.Options("/", Controller(func(c *Context) *Response { fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param")) return nil })) @@ -464,7 +472,7 @@ func TestRegisterHeadRoute(t *testing.T) { hr := httprouter.New() gcr := NewRouter() tfp := filepath.Join(t.TempDir(), uuid.NewString()) - gcr.Head("/", Handler(func(c *Context) *Response { + gcr.Head("/", Controller(func(c *Context) *Response { param := c.GetRequestParam("param") p, _ := param.(string) f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 777) diff --git a/events-manager_test.go b/events-manager_test.go index 4630b21..1380039 100644 --- a/events-manager_test.go +++ b/events-manager_test.go @@ -2,7 +2,12 @@ package core import ( "fmt" + "io" + "os" + "path/filepath" "testing" + + "github.com/google/uuid" ) func TestNewEventsManager(t *testing.T) { @@ -12,92 +17,171 @@ func TestNewEventsManager(t *testing.T) { } } -// func TestResolveEventsManager(t *testing.T) { -// NewEventsManager() -// m := ResolveEventsManager() -// if fmt.Sprintf("%T", m) != "*core.EventsManager" { -// t.Errorf("failed testing new events manager") -// } -// } +func TestResolveEventsManager(t *testing.T) { + NewEventsManager() + m := ResolveEventsManager() + if fmt.Sprintf("%T", m) != "*core.EventsManager" { + t.Errorf("failed testing resolve events manager") + } +} -// func TestEvents(t *testing.T) { -// pwd, _ := os.Getwd() -// const eventName1 string = "test-event-name1" -// const eventName2 string = "test-event-name2" -// var tmpDir string -// if runtime.GOOS == "linux" { -// tmpDir = t.TempDir() -// } else { -// tmpDir = filepath.Join(pwd, "/testingdata/tmp") -// } -// tmpFile1 := filepath.Join(tmpDir, uuid.NewString()) -// tmpFile2 := filepath.Join(tmpDir, uuid.NewString()) -// tmpFile3 := filepath.Join(tmpDir, uuid.NewString()) -// m := NewEventsManager() -// m.Register(eventName1, func(event *Event, requestContext *Context) { -// os.Create(tmpFile1) -// f, err := os.Create(tmpFile1) -// if err != nil { -// t.Errorf("error testing register event: %v", err.Error()) -// } -// f.WriteString(event.Name) -// f.Close() -// }) -// m.Register(eventName1, func(event *Event, requestContext *Context) { -// os.Create(tmpFile3) -// f, err := os.Create(tmpFile3) -// if err != nil { -// t.Errorf("error testing register event: %v", err.Error()) -// } -// f.WriteString(event.Name) -// f.Close() -// }) -// m.Fire(&Event{Name: eventName1}) -// m.processFiredEvents() +func TestEventsFireAndProcess(t *testing.T) { + const eventName1 string = "test-event-name1" + const eventName2 string = "test-event-name2" -// ff, err := os.Open(tmpFile1) -// if err != nil { -// t.Errorf("error testing register event : %v", err.Error()) -// } + tmpDir := t.TempDir() + tmpFile1 := filepath.Join(tmpDir, uuid.NewString()) + tmpFile2 := filepath.Join(tmpDir, uuid.NewString()) + tmpFile3 := filepath.Join(tmpDir, uuid.NewString()) -// d, err := io.ReadAll(ff) -// if string(d) != eventName1 { -// t.Error("faild testing events") -// } -// ff.Close() -// os.Remove(tmpFile1) + m := NewEventsManager() -// ff, err = os.Open(tmpFile3) -// if err != nil { -// t.Errorf("error testing register event : %v", err.Error()) -// } + // Two jobs registered on the same event must BOTH run. + m.Register(eventName1, func(event *Event, requestContext *Context) { + f, err := os.Create(tmpFile1) + if err != nil { + t.Errorf("error testing register event: %v", err.Error()) + return + } + f.WriteString(event.Name) + f.Close() + }) + m.Register(eventName1, func(event *Event, requestContext *Context) { + f, err := os.Create(tmpFile3) + if err != nil { + t.Errorf("error testing register event: %v", err.Error()) + return + } + f.WriteString(event.Name) + f.Close() + }) -// d, err = io.ReadAll(ff) -// if string(d) != eventName1 { -// t.Error("faild testing events") -// } -// ff.Close() -// os.Remove(tmpFile3) + if err := m.Fire(&Event{Name: eventName1}); err != nil { + t.Fatalf("failed firing event: %v", err) + } + m.processFiredEvents() -// m.Register(eventName2, func(event *Event, requestContext *Context) { -// f, err := os.Create(tmpFile2) -// if err != nil { -// t.Errorf("error testing register event: %v", err.Error()) -// } -// f.WriteString(event.Name) -// f.Close() -// }) -// m.Fire(&Event{Name: eventName2}) -// m.processFiredEvents() + for _, fp := range []string{tmpFile1, tmpFile3} { + f, err := os.Open(fp) + if err != nil { + t.Errorf("error opening event file %v: %v", fp, err.Error()) + continue + } + d, err := io.ReadAll(f) + if err != nil { + t.Errorf("error reading event file %v: %v", fp, err.Error()) + } + if string(d) != eventName1 { + t.Errorf("failed testing events: expected %q, got %q", eventName1, string(d)) + } + f.Close() + } -// ff, err = os.Open(tmpFile2) -// if err != nil { -// t.Errorf("error testing register event : %v", err.Error()) -// } + // A registered event with a distinct payload is processed independently. + m.Register(eventName2, func(event *Event, requestContext *Context) { + f, err := os.Create(tmpFile2) + if err != nil { + t.Errorf("error testing register event: %v", err.Error()) + return + } + f.WriteString(event.Name) + f.Close() + }) + if err := m.Fire(&Event{Name: eventName2}); err != nil { + t.Fatalf("failed firing event: %v", err) + } + m.processFiredEvents() -// d, err = io.ReadAll(ff) -// if string(d) != eventName2 { -// t.Error("faild testing events") -// } -// ff.Close() -// } + f, err := os.Open(tmpFile2) + if err != nil { + t.Fatalf("error opening event file: %v", err.Error()) + } + d, err := io.ReadAll(f) + if err != nil { + t.Errorf("error reading event file: %v", err.Error()) + } + f.Close() + if string(d) != eventName2 { + t.Errorf("failed testing events: expected %q, got %q", eventName2, string(d)) + } +} + +func TestEventFireUnregistered(t *testing.T) { + m := NewEventsManager() + err := m.Fire(&Event{Name: "not-registered"}) + if err == nil { + t.Errorf("expected error firing an unregistered event") + } +} + +func TestEventFireEmptyName(t *testing.T) { + m := NewEventsManager() + err := m.Fire(&Event{Name: ""}) + if err == nil { + t.Errorf("expected error firing an event with an empty name") + } +} + +func TestEventRegisterEmptyNamePanics(t *testing.T) { + m := NewEventsManager() + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic registering an event with an empty name") + } + }() + m.Register("", func(event *Event, requestContext *Context) {}) +} + +func TestEventsProcessClearsFiredList(t *testing.T) { + const eventName = "test-clear-fired" + m := NewEventsManager() + m.Register(eventName, func(event *Event, requestContext *Context) {}) + if err := m.Fire(&Event{Name: eventName}); err != nil { + t.Fatalf("failed firing event: %v", err) + } + if len(m.firedEvents) != 1 { + t.Fatalf("expected 1 fired event, got %d", len(m.firedEvents)) + } + m.processFiredEvents() + if len(m.firedEvents) != 0 { + t.Errorf("expected fired events to be cleared after processing") + } +} + +func TestEventsDisabled(t *testing.T) { + DisableEvents() + defer EnableEvents() + + const eventName = "test-disabled-event" + m := NewEventsManager() + m.Register(eventName, func(event *Event, requestContext *Context) {}) + // When disabled, Register is a no-op and Fire returns nil without recording. + if len(m.eventsJobsList) != 0 { + t.Errorf("expected no jobs registered while events are disabled") + } + if err := m.Fire(&Event{Name: eventName}); err != nil { + t.Errorf("expected Fire to be a no-op while events are disabled, got: %v", err) + } + if len(m.firedEvents) != 0 { + t.Errorf("expected no fired events while events are disabled") + } +} + +func TestEventsSetContextAndExecute(t *testing.T) { + const eventName = "test-context-event" + m := NewEventsManager() + var receivedCtx *Context + m.Register(eventName, func(event *Event, requestContext *Context) { + receivedCtx = requestContext + }) + + expected := &Context{} + m.setContext(expected) + if err := m.Fire(&Event{Name: eventName}); err != nil { + t.Fatalf("failed firing event: %v", err) + } + m.processFiredEvents() + if receivedCtx != expected { + t.Errorf("expected the event job to receive the request context") + } +} diff --git a/hashing_test.go b/hashing_test.go new file mode 100644 index 0000000..53da62f --- /dev/null +++ b/hashing_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "testing" + + "git.smarteching.com/goffee/core/logger" +) + +func TestHashPassword(t *testing.T) { + h := &Hashing{} + password := "super-secret-password" + hashed, err := h.HashPassword(password) + if err != nil { + t.Fatalf("failed testing hash password: %v", err) + } + if hashed == "" { + t.Errorf("expected a non-empty hash") + } + if hashed == password { + t.Errorf("hash must differ from the plaintext password") + } +} + +func TestHashPasswordIsSalted(t *testing.T) { + h := &Hashing{} + first, err := h.HashPassword("same-password") + if err != nil { + t.Fatalf("failed testing hash password: %v", err) + } + second, err := h.HashPassword("same-password") + if err != nil { + t.Fatalf("failed testing hash password: %v", err) + } + // bcrypt generates a random salt, so equal inputs must yield different hashes. + if first == second { + t.Errorf("expected different hashes for the same password (salting)") + } +} + +func TestCheckPasswordHash(t *testing.T) { + // The CheckPasswordHash error path logs through the global logger; make sure + // it is initialized so the test does not panic on a nil logger. + loggr = logger.NewLogger(&logger.LogNullDriver{}) + + h := &Hashing{} + password := "correct-horse-battery-staple" + hashed, err := h.HashPassword(password) + if err != nil { + t.Fatalf("failed testing hash password: %v", err) + } + + ok, err := h.CheckPasswordHash(hashed, password) + if err != nil { + t.Fatalf("failed testing check password hash: %v", err) + } + if !ok { + t.Errorf("expected password check to succeed") + } +} + +func TestCheckPasswordHashMismatch(t *testing.T) { + loggr = logger.NewLogger(&logger.LogNullDriver{}) + + h := &Hashing{} + hashed, err := h.HashPassword("the-right-password") + if err != nil { + t.Fatalf("failed testing hash password: %v", err) + } + + ok, err := h.CheckPasswordHash(hashed, "the-wrong-password") + if err != nil { + t.Fatalf("mismatched password should not return an error, got: %v", err) + } + if ok { + t.Errorf("expected password check to fail for wrong password") + } +} + +func TestCheckPasswordHashInvalidHash(t *testing.T) { + loggr = logger.NewLogger(&logger.LogNullDriver{}) + + h := &Hashing{} + // An invalid hash that is not a mismatched-but-valid bcrypt hash should + // surface as an error rather than a simple false. + ok, err := h.CheckPasswordHash("not-a-valid-bcrypt-hash", "whatever") + if err == nil { + t.Errorf("expected an error for an invalid hash") + } + if ok { + t.Errorf("expected ok to be false for an invalid hash") + } +} + +func TestCheckPasswordHashEmptyHash(t *testing.T) { + loggr = logger.NewLogger(&logger.LogNullDriver{}) + + h := &Hashing{} + ok, err := h.CheckPasswordHash("", "password") + if err == nil { + t.Errorf("expected an error for an empty hash") + } + if ok { + t.Errorf("expected ok to be false for an empty hash") + } +} diff --git a/jwt_test.go b/jwt_test.go index c7bf7a8..3d5459b 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -104,6 +104,79 @@ func TestMapClaims(t *testing.T) { } } +func TestLifetimeMinutes(t *testing.T) { + j := newJWT(JWTOptions{ + SigningKey: "testsigning", + LifetimeMinutes: 42, + }) + if j.LifetimeMinutes() != 42 { + t.Errorf("expected lifetime 42, got %d", j.LifetimeMinutes()) + } +} + +func TestExpiresAtIgnoreExpiry(t *testing.T) { + j := initiateJWTHelper(t) + token, err := j.GenerateToken(map[string]interface{}{ + "userID": 1, + }) + if err != nil { + t.Fatalf("failed generating token: %v", err) + } + exp, err := j.ExpiresAtIgnoreExpiry(token) + if err != nil { + t.Fatalf("failed testing expires at ignore expiry: %v", err) + } + expected := time.Now().Add(time.Duration(j.LifetimeMinutes()) * time.Minute) + if diff := exp.Sub(expected); diff > time.Minute || diff < -time.Minute { + t.Errorf("expected expiration close to %v, got %v", expected, exp) + } + + // An expired token must still return its expiration (no validation performed). + expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJKIjoiZXlKMFpYTjBTMlY1SWpvaWRHVnpkRlpoYkNKOSIsImV4cCI6MTY4NDkyMzQwOX0.v2aM9OTDJ48L4KnGjfLH3JAFQw4Gkgj5z7cA7txPNag" + _, err = j.ExpiresAtIgnoreExpiry(expiredToken) + if err != nil { + t.Errorf("expected to read expiration of an expired token, got error: %v", err) + } +} + +func TestExpiresAtIgnoreExpiryInvalid(t *testing.T) { + j := initiateJWTHelper(t) + _, err := j.ExpiresAtIgnoreExpiry("not-a-token") + if err == nil { + t.Errorf("expected error for an invalid token") + } +} + +func TestDecodeTokenIgnoreExpiry(t *testing.T) { + j := initiateJWTHelper(t) + + // An already expired token should still be decodable. + expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJKIjoiZXlKMFpYTjBTMlY1SWpvaWRHVnpkRlpoYkNKOSIsImV4cCI6MTY4NDkyMzQwOX0.v2aM9OTDJ48L4KnGjfLH3JAFQw4Gkgj5z7cA7txPNag" + _, err := j.DecodeTokenIgnoreExpiry(expiredToken) + if err != nil { + t.Errorf("expected to decode an expired token ignoring expiry, got: %v", err) + } + + token, err := j.GenerateToken(map[string]interface{}{ + "userID": 99, + }) + if err != nil { + t.Fatalf("failed generating token: %v", err) + } + payload, err := j.DecodeTokenIgnoreExpiry(token) + if err != nil { + t.Fatalf("failed decoding token ignoring expiry: %v", err) + } + if fmt.Sprintf("%v", payload["userID"]) != "99" { + t.Errorf("expected userID 99, got %v", payload["userID"]) + } + + // An invalid token must fail. + if _, err := j.DecodeTokenIgnoreExpiry("invalid"); err == nil { + t.Errorf("expected error decoding an invalid token") + } +} + func initiateJWTHelper(t *testing.T) *JWT { t.Helper() j := newJWT(JWTOptions{ diff --git a/response_test.go b/response_test.go index dd99405..3b7bfa2 100644 --- a/response_test.go +++ b/response_test.go @@ -1,7 +1,10 @@ package core import ( + "bytes" "fmt" + "net/http" + "net/http/httptest" "testing" ) @@ -152,3 +155,169 @@ func TestCastBasicVarToString(t *testing.T) { t.Errorf("failed test cast basic var to string") } } + +func TestCastBasicVarToStringPanicsOnUnsupported(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("expected panic for unsupported type") + } + }() + res := Response{} + res.castBasicVarsToString(struct{ Name string }{Name: "x"}) +} + +func TestText(t *testing.T) { + res := Response{} + res.Text("plain text") + if res.contentType != CONTENT_TYPE_TEXT { + t.Errorf("expected content type TEXT, got %q", res.contentType) + } + if string(res.body) != "plain text" { + t.Errorf("unexpected body %q", string(res.body)) + } +} + +func TestHTML(t *testing.T) { + res := Response{} + res.HTML("

Hi

") + if res.contentType != CONTENT_TYPE_HTML { + t.Errorf("expected content type HTML, got %q", res.contentType) + } + if string(res.body) != "

Hi

" { + t.Errorf("unexpected body %q", string(res.body)) + } +} + +func TestSetStatusCode(t *testing.T) { + res := Response{} + res.SetStatusCode(http.StatusCreated) + if res.statusCode != http.StatusCreated { + t.Errorf("expected status code 201, got %d", res.statusCode) + } +} + +func TestSetContentType(t *testing.T) { + res := Response{} + res.SetContentType(CONTENT_TYPE_JSON) + if res.overrideContentType != CONTENT_TYPE_JSON { + t.Errorf("expected override content type JSON, got %q", res.overrideContentType) + } +} + +func TestForceSendResponse(t *testing.T) { + res := Response{} + res.ForceSendResponse() + if !res.isTerminated { + t.Errorf("expected response to be terminated") + } +} + +func TestTerminatedResponseIgnoresWrites(t *testing.T) { + res := Response{} + res.ForceSendResponse() + res.Text("ignored") + res.SetHeader("x", "y") + res.SetStatusCode(500) + + if res.body != nil { + t.Errorf("expected body to be untouched after termination, got %q", string(res.body)) + } + if len(res.headers) != 0 { + t.Errorf("expected no headers to be added after termination") + } + if res.statusCode != 0 { + t.Errorf("expected status code to stay 0 after termination, got %d", res.statusCode) + } +} + +func TestRedirect(t *testing.T) { + res := Response{} + res.Redirect("https://example.com") + if res.redirectTo != "https://example.com" { + t.Errorf("expected redirect to 'https://example.com', got %q", res.redirectTo) + } + if res.redirectStatusCode != http.StatusTemporaryRedirect { + t.Errorf("expected default 307 redirect, got %d", res.redirectStatusCode) + } +} + +func TestRedirectUse303(t *testing.T) { + res := Response{} + res.Redirect("https://example.com", true) + if res.redirectStatusCode != http.StatusSeeOther { + t.Errorf("expected 303 redirect, got %d", res.redirectStatusCode) + } +} + +func TestRedirectInvalidUrlGetsLeadingSlash(t *testing.T) { + res := Response{} + // A relative path is treated as an invalid URL by the validator and should + // be normalized to an absolute path. + res.Redirect("dashboard") + if res.redirectTo != "/dashboard" { + t.Errorf("expected '/dashboard', got %q", res.redirectTo) + } +} + +func TestResetRestoresDefaults(t *testing.T) { + res := Response{} + res.SetStatusCode(http.StatusTeapot) + res.SetContentType(CONTENT_TYPE_JSON) + res.Redirect("https://example.com") + res.Text("body") + res.reset() + + if res.body != nil { + t.Errorf("expected body to be cleared") + } + if res.statusCode != http.StatusOK { + t.Errorf("expected status code to reset to 200, got %d", res.statusCode) + } + if res.contentType != CONTENT_TYPE_HTML { + t.Errorf("expected content type to reset to HTML, got %q", res.contentType) + } + if res.overrideContentType != "" { + t.Errorf("expected override content type to be cleared") + } + if res.redirectTo != "" { + t.Errorf("expected redirect to be cleared") + } + if res.isTerminated { + t.Errorf("expected termination flag to be cleared") + } +} + +func TestBufferFile(t *testing.T) { + w := httptest.NewRecorder() + res := Response{HttpResponseWriter: w} + var buf bytes.Buffer + buf.WriteString("file-content") + res.BufferFile("report.csv", "text/csv", buf) + + rsp := w.Result() + if ct := rsp.Header.Get(CONTENT_TYPE); ct != "text/csv" { + t.Errorf("expected content type 'text/csv', got %q", ct) + } + if cd := rsp.Header.Get("Content-Disposition"); cd != "attachment; filename=report.csv" { + t.Errorf("unexpected content disposition %q", cd) + } + if w.Body.String() != "file-content" { + t.Errorf("unexpected body %q", w.Body.String()) + } +} + +func TestBufferInline(t *testing.T) { + w := httptest.NewRecorder() + res := Response{HttpResponseWriter: w} + var buf bytes.Buffer + buf.WriteString("inline-content") + res.BufferInline("image.png", "image/png", buf) + + rsp := w.Result() + if ct := rsp.Header.Get(CONTENT_TYPE); ct != "image/png" { + t.Errorf("expected content type 'image/png', got %q", ct) + } + if w.Body.String() != "inline-content" { + t.Errorf("unexpected body %q", w.Body.String()) + } +} diff --git a/router_test.go b/router_test.go index 478192c..ad20297 100644 --- a/router_test.go +++ b/router_test.go @@ -66,6 +66,20 @@ func TestDeleteRequest(t *testing.T) { } } +func TestPatchRequest(t *testing.T) { + r := NewRouter() + handler := Controller(func(c *Context) *Response { + c.GetLogger().Info(TEST_STR) + return nil + }) + r.Patch("/", handler) + + route := r.GetRoutes()[0] + if route.Method != "patch" || route.Path != "/" { + t.Errorf("failed adding route with patch http method") + } +} + func TestPutRequest(t *testing.T) { r := NewRouter() handler := Controller(func(c *Context) *Response { diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..e06c954 --- /dev/null +++ b/session_test.go @@ -0,0 +1,469 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "git.smarteching.com/goffee/core/logger" + "github.com/golang-jwt/jwt/v5" +) + +// ───────────────────────────────────────────── +// Pure helpers (no external dependencies) +// ───────────────────────────────────────────── + +func TestCreateAuthTokenHashedCacheKey(t *testing.T) { + k1 := CreateAuthTokenHashedCacheKey(1, "Mozilla/5.0") + k2 := CreateAuthTokenHashedCacheKey(1, "Mozilla/5.0") + if k1 != k2 { + t.Errorf("expected deterministic cache keys, got %q and %q", k1, k2) + } + // MD5 hex representation is 32 characters long. + if len(k1) != 32 { + t.Errorf("expected a 32 char md5 hex key, got %d chars (%q)", len(k1), k1) + } + k3 := CreateAuthTokenHashedCacheKey(2, "Mozilla/5.0") + if k1 == k3 { + t.Errorf("expected different keys for different user IDs") + } + k4 := CreateAuthTokenHashedCacheKey(1, "curl/8.0") + if k1 == k4 { + t.Errorf("expected different keys for different user agents") + } +} + +func TestTemplateEngineEnabled(t *testing.T) { + prev := os.Getenv("TEMPLATE_ENABLE") + t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) }) + + os.Setenv("TEMPLATE_ENABLE", "") + if templateEngineEnabled() { + t.Errorf("expected template engine to be disabled when unset") + } + os.Setenv("TEMPLATE_ENABLE", "false") + if templateEngineEnabled() { + t.Errorf("expected template engine to be disabled for 'false'") + } + os.Setenv("TEMPLATE_ENABLE", "true") + if !templateEngineEnabled() { + t.Errorf("expected template engine to be enabled for 'true'") + } +} + +func TestSlidingRenewThresholdPercent(t *testing.T) { + if slidingRenewThresholdPercent != 0.25 { + t.Errorf("expected threshold 0.25, got %v", slidingRenewThresholdPercent) + } +} + +func TestRenewedTokenForRequest(t *testing.T) { + // A context without a request should not panic and return an empty string. + empty := &Context{} + if got := renewedTokenForRequest(empty); got != "" { + t.Errorf("expected empty token for nil request, got %q", got) + } + + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := &Context{Request: &Request{httpRequest: r}} + if got := renewedTokenForRequest(c); got != "" { + t.Errorf("expected empty token before marking, got %q", got) + } + + markRenewedTokenForRequest(c, "new-token") + if got := renewedTokenForRequest(c); got != "new-token" { + t.Errorf("expected 'new-token', got %q", got) + } +} + +func TestMarkRenewedTokenForRequestNilSafety(t *testing.T) { + // Must not panic on a context without request/writer. + markRenewedTokenForRequest(&Context{}, "x") + markRenewedTokenForRequest(&Context{Request: &Request{}}, "x") +} + +// ───────────────────────────────────────────── +// SessionUser value store (in-memory only) +// ───────────────────────────────────────────── + +func TestSessionUserGetSetIsAuthenticated(t *testing.T) { + // Construct a session without a cache, exercising the value map directly. + s := &SessionUser{values: make(map[string]interface{})} + + if s.IsAuthenticated() { + t.Errorf("expected a fresh session to be unauthenticated") + } + if _, ok := s.Get("missing"); ok { + t.Errorf("expected 'missing' key to be absent") + } +} + +func TestSessionUserGetUserID(t *testing.T) { + s := &SessionUser{userID: 7} + if s.GetUserID() != 7 { + t.Errorf("expected user ID 7, got %d", s.GetUserID()) + } +} + +// ───────────────────────────────────────────── +// Redis-backed integration tests (skipped when Redis is unavailable) +// ───────────────────────────────────────────── + +// newTestCache returns a Cache connected to a local Redis, or skips the test if +// Redis is not reachable. Tests that require a live cache call this helper. +func newTestCache(t *testing.T) *Cache { + t.Helper() + if os.Getenv("REDIS_HOST") == "" { + os.Setenv("REDIS_HOST", "127.0.0.1") + } + if os.Getenv("REDIS_PORT") == "" { + os.Setenv("REDIS_PORT", "6379") + } + os.Setenv("REDIS_DB", "0") + + c := NewCache(CacheConfig{EnableCache: false}) + // Probe the connection; skip if Redis is not available. + probeKey := fmt.Sprintf("goffee_test_probe_%d", time.Now().UnixNano()) + if err := c.Set(probeKey, "1"); err != nil { + t.Skipf("redis is not available, skipping integration test: %v", err) + } + _ = c.Delete(probeKey) + return c +} + +func newSessionContext(r *http.Request, cch *Cache) *Context { + w := httptest.NewRecorder() + return &Context{ + Request: &Request{ + httpRequest: r, + }, + Response: &Response{ + headers: []header{}, + HttpResponseWriter: w, + }, + GetLogger: loggerResolverForTest, + GetCache: func() *Cache { return cch }, + } +} + +func loggerResolverForTest() *logger.Logger { + return logger.NewLogger(&logger.LogNullDriver{}) +} + +func TestSessionUserSetGetDelete(t *testing.T) { + cch := newTestCache(t) + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := newSessionContext(r, cch) + + s := &SessionUser{ + context: c, + values: make(map[string]interface{}), + hashedSessionKey: CreateAuthTokenHashedCacheKey(1, "sess_test-agent"), + } + + if err := s.Set("username", "alice"); err != nil { + t.Fatalf("failed setting session value: %v", err) + } + if v, ok := s.Get("username"); !ok || v != "alice" { + t.Errorf("expected username 'alice', got %v (ok=%v)", v, ok) + } + + // Value should be persisted to the cache. + cached, err := cch.Get(s.hashedSessionKey) + if err != nil { + t.Fatalf("expected value in cache: %v", err) + } + if cached == "" { + t.Errorf("expected cached session to be non-empty") + } + + // Delete returns the removed value and persists the change. + deleted := s.Delete("username") + if deleted != "alice" { + t.Errorf("expected deleted value 'alice', got %v", deleted) + } + if _, ok := s.Get("username"); ok { + t.Errorf("expected username to be deleted") + } + + t.Cleanup(func() { _ = cch.Delete(s.hashedSessionKey) }) +} + +func TestSessionUserFlush(t *testing.T) { + cch := newTestCache(t) + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := newSessionContext(r, cch) + + key := CreateAuthTokenHashedCacheKey(2, "sess_flush-agent") + if err := cch.Set(key, `{"a":"b"}`); err != nil { + t.Fatalf("failed seeding cache: %v", err) + } + + s := &SessionUser{ + context: c, + values: map[string]interface{}{"a": "b"}, + hashedSessionKey: key, + authenticated: true, + } + + if err := s.Flush(); err != nil { + t.Fatalf("failed flushing session: %v", err) + } + if s.IsAuthenticated() { + t.Errorf("expected session to be unauthenticated after flush") + } + if len(s.values) != 0 { + t.Errorf("expected session values to be cleared") + } + if _, ok := s.Get("a"); ok { + t.Errorf("expected value 'a' to be removed after flush") + } + if _, err := cch.Get(key); err == nil { + t.Errorf("expected cache key to be deleted after flush") + } +} + +func TestSessionUserSaveEmptyDeletesKey(t *testing.T) { + cch := newTestCache(t) + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := newSessionContext(r, cch) + + key := CreateAuthTokenHashedCacheKey(3, "sess_save-agent") + if err := cch.Set(key, `{"a":"b"}`); err != nil { + t.Fatalf("failed seeding cache: %v", err) + } + + s := &SessionUser{ + context: c, + values: map[string]interface{}{}, + hashedSessionKey: key, + } + if err := s.Save(); err != nil { + t.Fatalf("failed saving empty session: %v", err) + } + if _, err := cch.Get(key); err == nil { + t.Errorf("expected cache key to be deleted when saving an empty session") + } +} + +func TestSessionUserInitNoCookie(t *testing.T) { + enableTemplateCookieEnv(t) + // With no cookie present Init must fail gracefully. + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := &Context{ + Request: &Request{httpRequest: r}, + Response: &Response{ + HttpResponseWriter: httptest.NewRecorder(), + }, + GetLogger: loggerResolverForTest, + } + s := &SessionUser{} + if s.Init(c) { + t.Errorf("expected Init to return false when no cookie is present") + } + if s.IsAuthenticated() { + t.Errorf("expected session to remain unauthenticated") + } +} + +// TestSessionUserInitValidToken exercises the full happy path against Redis: +// JWT generation, cookie round-trip, cache verification and session loading. +func TestSessionUserInitValidToken(t *testing.T) { + cch := newTestCache(t) + enableTemplateCookieEnv(t) + + jwtSecret := "test-session-secret" + jwtObj := newJWT(JWTOptions{SigningKey: jwtSecret, LifetimeMinutes: 60}) + token, err := jwtObj.GenerateToken(map[string]interface{}{"userID": 123}) + if err != nil { + t.Fatalf("failed generating token: %v", err) + } + + userAgent := "goffee-test-agent" + hashedCacheKey := CreateAuthTokenHashedCacheKey(123, userAgent) + if err := cch.Set(hashedCacheKey, token); err != nil { + t.Fatalf("failed seeding cached token: %v", err) + } + t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) }) + + // Build the request with a valid encrypted goffee cookie. + w := httptest.NewRecorder() + if err := SetCookie(w, "user@example.com", token); err != nil { + t.Fatalf("failed setting cookie: %v", err) + } + r := httptest.NewRequest(GET, LOCALHOST, nil) + r.Header.Set("User-Agent", userAgent) + for _, ck := range w.Result().Cookies() { + r.AddCookie(ck) + } + + c := newSessionContext(r, cch) + c.GetJWT = func() *JWT { return jwtObj } + + s := &SessionUser{} + if !s.Init(c) { + t.Fatalf("expected Init to succeed with a valid token") + } + if !s.IsAuthenticated() { + t.Errorf("expected session to be authenticated") + } + if s.GetUserID() != 123 { + t.Errorf("expected user ID 123, got %d", s.GetUserID()) + } +} + +// TestSessionUserInitWrongToken ensures a mismatched cached token fails Init. +func TestSessionUserInitWrongToken(t *testing.T) { + cch := newTestCache(t) + enableTemplateCookieEnv(t) + + jwtSecret := "test-session-secret" + jwtObj := newJWT(JWTOptions{SigningKey: jwtSecret, LifetimeMinutes: 60}) + token, err := jwtObj.GenerateToken(map[string]interface{}{"userID": 5}) + if err != nil { + t.Fatalf("failed generating token: %v", err) + } + + userAgent := "goffee-test-agent-2" + hashedCacheKey := CreateAuthTokenHashedCacheKey(5, userAgent) + // Cache a different token so the verification fails. + if err := cch.Set(hashedCacheKey, "a-different-token"); err != nil { + t.Fatalf("failed seeding cached token: %v", err) + } + t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) }) + + w := httptest.NewRecorder() + if err := SetCookie(w, "user@example.com", token); err != nil { + t.Fatalf("failed setting cookie: %v", err) + } + r := httptest.NewRequest(GET, LOCALHOST, nil) + r.Header.Set("User-Agent", userAgent) + for _, ck := range w.Result().Cookies() { + r.AddCookie(ck) + } + + c := newSessionContext(r, cch) + c.GetJWT = func() *JWT { return jwtObj } + + s := &SessionUser{} + if s.Init(c) { + t.Errorf("expected Init to fail when the cached token does not match") + } +} + +func TestSessionUserMaybeRenewSkipsWhenTemplatesDisabled(t *testing.T) { + prev := os.Getenv("TEMPLATE_ENABLE") + os.Setenv("TEMPLATE_ENABLE", "false") + t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) }) + + jwtObj := newJWT(JWTOptions{SigningKey: "k", LifetimeMinutes: 60}) + token, _ := jwtObj.GenerateToken(map[string]interface{}{"userID": 1}) + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := &Context{ + Request: &Request{httpRequest: r}, + Response: &Response{HttpResponseWriter: httptest.NewRecorder()}, + GetLogger: loggerResolverForTest, + GetJWT: func() *JWT { return jwtObj }, + GetCache: func() *Cache { return &Cache{} }, + } + // Should simply return without renewing or panicking. + s := &SessionUser{} + s.maybeRenew(c, "user@example.com", 1, "key", token) + if renewedTokenForRequest(c) != "" { + t.Errorf("expected no renewal when templates are disabled") + } +} + +// buildTokenWithCustomLifetime signs a token whose exp is now + expiresIn, while +// the JWT object advertises lifetimeMinutes. This lets us simulate a token that +// appears to have been issued long ago (and is therefore due for renewal) while +// still being cryptographically valid. +func buildTokenWithCustomLifetime(t *testing.T, jwtObj *JWT, expiresIn time.Duration, payload map[string]interface{}) string { + t.Helper() + claims, err := mapClaims(payload, time.Now().Add(expiresIn)) + if err != nil { + t.Fatalf("failed building claims: %v", err) + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(jwtObj.signingKey) + if err != nil { + t.Fatalf("failed signing token: %v", err) + } + return signed +} + +func TestSessionUserMaybeRenewRenews(t *testing.T) { + cch := newTestCache(t) + enableTemplateCookieEnv(t) + + // Lifetime 60 min, token expiring in 1 min → derived issuance ~59 min ago, + // which is well past the 25% (15 min) threshold → renewal must happen. + jwtObj := newJWT(JWTOptions{SigningKey: "renew-secret", LifetimeMinutes: 60}) + token := buildTokenWithCustomLifetime(t, jwtObj, time.Minute, map[string]interface{}{"userID": 77}) + + userAgent := "renew-agent" + hashedCacheKey := CreateAuthTokenHashedCacheKey(77, userAgent) + if err := cch.Set(hashedCacheKey, token); err != nil { + t.Fatalf("failed seeding token: %v", err) + } + t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) }) + + r := httptest.NewRequest(GET, LOCALHOST, nil) + r.Header.Set("User-Agent", userAgent) + c := newSessionContext(r, cch) + c.GetJWT = func() *JWT { return jwtObj } + + s := &SessionUser{} + s.maybeRenew(c, "user@example.com", 77, hashedCacheKey, token) + + renewed := renewedTokenForRequest(c) + if renewed == "" { + t.Fatalf("expected a renewed token to be issued") + } + if renewed == token { + t.Errorf("expected a brand new token different from the previous one") + } + + // The cache must now hold the renewed token. + cached, err := cch.Get(hashedCacheKey) + if err != nil { + t.Fatalf("failed reading renewed token from cache: %v", err) + } + if cached != renewed { + t.Errorf("expected cache to store the renewed token") + } + + // A renewed cookie must be written to the response. + if len(c.Response.HttpResponseWriter.(*httptest.ResponseRecorder).Result().Cookies()) == 0 { + t.Errorf("expected a renewed cookie to be set") + } +} + +func TestSessionUserMaybeRenewSkipsWhenTooEarly(t *testing.T) { + enableTemplateCookieEnv(t) + + // Lifetime 1000 minutes, token just issued → elapsed ~0 < threshold → no renewal. + jwtObj := newJWT(JWTOptions{SigningKey: "k", LifetimeMinutes: 1000}) + token, _ := jwtObj.GenerateToken(map[string]interface{}{"userID": 1}) + + r := httptest.NewRequest(GET, LOCALHOST, nil) + c := &Context{ + Request: &Request{httpRequest: r}, + Response: &Response{HttpResponseWriter: httptest.NewRecorder()}, + GetLogger: loggerResolverForTest, + GetJWT: func() *JWT { return jwtObj }, + GetCache: func() *Cache { return &Cache{} }, + } + s := &SessionUser{} + s.maybeRenew(c, "user@example.com", 1, "key", token) + if renewedTokenForRequest(c) != "" { + t.Errorf("expected no renewal before the threshold elapses") + } +} diff --git a/templates_test.go b/templates_test.go new file mode 100644 index 0000000..22c1e53 --- /dev/null +++ b/templates_test.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "embed" + "strings" + "testing" + "time" +) + +type templateFieldSample struct { + Name string + Age int +} + +func TestHasField(t *testing.T) { + s := templateFieldSample{Name: "test", Age: 3} + if !hasField(s, "Name") { + t.Errorf("expected struct to have field 'Name'") + } + if !hasField(&s, "Age") { + t.Errorf("expected pointer to struct to have field 'Age'") + } + if hasField(s, "Missing") { + t.Errorf("expected struct NOT to have field 'Missing'") + } + if hasField(42, "Name") { + t.Errorf("expected non-struct to have no fields") + } +} + +func TestCapitalize(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"hello world", "Hello world"}, + {"HELLO", "Hello"}, + {"", ""}, + {"a", "A"}, + } + for _, tt := range tests { + if got := capitalize(tt.in); got != tt.want { + t.Errorf("capitalize(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestPrepend(t *testing.T) { + if got := prepend("World", "Hello, "); got != "Hello, World" { + t.Errorf("prepend returned %q", got) + } +} + +func TestStrAppend(t *testing.T) { + if got := strAppend("Hello", "!"); got != "Hello!" { + t.Errorf("strAppend returned %q", got) + } +} + +func TestSplitHelper(t *testing.T) { + got := split("a,b,c", ",") + if len(got) != 3 { + t.Fatalf("expected 3 parts, got %d", len(got)) + } + if got[0] != "a" || got[2] != "c" { + t.Errorf("unexpected split result: %v", got) + } +} + +func TestTruncate(t *testing.T) { + if got := truncate(5, "hello world"); got != "hello…" { + t.Errorf("truncate returned %q", got) + } + if got := truncate(20, "short"); got != "short" { + t.Errorf("truncate should not modify short strings, got %q", got) + } + // Multibyte safety. + if got := truncate(3, "héllo"); got != "hél…" { + t.Errorf("truncate multibyte returned %q", got) + } +} + +func TestFmtNumber(t *testing.T) { + if got := fmtNumber(1000000); got != "1,000,000" { + t.Errorf("fmtNumber(int) = %q, want %q", got, "1,000,000") + } + if got := fmtNumber(int64(1500)); got != "1,500" { + t.Errorf("fmtNumber(int64) = %q, want %q", got, "1,500") + } + if got := fmtNumber(12.5); got != "12.50" { + t.Errorf("fmtNumber(float64) = %q, want %q", got, "12.50") + } + // Fallback for unsupported types. + if got := fmtNumber("abc"); got != "abc" { + t.Errorf("fmtNumber(string) = %q, want %q", got, "abc") + } +} + +func TestFmtDate(t *testing.T) { + d := time.Date(2023, time.May, 2, 15, 4, 0, 0, time.UTC) + if got := fmtDate(d, "short"); got != "02 May 2023" { + t.Errorf("fmtDate(short) = %q", got) + } + if got := fmtDate(d, "long"); got != "02 May 2023" { + t.Errorf("fmtDate(long) = %q", got) + } + if got := fmtDate(d, "iso"); got != "2023-05-02" { + t.Errorf("fmtDate(iso) = %q", got) + } + if got := fmtDate(d, "datetime"); got != "02 May 2023 15:04" { + t.Errorf("fmtDate(datetime) = %q", got) + } + if got := fmtDate(d, "02/01/2006"); got != "02/05/2023" { + t.Errorf("fmtDate(custom) = %q", got) + } +} + +func TestTimeAgo(t *testing.T) { + tests := []struct { + name string + at time.Time + want string + }{ + {"just now", time.Now().Add(-10 * time.Second), "just now"}, + {"minutes", time.Now().Add(-5 * time.Minute), "5 minutes ago"}, + {"one minute", time.Now().Add(-1 * time.Minute), "1 minute ago"}, + {"hours", time.Now().Add(-3 * time.Hour), "3 hours ago"}, + {"one hour", time.Now().Add(-1 * time.Hour), "1 hour ago"}, + {"days", time.Now().Add(-48 * time.Hour), "2 days ago"}, + {"one day", time.Now().Add(-24 * time.Hour), "1 day ago"}, + } + for _, tt := range tests { + if got := timeAgo(tt.at); got != tt.want { + t.Errorf("timeAgo(%s) = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestPlural(t *testing.T) { + if got := plural(1, "item"); got != "1 item" { + t.Errorf("plural(1) = %q", got) + } + if got := plural(3, "item"); got != "3 items" { + t.Errorf("plural(3) = %q", got) + } +} + +func TestFirstAndLast(t *testing.T) { + items := []int{10, 20, 30} + if got := first(items); got != 10 { + t.Errorf("first = %v, want 10", got) + } + if got := last(items); got != 30 { + t.Errorf("last = %v, want 30", got) + } + if got := first([]int{}); got != nil { + t.Errorf("first of empty slice should be nil, got %v", got) + } + if got := last([]int{}); got != nil { + t.Errorf("last of empty slice should be nil, got %v", got) + } + if got := first("not a slice"); got != nil { + t.Errorf("first of non-slice should be nil, got %v", got) + } +} + +func TestSliceOf(t *testing.T) { + items := []int{1, 2, 3, 4, 5} + got, ok := sliceOf(items, 1, 4).([]int) + if !ok { + t.Fatalf("sliceOf should return a slice") + } + if len(got) != 3 || got[0] != 2 || got[2] != 4 { + t.Errorf("sliceOf returned %v", got) + } + // Negative start should be clamped to 0. + got, _ = sliceOf(items, -5, 2).([]int) + if len(got) != 2 || got[0] != 1 { + t.Errorf("sliceOf with negative start returned %v", got) + } + // End beyond length should be clamped. + got, _ = sliceOf(items, 3, 100).([]int) + if len(got) != 2 || got[1] != 5 { + t.Errorf("sliceOf with large end returned %v", got) + } + if got := sliceOf("not slice", 0, 1); got != nil { + t.Errorf("sliceOf of non-slice should be nil, got %v", got) + } +} + +func TestContainsHelper(t *testing.T) { + if !contains("hello world", "world") { + t.Errorf("expected substring match") + } + if contains("hello", "xyz") { + t.Errorf("expected no substring match") + } + items := []string{"a", "b", "c"} + if !contains(items, "b") { + t.Errorf("expected element in slice") + } + if contains(items, "z") { + t.Errorf("expected element not in slice") + } + if contains(42, "x") { + t.Errorf("expected no match for non-string/non-slice") + } +} + +func TestJoinHelper(t *testing.T) { + if got := join([]string{"a", "b", "c"}, ", "); got != "a, b, c" { + t.Errorf("join returned %q", got) + } +} + +func TestDefaultVal(t *testing.T) { + if got := defaultVal("N/A", ""); got != "N/A" { + t.Errorf("defaultVal for empty string = %v, want N/A", got) + } + if got := defaultVal("N/A", nil); got != "N/A" { + t.Errorf("defaultVal for nil = %v, want N/A", got) + } + if got := defaultVal("N/A", 0); got != "N/A" { + t.Errorf("defaultVal for zero = %v, want N/A", got) + } + if got := defaultVal("N/A", "value"); got != "value" { + t.Errorf("defaultVal for present value = %v, want value", got) + } +} + +func TestTernary(t *testing.T) { + if got := ternary("yes", "no", true); got != "yes" { + t.Errorf("ternary(true) = %v", got) + } + if got := ternary("yes", "no", false); got != "no" { + t.Errorf("ternary(false) = %v", got) + } +} + +func TestCoalesce(t *testing.T) { + if got := coalesce(nil, "", "first", "second"); got != "first" { + t.Errorf("coalesce = %v, want first", got) + } + if got := coalesce(nil, ""); got != nil { + t.Errorf("coalesce of all empty should be nil, got %v", got) + } +} + +func TestFuncMap(t *testing.T) { + fm := funcMap() + expected := []string{ + "hasField", "capitalize", "prepend", "strAppend", "split", "truncate", + "fmtNumber", "fmtDate", "timeAgo", "first", "last", "sliceOf", + "contains", "join", "defaultVal", "ternary", "coalesce", + } + for _, name := range expected { + if _, ok := fm[name]; !ok { + t.Errorf("expected funcMap to contain %q", name) + } + } +} + +//go:embed all:testingdata/templates +var testTemplatesFS embed.FS + +func TestNewTemplatesAndRenderNamed(t *testing.T) { + // Register the built-in components plus our test templates. + NewTemplates(components_resources, testTemplatesFS) + if tmpl == nil { + t.Fatalf("expected templates to be initialized") + } + + out, err := RenderNamedTemplate("test_greeting", map[string]interface{}{"Name": "Goffee"}) + if err != nil { + t.Fatalf("failed rendering named template: %v", err) + } + if !strings.Contains(string(out), "Hello, Goffee!") { + t.Errorf("unexpected rendered output: %q", string(out)) + } +} + +func TestRenderNamedTemplateMissing(t *testing.T) { + NewTemplates(components_resources, testTemplatesFS) + _, err := RenderNamedTemplate("does_not_exist", nil) + if err == nil { + t.Errorf("expected error rendering a missing template") + } +} + +func TestRenderNamedTemplateUsesFuncMap(t *testing.T) { + NewTemplates(components_resources, testTemplatesFS) + out, err := RenderNamedTemplate("test_funcmap", map[string]interface{}{"Raw": "hello world"}) + if err != nil { + t.Fatalf("failed rendering named template: %v", err) + } + if !strings.Contains(string(out), "Hello world") { + t.Errorf("expected capitalize helper to be applied, got %q", string(out)) + } +} diff --git a/testingdata/templates/funcmap.html b/testingdata/templates/funcmap.html new file mode 100644 index 0000000..8a3bb46 --- /dev/null +++ b/testingdata/templates/funcmap.html @@ -0,0 +1 @@ +{{define "test_funcmap"}}{{capitalize .Raw}}{{end}} diff --git a/testingdata/templates/greeting.html b/testingdata/templates/greeting.html new file mode 100644 index 0000000..f8bff9b --- /dev/null +++ b/testingdata/templates/greeting.html @@ -0,0 +1 @@ +{{define "test_greeting"}}Hello, {{.Name}}!{{end}}