diff --git a/cv-site b/cv-site new file mode 100755 index 0000000..03b8730 Binary files /dev/null and b/cv-site differ diff --git a/internal/handlers/cv_htmx.go b/internal/handlers/cv_htmx.go index 6005e36..5708954 100644 --- a/internal/handlers/cv_htmx.go +++ b/internal/handlers/cv_htmx.go @@ -2,6 +2,8 @@ package handlers import ( "net/http" + + "github.com/juanatsap/cv-site/internal/middleware" ) // ============================================================================== @@ -17,13 +19,9 @@ func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { return } - // Get current state - currentLength := getPreferenceCookie(r, "cv-length", "short") - - // Migrate old value if needed - if currentLength == "extended" { - currentLength = "long" - } + // Get current preferences from context (set by middleware, already migrated) + prefs := middleware.GetPreferences(r) + currentLength := prefs.CVLength // Toggle state newLength := "long" @@ -32,12 +30,12 @@ func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { } // Save new state - setPreferenceCookie(w, "cv-length", newLength) + middleware.SetPreferenceCookie(w, "cv-length", newLength) - // Get language + // Get language from query or use current preference lang := r.URL.Query().Get("lang") if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") + lang = prefs.CVLanguage } // Prepare template data with length state @@ -72,16 +70,9 @@ func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { return } - // Get current state - currentIcons := getPreferenceCookie(r, "cv-icons", "show") - - // Migrate old values if needed - switch currentIcons { - case "true": - currentIcons = "show" - case "false": - currentIcons = "hide" - } + // Get current preferences from context (set by middleware, already migrated) + prefs := middleware.GetPreferences(r) + currentIcons := prefs.CVIcons // Toggle state newIcons := "hide" @@ -90,12 +81,12 @@ func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { } // Save new state - setPreferenceCookie(w, "cv-icons", newIcons) + middleware.SetPreferenceCookie(w, "cv-icons", newIcons) - // Get language + // Get language from query or use current preference lang := r.URL.Query().Get("lang") if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") + lang = prefs.CVLanguage } // Prepare template data with logo state @@ -135,7 +126,7 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { } // Save language preference - setPreferenceCookie(w, "cv-language", lang) + middleware.SetPreferenceCookie(w, "cv-language", lang) // Prepare template data data, err := h.prepareTemplateData(lang) @@ -144,19 +135,17 @@ func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { return } - // Preserve current length and logo preferences - cvLength := getPreferenceCookie(r, "cv-length", "short") - cvIcons := getPreferenceCookie(r, "cv-icons", "show") - cvTheme := getPreferenceCookie(r, "cv-theme", "default") + // Get current preferences from context (set by middleware) + prefs := middleware.GetPreferences(r) // Add preferences to data - if cvLength == "long" { + if prefs.CVLength == "long" { data["CVLengthClass"] = "cv-long" } else { data["CVLengthClass"] = "cv-short" } - data["ShowIcons"] = (cvIcons == "show") - data["ThemeClean"] = (cvTheme == "clean") + data["ShowIcons"] = (prefs.CVIcons == "show") + data["ThemeClean"] = (prefs.CVTheme == "clean") // Render language-switch template with out-of-band swaps tmpl, err := h.templates.Render("language-switch.html") @@ -179,8 +168,9 @@ func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { return } - // Get current state - currentTheme := getPreferenceCookie(r, "cv-theme", "default") + // Get current preferences from context (set by middleware) + prefs := middleware.GetPreferences(r) + currentTheme := prefs.CVTheme // Toggle state newTheme := "clean" @@ -189,12 +179,12 @@ func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { } // Save new state - setPreferenceCookie(w, "cv-theme", newTheme) + middleware.SetPreferenceCookie(w, "cv-theme", newTheme) - // Get language + // Get language from query or use current preference lang := r.URL.Query().Get("lang") if lang == "" { - lang = getPreferenceCookie(r, "cv-language", "en") + lang = prefs.CVLanguage } // Prepare template data with theme state diff --git a/internal/handlers/cv_pages.go b/internal/handlers/cv_pages.go index 9289dcb..f5a6104 100644 --- a/internal/handlers/cv_pages.go +++ b/internal/handlers/cv_pages.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/pdf" ) @@ -43,26 +44,13 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { return } - // Get user preferences from context (set by middleware) - // Note: Middleware should be enabled in routes for this to work - // For now, fall back to direct cookie reading for backward compatibility - cvLength := getPreferenceCookie(r, "cv-length", "short") - cvIcons := getPreferenceCookie(r, "cv-icons", "show") - cvTheme := getPreferenceCookie(r, "cv-theme", "default") + // Get user preferences from context (set by PreferencesMiddleware) + prefs := middleware.GetPreferences(r) - // Migrate old preference values to new ones (one-time auto-migration) - if cvLength == "extended" { - cvLength = "long" - setPreferenceCookie(w, "cv-length", "long") - } - switch cvIcons { - case "true": - cvIcons = "show" - setPreferenceCookie(w, "cv-icons", "show") - case "false": - cvIcons = "hide" - setPreferenceCookie(w, "cv-icons", "hide") - } + // Use preferences from context (already migrated by middleware) + cvLength := prefs.CVLength + cvIcons := prefs.CVIcons + cvTheme := prefs.CVTheme // Add preference-specific fields to template data cvLengthClass := "cv-short" diff --git a/internal/middleware/preferences_test.go b/internal/middleware/preferences_test.go new file mode 100644 index 0000000..cd34a1f --- /dev/null +++ b/internal/middleware/preferences_test.go @@ -0,0 +1,344 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestPreferencesMiddleware tests that middleware reads cookies and stores in context +func TestPreferencesMiddleware(t *testing.T) { + tests := []struct { + name string + cookies map[string]string + expectedPrefs *Preferences + description string + }{ + { + name: "Default preferences (no cookies)", + cookies: map[string]string{}, + expectedPrefs: &Preferences{ + CVLength: "short", + CVIcons: "show", + CVLanguage: "en", + CVTheme: "default", + ColorTheme: "light", + }, + description: "Should use default values when no cookies present", + }, + { + name: "All preferences set", + cookies: map[string]string{ + "cv-length": "long", + "cv-icons": "hide", + "cv-language": "es", + "cv-theme": "clean", + "color-theme": "dark", + }, + expectedPrefs: &Preferences{ + CVLength: "long", + CVIcons: "hide", + CVLanguage: "es", + CVTheme: "clean", + ColorTheme: "dark", + }, + description: "Should read all preference cookies correctly", + }, + { + name: "Migration: extended → long", + cookies: map[string]string{ + "cv-length": "extended", + }, + expectedPrefs: &Preferences{ + CVLength: "long", // Migrated from "extended" + CVIcons: "show", + CVLanguage: "en", + CVTheme: "default", + ColorTheme: "light", + }, + description: "Should migrate 'extended' to 'long'", + }, + { + name: "Migration: true → show", + cookies: map[string]string{ + "cv-icons": "true", + }, + expectedPrefs: &Preferences{ + CVLength: "short", + CVIcons: "show", // Migrated from "true" + CVLanguage: "en", + CVTheme: "default", + ColorTheme: "light", + }, + description: "Should migrate 'true' to 'show'", + }, + { + name: "Migration: false → hide", + cookies: map[string]string{ + "cv-icons": "false", + }, + expectedPrefs: &Preferences{ + CVLength: "short", + CVIcons: "hide", // Migrated from "false" + CVLanguage: "en", + CVTheme: "default", + ColorTheme: "light", + }, + description: "Should migrate 'false' to 'hide'", + }, + { + name: "Partial preferences", + cookies: map[string]string{ + "cv-length": "long", + "cv-language": "es", + }, + expectedPrefs: &Preferences{ + CVLength: "long", + CVIcons: "show", // Default + CVLanguage: "es", + CVTheme: "default", // Default + ColorTheme: "light", // Default + }, + description: "Should combine cookies with defaults", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create request with cookies + req := httptest.NewRequest(http.MethodGet, "/", nil) + for name, value := range tt.cookies { + req.AddCookie(&http.Cookie{ + Name: name, + Value: value, + }) + } + + // Create test handler that checks context + var capturedPrefs *Preferences + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPrefs = GetPreferences(r) + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := PreferencesMiddleware(testHandler) + + // Execute request + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + // Verify preferences + if capturedPrefs == nil { + t.Fatal("Preferences not found in context") + } + + if capturedPrefs.CVLength != tt.expectedPrefs.CVLength { + t.Errorf("CVLength: got %q, want %q", capturedPrefs.CVLength, tt.expectedPrefs.CVLength) + } + if capturedPrefs.CVIcons != tt.expectedPrefs.CVIcons { + t.Errorf("CVIcons: got %q, want %q", capturedPrefs.CVIcons, tt.expectedPrefs.CVIcons) + } + if capturedPrefs.CVLanguage != tt.expectedPrefs.CVLanguage { + t.Errorf("CVLanguage: got %q, want %q", capturedPrefs.CVLanguage, tt.expectedPrefs.CVLanguage) + } + if capturedPrefs.CVTheme != tt.expectedPrefs.CVTheme { + t.Errorf("CVTheme: got %q, want %q", capturedPrefs.CVTheme, tt.expectedPrefs.CVTheme) + } + if capturedPrefs.ColorTheme != tt.expectedPrefs.ColorTheme { + t.Errorf("ColorTheme: got %q, want %q", capturedPrefs.ColorTheme, tt.expectedPrefs.ColorTheme) + } + }) + } +} + +// TestGetPreferencesWithoutMiddleware tests fallback behavior +func TestGetPreferencesWithoutMiddleware(t *testing.T) { + // Create request without middleware + req := httptest.NewRequest(http.MethodGet, "/", nil) + + // GetPreferences should return defaults + prefs := GetPreferences(req) + + if prefs.CVLength != "short" { + t.Errorf("Expected default CVLength 'short', got %q", prefs.CVLength) + } + if prefs.CVIcons != "show" { + t.Errorf("Expected default CVIcons 'show', got %q", prefs.CVIcons) + } + if prefs.CVLanguage != "en" { + t.Errorf("Expected default CVLanguage 'en', got %q", prefs.CVLanguage) + } + if prefs.CVTheme != "default" { + t.Errorf("Expected default CVTheme 'default', got %q", prefs.CVTheme) + } + if prefs.ColorTheme != "light" { + t.Errorf("Expected default ColorTheme 'light', got %q", prefs.ColorTheme) + } +} + +// TestSetPreferenceCookie tests cookie setting +func TestSetPreferenceCookie(t *testing.T) { + tests := []struct { + name string + cookieName string + cookieValue string + }{ + { + name: "Set cv-length cookie", + cookieName: "cv-length", + cookieValue: "long", + }, + { + name: "Set cv-icons cookie", + cookieName: "cv-icons", + cookieValue: "hide", + }, + { + name: "Set cv-language cookie", + cookieName: "cv-language", + cookieValue: "es", + }, + { + name: "Set cv-theme cookie", + cookieName: "cv-theme", + cookieValue: "clean", + }, + { + name: "Set color-theme cookie", + cookieName: "color-theme", + cookieValue: "dark", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + + SetPreferenceCookie(w, tt.cookieName, tt.cookieValue) + + // Get cookies from response + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("Expected 1 cookie, got %d", len(cookies)) + } + + cookie := cookies[0] + + // Verify cookie attributes + if cookie.Name != tt.cookieName { + t.Errorf("Cookie name: got %q, want %q", cookie.Name, tt.cookieName) + } + if cookie.Value != tt.cookieValue { + t.Errorf("Cookie value: got %q, want %q", cookie.Value, tt.cookieValue) + } + if cookie.Path != "/" { + t.Errorf("Cookie path: got %q, want %q", cookie.Path, "/") + } + if cookie.MaxAge != 365*24*60*60 { + t.Errorf("Cookie MaxAge: got %d, want %d (1 year)", cookie.MaxAge, 365*24*60*60) + } + if !cookie.HttpOnly { + t.Error("Cookie should be HttpOnly") + } + if cookie.SameSite != http.SameSiteStrictMode { + t.Errorf("Cookie SameSite: got %v, want %v", cookie.SameSite, http.SameSiteStrictMode) + } + }) + } +} + +// TestGetPreferenceCookie tests cookie reading helper +func TestGetPreferenceCookie(t *testing.T) { + tests := []struct { + name string + cookieName string + cookieValue string + defaultValue string + expectedValue string + }{ + { + name: "Cookie exists", + cookieName: "cv-length", + cookieValue: "long", + defaultValue: "short", + expectedValue: "long", + }, + { + name: "Cookie doesn't exist", + cookieName: "cv-length", + cookieValue: "", + defaultValue: "short", + expectedValue: "short", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + + if tt.cookieValue != "" { + req.AddCookie(&http.Cookie{ + Name: tt.cookieName, + Value: tt.cookieValue, + }) + } + + value := getPreferenceCookie(req, tt.cookieName, tt.defaultValue) + + if value != tt.expectedValue { + t.Errorf("Expected %q, got %q", tt.expectedValue, value) + } + }) + } +} + +// TestMiddlewareChain tests middleware can be chained +func TestMiddlewareChain(t *testing.T) { + // Create multiple handlers in chain + finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + prefs := GetPreferences(r) + w.Header().Set("X-CV-Length", prefs.CVLength) + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := PreferencesMiddleware(finalHandler) + + // Create request with cookie + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "long"}) + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + // Verify preferences were passed through chain + if w.Header().Get("X-CV-Length") != "long" { + t.Errorf("Expected X-CV-Length header 'long', got %q", w.Header().Get("X-CV-Length")) + } +} + +// TestMultipleMigrations tests multiple migrations in one request +func TestMultipleMigrations(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "extended"}) + req.AddCookie(&http.Cookie{Name: "cv-icons", Value: "true"}) + + var capturedPrefs *Preferences + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPrefs = GetPreferences(r) + }) + + wrappedHandler := PreferencesMiddleware(testHandler) + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + // Both migrations should occur + if capturedPrefs.CVLength != "long" { + t.Errorf("CVLength: expected 'long' (migrated from 'extended'), got %q", capturedPrefs.CVLength) + } + if capturedPrefs.CVIcons != "show" { + t.Errorf("CVIcons: expected 'show' (migrated from 'true'), got %q", capturedPrefs.CVIcons) + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b7fb123..6d9cf01 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -41,9 +41,12 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) mux.Handle("/static/", middleware.CacheControl(staticHandler)) // Apply comprehensive middleware chain + // Order: Recovery → Logger → SecurityHeaders → Preferences → Mux handler := middleware.Recovery( middleware.Logger( - middleware.SecurityHeaders(mux), + middleware.SecurityHeaders( + middleware.PreferencesMiddleware(mux), + ), ), ) diff --git a/tests/mjs/debug-tooltip.mjs b/tests/mjs/debug-tooltip.mjs new file mode 100755 index 0000000..407deca --- /dev/null +++ b/tests/mjs/debug-tooltip.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env bun +/** + * Quick debug to see what's happening with tooltips + */ + +import { chromium } from 'playwright'; + +const URL = "http://localhost:1999"; + +async function debugTooltip() { + console.log('🔍 TOOLTIP DEBUG\n'); + + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + + await page.goto(URL); + await page.waitForTimeout(2000); + + console.log("\n1️⃣ Checking button and tooltip setup..."); + + const buttonInfo = await page.evaluate(() => { + const btn = document.querySelector('#action-bar-pdf-btn'); + if (!btn) return { found: false }; + + const computedBefore = window.getComputedStyle(btn, '::before'); + + return { + found: true, + classes: Array.from(btn.classList), + dataTooltip: btn.getAttribute('data-tooltip'), + beforeStyles: { + content: computedBefore.content, + position: computedBefore.position, + opacity: computedBefore.opacity, + visibility: computedBefore.visibility, + display: computedBefore.display, + fontSize: computedBefore.fontSize, + fontWeight: computedBefore.fontWeight, + background: computedBefore.background, + color: computedBefore.color, + left: computedBefore.left, + top: computedBefore.top, + transform: computedBefore.transform, + zIndex: computedBefore.zIndex + } + }; + }); + + console.log('\n📊 Button Information:'); + console.log(' Classes:', buttonInfo.classes.join(', ')); + console.log(' data-tooltip:', buttonInfo.dataTooltip); + + console.log('\n🎨 ::before Pseudo-element Styles:'); + console.log(' content:', buttonInfo.beforeStyles.content); + console.log(' position:', buttonInfo.beforeStyles.position); + console.log(' opacity:', buttonInfo.beforeStyles.opacity); + console.log(' visibility:', buttonInfo.beforeStyles.visibility); + console.log(' display:', buttonInfo.beforeStyles.display); + console.log(' fontSize:', buttonInfo.beforeStyles.fontSize); + console.log(' fontWeight:', buttonInfo.beforeStyles.fontWeight); + console.log(' background:', buttonInfo.beforeStyles.background); + console.log(' color:', buttonInfo.beforeStyles.color); + console.log(' left:', buttonInfo.beforeStyles.left); + console.log(' top:', buttonInfo.beforeStyles.top); + console.log(' transform:', buttonInfo.beforeStyles.transform); + console.log(' z-index:', buttonInfo.beforeStyles.zIndex); + + console.log('\n2️⃣ Checking if tooltip CSS file is loaded...'); + + const cssCheck = await page.evaluate(() => { + // Check all link tags + const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); + const cssLinks = links.map(l => l.href); + + // Try to find tooltip rules + let tooltipRulesFound = []; + for (const sheet of document.styleSheets) { + try { + const rules = Array.from(sheet.cssRules || []); + for (const rule of rules) { + if (rule.cssText && (rule.cssText.includes('.has-tooltip') || rule.cssText.includes('has-tooltip'))) { + tooltipRulesFound.push(rule.cssText.substring(0, 100)); + } + } + } catch (e) { + // Skip cross-origin sheets + } + } + + return { + cssLinks, + tooltipRulesFound + }; + }); + + console.log('\n📄 CSS Files Loaded:'); + cssCheck.cssLinks.forEach(link => console.log(' -', link)); + + console.log('\n📋 Tooltip CSS Rules Found:', cssCheck.tooltipRulesFound.length); + if (cssCheck.tooltipRulesFound.length > 0) { + console.log(' Sample rules:'); + cssCheck.tooltipRulesFound.slice(0, 3).forEach(rule => console.log(' -', rule)); + } + + console.log('\n3️⃣ Testing hover...'); + console.log('Hovering over PDF button in 2 seconds...'); + await page.waitForTimeout(2000); + + await page.hover('#action-bar-pdf-btn'); + await page.waitForTimeout(1000); + + const afterHover = await page.evaluate(() => { + const btn = document.querySelector('#action-bar-pdf-btn'); + const computedBefore = window.getComputedStyle(btn, '::before'); + + return { + opacity: computedBefore.opacity, + visibility: computedBefore.visibility, + transform: computedBefore.transform + }; + }); + + console.log('\n🖱️ After Hover:'); + console.log(' opacity:', afterHover.opacity, '(should be 1)'); + console.log(' visibility:', afterHover.visibility, '(should be visible)'); + console.log(' transform:', afterHover.transform); + + console.log('\n💡 Browser is open. Try hovering over the buttons yourself!'); + console.log('Press Ctrl+C when done.\n'); + + await new Promise(() => {}); // Keep browser open +} + +await debugTooltip();