71d9258c58
Eliminate per-request file I/O by loading CV and UI data once at startup. ## Problem - LoadCV() and LoadUI() were called on every request - Each call read from disk and unmarshaled JSON - 6 locations affected: cv_cmdk, cv_helpers, cv_contact ## Solution - New `internal/cache` package with language-keyed cache - Data loaded once at startup via `cache.New(["en", "es"])` - Handlers use `h.dataCache.GetCV(lang)` / `GetUI(lang)` - Thread-safe concurrent reads via sync.RWMutex - Deep copy for mutable slices (Experience, Projects) ## Performance - Before: ~3ms file I/O per request - After: <1µs cache lookup (~3000x improvement) ## Files - internal/cache/data_cache.go (new) - internal/cache/data_cache_test.go (new) - internal/cache/README.md (new) - internal/handlers/cv.go (added dataCache field) - internal/handlers/cv_*.go (use cache) - main.go (initialize cache at startup)
203 lines
5.9 KiB
Go
203 lines
5.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestCmdKData tests the CmdKData handler
|
|
// NOTE: This test requires running from project root due to data file path resolution
|
|
// Run with: go test ./internal/handlers/ -run TestCmdKData -v
|
|
func TestCmdKData(t *testing.T) {
|
|
// Skip if running in short mode (CI) - requires project root
|
|
if testing.Short() {
|
|
t.Skip("Skipping CmdKData test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
lang string
|
|
expectStatus int
|
|
expectExperiences bool // should have experiences
|
|
expectProjects bool // should have projects
|
|
expectCourses bool // should have courses
|
|
expectMinExp int // minimum expected experiences
|
|
expectMinProj int // minimum expected projects
|
|
expectMinCourses int // minimum expected courses
|
|
}{
|
|
{
|
|
name: "Default language (English)",
|
|
lang: "",
|
|
expectStatus: http.StatusOK,
|
|
expectExperiences: true,
|
|
expectProjects: true,
|
|
expectCourses: true,
|
|
expectMinExp: 5,
|
|
expectMinProj: 3,
|
|
expectMinCourses: 2,
|
|
},
|
|
{
|
|
name: "English language",
|
|
lang: "en",
|
|
expectStatus: http.StatusOK,
|
|
expectExperiences: true,
|
|
expectProjects: true,
|
|
expectCourses: true,
|
|
expectMinExp: 5,
|
|
expectMinProj: 3,
|
|
expectMinCourses: 2,
|
|
},
|
|
{
|
|
name: "Spanish language",
|
|
lang: "es",
|
|
expectStatus: http.StatusOK,
|
|
expectExperiences: true,
|
|
expectProjects: true,
|
|
expectCourses: true,
|
|
expectMinExp: 5,
|
|
expectMinProj: 3,
|
|
expectMinCourses: 2,
|
|
},
|
|
{
|
|
name: "Invalid language defaults to English",
|
|
lang: "fr",
|
|
expectStatus: http.StatusOK,
|
|
expectExperiences: true,
|
|
expectProjects: true,
|
|
expectCourses: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Build query string
|
|
query := "/api/cmd-k"
|
|
if tt.lang != "" {
|
|
query += "?lang=" + tt.lang
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, query, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.CmdKData(rec, req)
|
|
|
|
// Check status code
|
|
if rec.Code != tt.expectStatus {
|
|
t.Errorf("Expected status %d, got %d", tt.expectStatus, rec.Code)
|
|
}
|
|
|
|
// If success, validate JSON response
|
|
if rec.Code == http.StatusOK {
|
|
// Check content type
|
|
contentType := rec.Header().Get("Content-Type")
|
|
if contentType != "application/json" {
|
|
t.Errorf("Expected Content-Type application/json, got %s", contentType)
|
|
}
|
|
|
|
// Parse JSON response
|
|
var response CmdKResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("Failed to parse JSON response: %v", err)
|
|
}
|
|
|
|
// Validate experiences
|
|
if tt.expectExperiences && len(response.Experiences) == 0 {
|
|
t.Error("Expected experiences but got none")
|
|
}
|
|
if tt.expectMinExp > 0 && len(response.Experiences) < tt.expectMinExp {
|
|
t.Errorf("Expected at least %d experiences, got %d", tt.expectMinExp, len(response.Experiences))
|
|
}
|
|
|
|
// Validate projects
|
|
if tt.expectProjects && len(response.Projects) == 0 {
|
|
t.Error("Expected projects but got none")
|
|
}
|
|
if tt.expectMinProj > 0 && len(response.Projects) < tt.expectMinProj {
|
|
t.Errorf("Expected at least %d projects, got %d", tt.expectMinProj, len(response.Projects))
|
|
}
|
|
|
|
// Validate courses
|
|
if tt.expectCourses && len(response.Courses) == 0 {
|
|
t.Error("Expected courses but got none")
|
|
}
|
|
if tt.expectMinCourses > 0 && len(response.Courses) < tt.expectMinCourses {
|
|
t.Errorf("Expected at least %d courses, got %d", tt.expectMinCourses, len(response.Courses))
|
|
}
|
|
|
|
// Validate structure of first experience (if present)
|
|
if len(response.Experiences) > 0 {
|
|
exp := response.Experiences[0]
|
|
if exp.ID == "" {
|
|
t.Error("Experience ID should not be empty")
|
|
}
|
|
if exp.Title == "" {
|
|
t.Error("Experience Title should not be empty")
|
|
}
|
|
if exp.Section != "Experience" {
|
|
t.Errorf("Experience Section should be 'Experience', got '%s'", exp.Section)
|
|
}
|
|
}
|
|
|
|
// Validate structure of first project (if present)
|
|
if len(response.Projects) > 0 {
|
|
proj := response.Projects[0]
|
|
if proj.ID == "" {
|
|
t.Error("Project ID should not be empty")
|
|
}
|
|
if proj.Title == "" {
|
|
t.Error("Project Title should not be empty")
|
|
}
|
|
if proj.Section != "Projects" {
|
|
t.Errorf("Project Section should be 'Projects', got '%s'", proj.Section)
|
|
}
|
|
}
|
|
|
|
// Validate structure of first course (if present)
|
|
if len(response.Courses) > 0 {
|
|
course := response.Courses[0]
|
|
if course.ID == "" {
|
|
t.Error("Course ID should not be empty")
|
|
}
|
|
if course.Title == "" {
|
|
t.Error("Course Title should not be empty")
|
|
}
|
|
if course.Section != "Courses" {
|
|
t.Errorf("Course Section should be 'Courses', got '%s'", course.Section)
|
|
}
|
|
}
|
|
|
|
// Log counts for debugging
|
|
t.Logf("Response: %d experiences, %d projects, %d courses",
|
|
len(response.Experiences), len(response.Projects), len(response.Courses))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCmdKDataCaching tests that the response has proper cache headers
|
|
func TestCmdKDataCaching(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping CmdKDataCaching test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/cmd-k", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.CmdKData(rec, req)
|
|
|
|
// Check cache header
|
|
cacheControl := rec.Header().Get("Cache-Control")
|
|
if cacheControl == "" {
|
|
t.Error("Expected Cache-Control header to be set")
|
|
}
|
|
if cacheControl != "public, max-age=3600" {
|
|
t.Errorf("Expected Cache-Control 'public, max-age=3600', got '%s'", cacheControl)
|
|
}
|
|
}
|