Files
cv-site/internal/handlers/cv_contact_test.go
T
juanatsap 71d9258c58 feat: add application-level data caching for CV/UI
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)
2025-12-06 15:57:23 +00:00

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)
}
}