Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions internal/config/config_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package config

import (
"log/slog"
"testing"
"time"
)

func TestLogLevel(t *testing.T) {
tests := []struct {
envVal string
expected slog.Leveler
}{
{"debug", slog.LevelDebug},
{"WARN", slog.LevelWarn},
{"warning", slog.LevelWarn},
{"error", slog.LevelError},
{"info", slog.LevelInfo},
{"", slog.LevelInfo},
{"invalid", slog.LevelInfo},
{"-4", slog.Level(-4)},
{"4", slog.Level(4)},
}

for _, tc := range tests {
cfg := Config{Log: tc.envVal}
if lvl := cfg.LogLevel(); lvl != tc.expected {
t.Errorf("for Log %q, expected %v, got %v", tc.envVal, tc.expected, lvl)
}
}
}

func TestGetEnvHelpers(t *testing.T) {
t.Run("getEnv", func(t *testing.T) {
t.Setenv("TEST_GET_ENV", " value ")
if got := getEnv("TEST_GET_ENV", "default"); got != " value " {
t.Errorf("expected ' value ', got %q", got)
}

t.Setenv("TEST_GET_ENV_EMPTY", " ")
if got := getEnv("TEST_GET_ENV_EMPTY", "default"); got != "default" {
t.Errorf("expected 'default', got %q", got)
}
})

t.Run("getEnvInt32", func(t *testing.T) {
t.Setenv("TEST_GET_ENV_INT32", "42")
if got := getEnvInt32("TEST_GET_ENV_INT32", 10); got != 42 {
t.Errorf("expected 42, got %d", got)
}

t.Setenv("TEST_GET_ENV_INT32_BAD", "abc")
if got := getEnvInt32("TEST_GET_ENV_INT32_BAD", 10); got != 10 {
t.Errorf("expected 10, got %d", got)
}

t.Setenv("TEST_GET_ENV_INT32_NEG", "-5")
if got := getEnvInt32("TEST_GET_ENV_INT32_NEG", 10); got != 10 {
t.Errorf("expected 10 (negative fallback), got %d", got)
}

t.Setenv("TEST_GET_ENV_INT32_EMPTY", " ")
if got := getEnvInt32("TEST_GET_ENV_INT32_EMPTY", 10); got != 10 {
t.Errorf("expected 10, got %d", got)
}
})

t.Run("getEnvInt", func(t *testing.T) {
t.Setenv("TEST_GET_ENV_INT", "100")
if got := getEnvInt("TEST_GET_ENV_INT", 50); got != 100 {
t.Errorf("expected 100, got %d", got)
}

t.Setenv("TEST_GET_ENV_INT_BAD", "xyz")
if got := getEnvInt("TEST_GET_ENV_INT_BAD", 50); got != 50 {
t.Errorf("expected 50, got %d", got)
}

t.Setenv("TEST_GET_ENV_INT_NEG", "-1")
if got := getEnvInt("TEST_GET_ENV_INT_NEG", 50); got != 50 {
t.Errorf("expected 50, got %d", got)
}

t.Setenv("TEST_GET_ENV_INT_EMPTY", "")
if got := getEnvInt("TEST_GET_ENV_INT_EMPTY", 50); got != 50 {
t.Errorf("expected 50, got %d", got)
}
})

t.Run("getEnvDuration", func(t *testing.T) {
t.Setenv("TEST_GET_ENV_DUR", "10s")
if got := getEnvDuration("TEST_GET_ENV_DUR", time.Minute); got != 10*time.Second {
t.Errorf("expected 10s, got %v", got)
}

t.Setenv("TEST_GET_ENV_DUR_BAD", "not-a-dur")
if got := getEnvDuration("TEST_GET_ENV_DUR_BAD", time.Minute); got != time.Minute {
t.Errorf("expected 1m, got %v", got)
}

t.Setenv("TEST_GET_ENV_DUR_NEG", "-10s")
if got := getEnvDuration("TEST_GET_ENV_DUR_NEG", time.Minute); got != time.Minute {
t.Errorf("expected 1m, got %v", got)
}

t.Setenv("TEST_GET_ENV_DUR_EMPTY", "")
if got := getEnvDuration("TEST_GET_ENV_DUR_EMPTY", time.Minute); got != time.Minute {
t.Errorf("expected 1m, got %v", got)
}
})

t.Run("getEnvBool", func(t *testing.T) {
trues := []string{"1", "true", "t", "yes", "y", "on"}
for _, v := range trues {
t.Setenv("TEST_GET_ENV_BOOL", v)
if !getEnvBool("TEST_GET_ENV_BOOL", false) {
t.Errorf("expected true for %q", v)
}
}

falses := []string{"0", "false", "f", "no", "n", "off"}
for _, v := range falses {
t.Setenv("TEST_GET_ENV_BOOL", v)
if getEnvBool("TEST_GET_ENV_BOOL", true) {
t.Errorf("expected false for %q", v)
}
}

t.Setenv("TEST_GET_ENV_BOOL", "invalid")
if got := getEnvBool("TEST_GET_ENV_BOOL", true); got != true {
t.Errorf("expected fallback true, got %v", got)
}

t.Setenv("TEST_GET_ENV_BOOL_EMPTY", " ")
if got := getEnvBool("TEST_GET_ENV_BOOL_EMPTY", true); got != true {
t.Errorf("expected fallback true, got %v", got)
}
})
}
9 changes: 7 additions & 2 deletions internal/config/dotenv.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"log"
"os"
"path/filepath"
"strings"
Expand All @@ -19,7 +20,9 @@ func LoadDotenv() {
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
_ = godotenv.Load(parts...)
if err := godotenv.Load(parts...); err != nil {
log.Printf("Warning: failed to load ENV_FILE: %v", err)
}
return
}

