package main import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "xtablo-backend/internal/web/handlers" ) func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("expected status 303, got %d", rec.Code) } if location := rec.Header().Get("Location"); location != "/login" { t.Fatalf("expected redirect to /login, got %q", location) } } func TestUnknownRouteRedirectsToLoginWhenUnauthenticated(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/missing", nil) rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("expected status 303, got %d", rec.Code) } if location := rec.Header().Get("Location"); location != "/login" { t.Fatalf("expected redirect to /login, got %q", location) } } func TestLoginPageRenders(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/login", nil) rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ "Se connecter à Xtablo", `hx-post="/login"`, "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", `href="/pwa-icons/favicon-32x32.png"`, `href="/pwa-icons/favicon-16x16.png"`, `href="/pwa-icons/apple-touch-icon-180x180.png"`, `href="/manifest.webmanifest"`, `src="/logo_dark.png"`, `src="/logo_white.png"`, `data-testid="auth-card-shell"`, "Découvrez la nouvelle expérience de connexion", "Mot de passe oublié ?", } { if !strings.Contains(body, want) { t.Fatalf("expected body to contain %q", want) } } } func TestSignupPageRenders(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/signup", nil) rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ "S'inscrire à Xtablo", `hx-post="/signup"`, "Vous avez déjà un compte ?", } { if !strings.Contains(body, want) { t.Fatalf("expected body to contain %q", want) } } } func TestBrandingAssetsAreServed(t *testing.T) { testCases := []string{ "/logo_dark.png", "/logo_white.png", "/pwa-icons/favicon-32x32.png", "/pwa-icons/favicon-16x16.png", "/pwa-icons/apple-touch-icon-180x180.png", "/manifest.webmanifest", } router := newTestRouter() for _, path := range testCases { req := httptest.NewRequest(http.MethodGet, path, nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected %s to return 200, got %d", path, rec.Code) } } } func TestLoginReturnsValidationError(t *testing.T) { form := url.Values{} form.Set("email", "") form.Set("password", "") req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("expected status 422, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Veuillez renseigner votre email et votre mot de passe") { t.Fatalf("expected validation error fragment, got %q", rec.Body.String()) } } func TestLoginCreatesSessionAndRedirects(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { t.Fatalf("expected HX-Redirect to /, got %q", redirect) } sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } homeReq := httptest.NewRequest(http.MethodGet, "/", nil) homeReq.AddCookie(sessionCookie) homeRec := httptest.NewRecorder() router.ServeHTTP(homeRec, homeReq) if homeRec.Code != http.StatusOK { t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code) } for _, want := range []string{ "Bonjour,", "Aperçu", "https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js", `hx-get="/tasks"`, `hx-target="#app-main-content"`, `hx-swap="outerHTML"`, `hx-push-url="true"`, "Tâches", "Projets", "Planning", "Discussions", "Fichiers", "Feedback", "Arctic Matrix", "Créer un projet", "Créer une tâche", "Inviter l'équipe", "Envoyer un message", "Mes Projets", "Mes Tâches", `action="/logout"`, } { if !strings.Contains(homeRec.Body.String(), want) { t.Fatalf("expected authenticated home page to contain %q, got %q", want, homeRec.Body.String()) } } } func TestTasksPageRendersFullDashboardPage(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(loginRec, loginReq) sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } req := httptest.NewRequest(http.MethodGet, "/tasks", nil) req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ `class="sidebar-nav-shell"`, `id="app-main-content"`, "Tâches", "Suivez les tâches de votre équipe", } { if !strings.Contains(body, want) { t.Fatalf("expected tasks page to contain %q", want) } } } func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(loginRec, loginReq) sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } req := httptest.NewRequest(http.MethodGet, "/tasks", nil) req.Header.Set("HX-Request", "true") req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ `id="app-main-content"`, `hx-swap-oob="outerHTML"`, `id="sidebar-nav-tasks"`, "Tâches", "Suivez les tâches de votre équipe", } { if !strings.Contains(body, want) { t.Fatalf("expected HTMX tasks response to contain %q", want) } } if strings.Contains(body, `class="sidebar-nav-shell"`) { t.Fatalf("expected HTMX tasks response to avoid rerendering the full sidebar") } } func TestSignupCreatesUserSessionAndRedirects(t *testing.T) { form := url.Values{} form.Set("email", "new@xtablo.com") form.Set("password", "xtablo-secret") req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } if redirect := rec.Header().Get("HX-Redirect"); redirect != "/" { t.Fatalf("expected HX-Redirect to /, got %q", redirect) } sessionCookie := findCookie(rec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() router.ServeHTTP(loginRec, loginReq) if loginRec.Header().Get("HX-Redirect") != "/" { t.Fatalf("expected signed up user to be able to log in") } } func TestLogoutClearsSessionAndRedirectsToLogin(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(loginRec, loginReq) sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil) logoutReq.AddCookie(sessionCookie) logoutRec := httptest.NewRecorder() router.ServeHTTP(logoutRec, logoutReq) if logoutRec.Code != http.StatusSeeOther { t.Fatalf("expected logout status 303, got %d", logoutRec.Code) } if location := logoutRec.Header().Get("Location"); location != "/login" { t.Fatalf("expected logout redirect to /login, got %q", location) } clearedCookie := findCookie(logoutRec.Result().Cookies(), "xtablo_session") if clearedCookie == nil { t.Fatalf("expected cleared session cookie") } if clearedCookie.MaxAge >= 0 && clearedCookie.Value != "" { t.Fatalf("expected cleared session cookie to be expired, got %+v", clearedCookie) } homeReq := httptest.NewRequest(http.MethodGet, "/", nil) homeReq.AddCookie(sessionCookie) homeRec := httptest.NewRecorder() router.ServeHTTP(homeRec, homeReq) if homeRec.Code != http.StatusSeeOther { t.Fatalf("expected logged-out root access to redirect, got %d", homeRec.Code) } if location := homeRec.Header().Get("Location"); location != "/login" { t.Fatalf("expected logged-out root redirect to /login, got %q", location) } } func TestUnknownRouteShowsDashboard404WhenAuthenticated(t *testing.T) { form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() router := newTestRouter() router.ServeHTTP(loginRec, loginReq) sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } req := httptest.NewRequest(http.MethodGet, "/missing", nil) req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status 404, got %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ "Aperçu", "Page introuvable", "Cette page n'existe pas", `href="/"`, `action="/logout"`, } { if !strings.Contains(body, want) { t.Fatalf("expected authenticated 404 page to contain %q", want) } } } func TestSessionSurvivesHandlerRecreation(t *testing.T) { repo := handlers.NewInMemoryAuthRepository() loginRouter := newRouterWithHandler(handlers.NewAuthHandler(repo)) form := url.Values{} form.Set("email", "demo@xtablo.com") form.Set("password", "xtablo-demo") loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") loginRec := httptest.NewRecorder() loginRouter.ServeHTTP(loginRec, loginReq) sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session") if sessionCookie == nil { t.Fatalf("expected session cookie to be set") } reloadedRouter := newRouterWithHandler(handlers.NewAuthHandler(repo)) homeReq := httptest.NewRequest(http.MethodGet, "/", nil) homeReq.AddCookie(sessionCookie) homeRec := httptest.NewRecorder() reloadedRouter.ServeHTTP(homeRec, homeReq) if homeRec.Code != http.StatusOK { t.Fatalf("expected session to survive handler recreation, got status %d", homeRec.Code) } } func findCookie(cookies []*http.Cookie, name string) *http.Cookie { for _, cookie := range cookies { if cookie.Name == name { return cookie } } return nil }