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)
188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanatsap/cv-site/internal/services"
|
|
)
|
|
|
|
// MockEmailService implements a mock email sender for testing
|
|
type MockEmailService struct {
|
|
SendCalled bool
|
|
LastEmailData *services.ContactFormData
|
|
ShouldFail bool
|
|
FailError error
|
|
}
|
|
|
|
func (m *MockEmailService) SendContactForm(data *services.ContactFormData) error {
|
|
m.SendCalled = true
|
|
m.LastEmailData = data
|
|
if m.ShouldFail {
|
|
return m.FailError
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestHandleContact_ValidSubmission tests successful form submission
|
|
func TestHandleContact_ValidSubmission(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping contact handler test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
// Create form data with valid timing (5 seconds ago)
|
|
formLoadedAt := time.Now().Add(-5 * time.Second).UnixMilli()
|
|
formData := url.Values{}
|
|
formData.Set("email", "test@example.com")
|
|
formData.Set("name", "Test User")
|
|
formData.Set("company", "Test Company")
|
|
formData.Set("subject", "Test Subject")
|
|
formData.Set("message", "This is a test message with more than 10 characters")
|
|
formData.Set("website", "") // Honeypot should be empty
|
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleContact(w, req)
|
|
|
|
// Should return OK (email service is nil, so it logs warning and continues)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status OK, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleContact_MissingFields tests validation for missing required fields
|
|
func TestHandleContact_MissingFields(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping contact handler test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
formData url.Values
|
|
expectError string
|
|
}{
|
|
{
|
|
name: "Missing email",
|
|
formData: url.Values{
|
|
"message": []string{"This is a valid message"},
|
|
"form_loaded_at": []string{fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())},
|
|
},
|
|
expectError: "email",
|
|
},
|
|
{
|
|
name: "Missing message",
|
|
formData: url.Values{
|
|
"email": []string{"test@example.com"},
|
|
"form_loaded_at": []string{fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())},
|
|
},
|
|
expectError: "message",
|
|
},
|
|
{
|
|
name: "Message too short",
|
|
formData: url.Values{
|
|
"email": []string{"test@example.com"},
|
|
"message": []string{"Short"},
|
|
"form_loaded_at": []string{fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())},
|
|
},
|
|
expectError: "short",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(tt.formData.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleContact(w, req)
|
|
|
|
// Should return OK (error is in response body, not status)
|
|
// This is because HTMX handles error display
|
|
body := w.Body.String()
|
|
if !strings.Contains(strings.ToLower(body), tt.expectError) {
|
|
t.Logf("Response body: %s", body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleContact_HoneypotDetection tests bot detection via honeypot
|
|
func TestHandleContact_HoneypotDetection(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping contact handler test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
formLoadedAt := time.Now().Add(-5 * time.Second).UnixMilli()
|
|
formData := url.Values{}
|
|
formData.Set("email", "bot@spam.com")
|
|
formData.Set("message", "This is spam message from a bot")
|
|
formData.Set("website", "http://spam-site.com") // Honeypot filled = bot
|
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleContact(w, req)
|
|
|
|
// Bot gets silent success (200 OK) to avoid revealing detection
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected silent success for bot, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleContact_TimingCheck tests bot detection via timing
|
|
func TestHandleContact_TimingCheck(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping contact handler test - requires running from project root")
|
|
}
|
|
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
// Form filled too quickly (1 second ago - bots are fast)
|
|
formLoadedAt := time.Now().Add(-1 * time.Second).UnixMilli()
|
|
formData := url.Values{}
|
|
formData.Set("email", "bot@spam.com")
|
|
formData.Set("message", "This is spam message from a fast bot")
|
|
formData.Set("form_loaded_at", fmt.Sprintf("%d", formLoadedAt))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/contact?lang=en", strings.NewReader(formData.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleContact(w, req)
|
|
|
|
// Bot gets silent success (200 OK) to avoid revealing detection
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected silent success for bot, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleContact_MethodNotAllowed tests that GET requests are rejected
|
|
func TestHandleContact_MethodNotAllowed(t *testing.T) {
|
|
handler := newTestCVHandler(t, "localhost:8080", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.HandleContact(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("Expected MethodNotAllowed, got %d", w.Code)
|
|
}
|
|
}
|