Expand All @@ -40,7 +43,9 @@ func LoadDotenv() {
continue
}
if _, err := os.Stat(p); err == nil {
_ = godotenv.Load(p)
if err := godotenv.Load(p); err != nil {
log.Printf("Warning: error loading .env file %q: %v", p, err)
}
return
}
}
Expand Down
129 changes: 129 additions & 0 deletions internal/config/dotenv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package config

import (
"bytes"
"log"
"os"
"path/filepath"
"strings"
"testing"
)

func TestLoadDotenv_Cases(t *testing.T) {
// Capture logs to verify warnings on malformed lines
var buf bytes.Buffer
log.SetOutput(&buf)
defer log.SetOutput(os.Stderr)

tmpDir := t.TempDir()
envFile := filepath.Join(tmpDir, ".env")

// Helper to write file and call LoadDotenv via ENV_FILE
testFile := func(content string) {
buf.Reset()
err := os.WriteFile(envFile, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write env file: %v", err)
}
t.Setenv("ENV_FILE", envFile)
LoadDotenv()
}

t.Run("empty file", func(t *testing.T) {
testFile("")
if buf.Len() > 0 {
t.Errorf("expected no warning for empty file, got: %s", buf.String())
}
})

t.Run("comment only", func(t *testing.T) {
testFile("# this is a comment\n# another comment\n")
if buf.Len() > 0 {
t.Errorf("expected no warning for comment-only file, got: %s", buf.String())
}
})

t.Run("duplicate key", func(t *testing.T) {
os.Unsetenv("TEST_DUP_KEY")
testFile("TEST_DUP_KEY=first\nTEST_DUP_KEY=second\n")
if buf.Len() > 0 {
t.Errorf("expected no warning for duplicate key, got: %s", buf.String())
}
// godotenv parses into a map, so last defined key wins
if val := os.Getenv("TEST_DUP_KEY"); val != "second" {
t.Errorf("expected 'second' (last-wins) for duplicate key, got: %q", val)
}
os.Unsetenv("TEST_DUP_KEY")
})

t.Run("missing equals", func(t *testing.T) {
testFile("VALID=1\nMISSING_EQUALS\n")
out := buf.String()
if out == "" {
t.Error("expected warning for missing equals sign")
} else if !strings.Contains(out, "Warning: failed to load ENV_FILE") {
t.Errorf("unexpected warning format: %s", out)
}
})

t.Run("unterminated quote", func(t *testing.T) {
testFile("VALID=1\nUNTERM=\"unterminated\n")
out := buf.String()
if out == "" {
t.Error("expected warning for unterminated quote")
} else if !strings.Contains(out, "Warning: failed to load ENV_FILE") {
t.Errorf("unexpected warning format: %s", out)
}
})

t.Run("windows line endings", func(t *testing.T) {
os.Unsetenv("TEST_WIN_CRLF")
testFile("TEST_WIN_CRLF=123\r\n")
if buf.Len() > 0 {
t.Errorf("expected no warning for CRLF file, got: %s", buf.String())
}
if val := os.Getenv("TEST_WIN_CRLF"); val != "123" {
t.Errorf("expected '123' for CRLF, got: %q", val)
}
os.Unsetenv("TEST_WIN_CRLF")
})

t.Run("default locations fallback", func(t *testing.T) {
t.Setenv("ENV_FILE", "") // Ensure explicit file is empty to trigger fallback
os.Unsetenv("TEST_DEFAULT_LOC")

cwd, _ := os.Getwd()
defaultEnvFile := filepath.Join(cwd, ".env")

// Create a temporary .env in the current directory
err := os.WriteFile(defaultEnvFile, []byte("TEST_DEFAULT_LOC=fallback_val"), 0644)
if err != nil {
t.Fatalf("failed to write default env file: %v", err)
}
defer os.Remove(defaultEnvFile)

LoadDotenv()

if val := os.Getenv("TEST_DEFAULT_LOC"); val != "fallback_val" {
t.Errorf("expected 'fallback_val' from default location, got: %q", val)
}
os.Unsetenv("TEST_DEFAULT_LOC")
})

t.Run("default locations fallback malformed", func(t *testing.T) {
t.Setenv("ENV_FILE", "")
buf.Reset()

cwd, _ := os.Getwd()
defaultEnvFile := filepath.Join(cwd, ".env")

os.WriteFile(defaultEnvFile, []byte("MALFORMED_LINE\n"), 0644)
defer os.Remove(defaultEnvFile)

LoadDotenv()

if !strings.Contains(buf.String(), "Warning: error loading .env file") {
t.Errorf("expected warning for malformed default file, got: %s", buf.String())
}
})
}
5 changes: 3 additions & 2 deletions internal/handlers/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/jagadeesh/grainlify/backend/internal/httpx"
"github.com/jagadeesh/grainlify/backend/internal/migrate"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// decodeErrorCode reads a standard httpx.ErrorEnvelope JSON body and returns
Expand Down Expand Up @@ -187,8 +188,8 @@ func TestNonceValidation(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/auth/nonce", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req)
assert.NoError(t, err)
resp, err := app.Test(req, 5000)
require.NoError(t, err)
defer resp.Body.Close()

if tt.wantStatus == http.StatusOK {
Expand Down
4 changes: 2 additions & 2 deletions internal/handlers/projects_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@ func TestProjectsCache_EvictionLoop(t *testing.T) {
t.Errorf("expected 10 entries, got %d", cache.Len())
}

// Wait for TTL + eviction interval
time.Sleep(ttl + 100*time.Millisecond)
// Wait for TTL + eviction interval (generous sleep for CI)
time.Sleep(ttl * 2)

// Eviction loop should have cleaned up expired entries
if cache.Len() != 0 {
Expand Down
6 changes: 3 additions & 3 deletions internal/handlers/projects_public_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func TestProjectsPublicHandler_ListCacheHit(t *testing.T) {
stopCh := make(chan struct{})
defer close(stopCh)

cache := NewProjectsCache(1*time.Second, stopCh)
cache := NewProjectsCache(10*time.Second, stopCh)
projectsPublic := newProjectsPublicHandler(config.Config{}, &db.DB{}, cache)

app := fiber.New()
Expand Down Expand Up @@ -156,7 +156,7 @@ func TestProjectsPublicHandler_RecommendedCacheHit(t *testing.T) {
stopCh := make(chan struct{})
defer close(stopCh)

cache := NewProjectsCache(1*time.Second, stopCh)
cache := NewProjectsCache(10*time.Second, stopCh)
projectsPublic := newProjectsPublicHandler(config.Config{}, &db.DB{}, cache)

app := fiber.New()
Expand Down Expand Up @@ -185,7 +185,7 @@ func TestProjectsPublicHandler_FiltersCacheHit(t *testing.T) {
stopCh := make(chan struct{})
defer close(stopCh)

cache := NewProjectsCache(1*time.Second, stopCh)
cache := NewProjectsCache(10*time.Second, stopCh)
projectsPublic := newProjectsPublicHandler(config.Config{}, &db.DB{}, cache)

app := fiber.New()
Expand Down
17 changes: 10 additions & 7 deletions internal/liveness/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func TestConcurrentTicksAndReads(t *testing.T) {
_ = tr.Stale()
}
close(done)
tr.Tick() // Ensure at least one tick happened before we check
if !tr.Started() {
t.Fatal("expected Started after concurrent access")
}
Expand Down Expand Up @@ -201,14 +202,16 @@ func TestServeAndShutdown(t *testing.T) {

mux := http.NewServeMux()
mux.HandleFunc("/healthz", tr.HealthzHandler())
srv := &http.Server{Addr: "127.0.0.1:0", Handler: mux}
go srv.ListenAndServe()
time.Sleep(100 * time.Millisecond)

addr := tr.ServerAddr()
if addr == "" {
addr = srv.Addr

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}

srv := &http.Server{Handler: mux}
go srv.Serve(listener)

addr := listener.Addr().String()
resp, err := http.Get("http://" + addr + "/healthz")
if err != nil {
t.Fatalf("failed to GET /healthz: %v", err)
Expand Down
Loading