627 lines
27 KiB
Go
627 lines
27 KiB
Go
package web
|
|
|
|
import (
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gardomatic.kleiax.de/lib/client"
|
|
"github.com/go-playground/form/v4"
|
|
)
|
|
|
|
type webHandlerTransport struct {
|
|
handler http.Handler
|
|
}
|
|
|
|
func (transport webHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
recorder := httptest.NewRecorder()
|
|
transport.handler.ServeHTTP(recorder, request)
|
|
response := recorder.Result()
|
|
response.Request = request
|
|
return response, nil
|
|
}
|
|
|
|
func newAPIBackedTestApplication(t *testing.T, handler http.Handler) *application {
|
|
t.Helper()
|
|
templateCache, err := newTemplateCache()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
apiClient, err := client.New("https://api.example", client.WithHTTPClient(&http.Client{Transport: webHandlerTransport{handler: handler}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &application{
|
|
config: Config{SessionCookieName: "gardomatic_session", CookieSecure: false},
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
templateCache: templateCache,
|
|
formDecoder: form.NewDecoder(),
|
|
apiClient: apiClient,
|
|
}
|
|
}
|
|
|
|
func TestSettingsKeepsSelectedGarden(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/settings?garden=3", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
body := response.Body.String()
|
|
if !strings.Contains(body, "href='/g/3'>Hinterhof</a>") || !strings.Contains(body, "href='/settings?garden=3'") {
|
|
t.Fatalf("selected garden was not kept in settings: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestHealthAndPrivacyPagesArePublic(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
|
case "/v1/healthcheck":
|
|
_, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
|
|
for _, test := range []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/healtcheck", "Serverzeit"},
|
|
{"/datenschutz", "Verarbeitete Daten"},
|
|
} {
|
|
request := httptest.NewRequest(http.MethodGet, test.path, nil)
|
|
response := httptest.NewRecorder()
|
|
app.routes().ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), test.want) {
|
|
t.Errorf("GET %s: status=%d, missing %q: %s", test.path, response.Code, test.want, response.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPinboardRequestsOnlyPinboardEntries(t *testing.T) {
|
|
requestedType := ""
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
|
|
case "/v1/gardens/3/journal":
|
|
requestedType = r.URL.Query().Get("type")
|
|
_, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"garden_id":3,"entry_type":"pinboard","title":"Sitzecke","body":"Bank bauen"}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/g/3/pinboard", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
if requestedType != client.JournalEntryTypePinboard {
|
|
t.Fatalf("entry type: got %q, want %q", requestedType, client.JournalEntryTypePinboard)
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, "Sitzecke") || !strings.Contains(body, "/g/3/pinboard/edit/8") {
|
|
t.Fatalf("pinboard entry missing: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGardenPageUsesIncomingSession(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
cookie, err := r.Cookie("gardomatic_session")
|
|
if err != nil || cookie.Value != "browser-session" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "browser-session", Path: "/", HttpOnly: true, MaxAge: 1800})
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
|
|
case "/v1/gardens":
|
|
_, _ = w.Write([]byte(`{"gardens":[{"id":3,"name":"Hinterhof","description":"Gemüse"}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
var refreshedSession *http.Cookie
|
|
for _, cookie := range response.Result().Cookies() {
|
|
if cookie.Name == "gardomatic_session" {
|
|
refreshedSession = cookie
|
|
break
|
|
}
|
|
}
|
|
if refreshedSession == nil || refreshedSession.Value != "browser-session" || refreshedSession.MaxAge != 1800 {
|
|
t.Errorf("refreshed session cookie was not forwarded: got %+v", refreshedSession)
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, "Hinterhof") || !strings.Contains(body, "Alice") {
|
|
t.Errorf("page does not contain garden and user: %s", body)
|
|
}
|
|
body := response.Body.String()
|
|
if !strings.Contains(body, `href='/gardens/new'`) {
|
|
t.Errorf("page does not link to the garden creation page: %s", body)
|
|
}
|
|
for _, want := range []string{"class='user-menu'", `href='/gardens'`, `href='/account'`, ">Nutzer ", "Abmelden"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("user menu does not contain %q: %s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, `<nav aria-label='Gartennavigation'>`) {
|
|
t.Errorf("garden overview unexpectedly renders an empty garden navigation: %s", body)
|
|
}
|
|
if strings.Contains(body, `<form action='/gardens'`) {
|
|
t.Errorf("garden creation form is still rendered on the overview: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGardenCreatePage(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/session" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/gardens/new", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
body := response.Body.String()
|
|
if !strings.Contains(body, "Garten anlegen") || !strings.Contains(body, "name='name'") || !strings.Contains(body, `href='/gardens'`) {
|
|
t.Errorf("garden creation page is incomplete: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGardenEditPageIncludesMemberManagement(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","description":"Gemüse","role":"owner"}}`))
|
|
case "/v1/gardens/3/members":
|
|
_, _ = w.Write([]byte(`{"members":[{"garden_id":3,"user_id":7,"name":"Alice","email":"alice@example.com","role":"owner"}]}`))
|
|
case "/v1/gardens/3/invites":
|
|
_, _ = w.Write([]byte(`{"invites":[{"id":11,"garden_id":3,"email":"bob@example.com","role":"member","expires_at":"2026-09-10T12:00:00Z"}]}`))
|
|
case "/v1/gardens/3/roles":
|
|
_, _ = w.Write([]byte(`{"roles":[{"role":{"name":"owner","scope":"garden","label":"Eigentümer","system":true},"effective_permissions":["garden:delete"]},{"role":{"name":"member","scope":"garden","label":"Mitglied","system":true},"effective_permissions":["garden:read"]},{"role":{"name":"worker","scope":"garden","label":"Mitarbeiter","system":true},"effective_permissions":["garden:read","tasks:complete:own"]}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/gardens/edit/3", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
body := response.Body.String()
|
|
for _, want := range []string{"Bearbeitungsbereiche", "href='#garden-general'", "href='#garden-members'", "href='#garden-invites'", "href='#garden-role-editor'", "href='#garden-delete'", "Allgemein", "Mitglieder", "Alice", "Mitglied einladen", "Mitarbeiter", "Gartenspezifische Rollenrechte", "data-role-select", ">Neu</option>", "bob@example.com", "Garten löschen", "delete-garden-dialog", "Endgültig löschen", "action='/gardens/delete/3'"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("garden edit page does not contain %q: %s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, `href='/g/3/members'`) {
|
|
t.Errorf("navigation still contains the separate members link: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1/session" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
|
|
}
|
|
if location := response.Header().Get("Location"); location != "/login" {
|
|
t.Errorf("Location: got %q, want %q", location, "/login")
|
|
}
|
|
}
|
|
|
|
func TestSignInForwardsAPISessionCookie(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/v1/session" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "new-session", Path: "/", HttpOnly: true})
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}}
|
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
|
|
response := httptest.NewRecorder()
|
|
|
|
app.signInPost(response, request)
|
|
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
|
|
}
|
|
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "new-session" {
|
|
t.Fatalf("forwarded cookies: got %+v", cookies)
|
|
}
|
|
}
|
|
|
|
func TestInactiveSignInRedirectsToActivation(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/v1/session" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "inactive-session", Path: "/", HttpOnly: true})
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":false}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}}
|
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
|
|
response := httptest.NewRecorder()
|
|
|
|
app.signInPost(response, request)
|
|
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
|
|
}
|
|
if location := response.Header().Get("Location"); location != "/activate" {
|
|
t.Errorf("Location: got %q, want %q", location, "/activate")
|
|
}
|
|
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "inactive-session" {
|
|
t.Fatalf("forwarded cookies: got %+v", cookies)
|
|
}
|
|
}
|
|
|
|
func TestInactiveSessionRedirectsProtectedPageToActivation(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/session" {
|
|
t.Fatalf("protected API resource should not be requested, got %s", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":false}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/gardens", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "inactive-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
|
|
}
|
|
if location := response.Header().Get("Location"); location != "/activate" {
|
|
t.Errorf("Location: got %q, want %q", location, "/activate")
|
|
}
|
|
}
|
|
|
|
func TestActivationTokenActivatesUser(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut || r.URL.Path != "/v1/users/activated" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), `"token":"ABCDEFGHIJKLMNOPQRSTUVWXYZ"`) {
|
|
t.Fatalf("activation request body: %s", body)
|
|
}
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
form := url.Values{"token": {"ABCDEFGHIJKLMNOPQRSTUVWXYZ"}}
|
|
request := httptest.NewRequest(http.MethodPost, "/activate", strings.NewReader(form.Encode()))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
|
|
response := httptest.NewRecorder()
|
|
|
|
app.activateUserPost(response, request)
|
|
|
|
if response.Code != http.StatusSeeOther {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusSeeOther, response.Body.String())
|
|
}
|
|
if location := response.Header().Get("Location"); location != "/gardens" {
|
|
t.Errorf("Location: got %q, want %q", location, "/gardens")
|
|
}
|
|
}
|
|
|
|
func TestActivationLinkPrefillsToken(t *testing.T) {
|
|
app := newAPIBackedTestApplication(t, http.NotFoundHandler())
|
|
request := httptest.NewRequest(http.MethodGet, "/activate?token=ABCDEFGHIJKLMNOPQRSTUVWXYZ", nil)
|
|
response := httptest.NewRecorder()
|
|
|
|
app.activateUser(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, `value='ABCDEFGHIJKLMNOPQRSTUVWXYZ'`) {
|
|
t.Errorf("activation page does not contain token: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestRoutesAcceptCSRFTokenFromLoginPage(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/session" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
handler := app.routes()
|
|
|
|
getRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
getResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(getResponse, getRequest)
|
|
if getResponse.Code != http.StatusOK {
|
|
t.Fatalf("GET status: got %d, want %d", getResponse.Code, http.StatusOK)
|
|
}
|
|
tokenMatch := regexp.MustCompile(`name='csrf_token' value='([^']+)'`).FindStringSubmatch(getResponse.Body.String())
|
|
if len(tokenMatch) != 2 {
|
|
t.Fatal("login page does not contain a CSRF token")
|
|
}
|
|
|
|
form := url.Values{
|
|
"csrf_token": {html.UnescapeString(tokenMatch[1])},
|
|
"email": {"alice@example.com"},
|
|
"password": {"correct horse battery staple"},
|
|
}
|
|
postRequest := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
postRequest.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
for _, cookie := range getResponse.Result().Cookies() {
|
|
postRequest.AddCookie(cookie)
|
|
}
|
|
postResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(postResponse, postRequest)
|
|
|
|
if postResponse.Code != http.StatusSeeOther {
|
|
t.Fatalf("POST status: got %d, want %d; body: %s", postResponse.Code, http.StatusSeeOther, postResponse.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPlantFormLoadsAvailableSpecies(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
|
|
case "/v1/gardens/3/species":
|
|
_, _ = w.Write([]byte(`{"species":[{"id":11,"common_name":"Tomate","cultivar":"Ochsenherz"}]}`))
|
|
case "/v1/gardens/3/locations":
|
|
_, _ = w.Write([]byte(`{"locations":[{"id":12,"garden_id":3,"name":"Hochbeet"}]}`))
|
|
case "/v1/task-priorities":
|
|
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/g/3/plants/new?location_id=12", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, "Ochsenherz") {
|
|
t.Errorf("species option missing from form: %s", body)
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, "value='12' selected") {
|
|
t.Errorf("preselected location missing from form: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestLocationsPageRendersHierarchy(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
|
|
case "/v1/gardens/3/locations":
|
|
_, _ = w.Write([]byte(`{"locations":[{"id":2,"garden_id":3,"parent_id":1,"name":"Reihe 1"},{"id":1,"garden_id":3,"name":"Hochbeet"}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/g/3/locations", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
app.routes().ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d; %s", response.Code, response.Body.String())
|
|
}
|
|
body := response.Body.String()
|
|
if root, child := strings.Index(body, "Hochbeet"), strings.Index(body, "Reihe 1"); root < 0 || child < root {
|
|
t.Errorf("hierarchy missing or reversed: %s", body)
|
|
}
|
|
if !strings.Contains(body, `href='/g/3/locations/view/1'`) {
|
|
t.Errorf("location tile does not link to its detail page: %s", body)
|
|
}
|
|
for _, want := range []string{"data-view-variant='list' hidden", "class='card-grid' data-view-variant='grid'", "data-view-variants data-view-key='locations'"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("separate flat tile and hierarchical list views missing %q: %s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "location-tree collection") || strings.Contains(body, "location-tree' data-view-key") {
|
|
t.Errorf("tile view must not reuse the hierarchical location tree: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestLocationDetailIncludesFormAndAssignedPlants(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
case "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
|
|
case "/v1/gardens/3/locations/1":
|
|
_, _ = w.Write([]byte(`{"location":{"id":1,"garden_id":3,"name":"Hochbeet","description":"Sonnige Lage"}}`))
|
|
case "/v1/gardens/3/locations":
|
|
_, _ = w.Write([]byte(`{"locations":[{"id":1,"garden_id":3,"name":"Hochbeet"}]}`))
|
|
case "/v1/gardens/3/plants":
|
|
_, _ = w.Write([]byte(`{"plants":[{"id":10,"garden_id":3,"name":"Tomate","status":"active"}]}`))
|
|
case "/v1/gardens/3/plants/10/locations":
|
|
_, _ = w.Write([]byte(`{"plant_locations":[{"id":20,"plant_id":10,"location_id":1,"quantity":3,"planted_at":"2026-05-01T00:00:00Z"}]}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
request := httptest.NewRequest(http.MethodGet, "/g/3/locations/view/1", nil)
|
|
request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
response := httptest.NewRecorder()
|
|
|
|
app.routes().ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String())
|
|
}
|
|
body := response.Body.String()
|
|
for _, want := range []string{"Ort bearbeiten", "Sonnige Lage", "Pflanzen an diesem Ort", "Tomate", "Anzahl: 3", "01 May 2026"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("location detail does not contain %q: %s", want, body)
|
|
}
|
|
}
|
|
for _, want := range []string{"id='location-form'", "class='actions location-form-actions'", "form='location-form'>Speichern", ">Abbrechen</a>", "class='danger'>Ort löschen"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("location action row does not contain %q: %s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInlineLocationCreationKeepsProgressiveFallback(t *testing.T) {
|
|
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/v1/session":
|
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
|
case r.URL.Path == "/v1/gardens/3":
|
|
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof"}}`))
|
|
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/locations":
|
|
_, _ = w.Write([]byte(`{"locations":[]}`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/locations":
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"location":{"id":12,"garden_id":3,"name":"Kräuterbeet"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
app := newAPIBackedTestApplication(t, apiHandler)
|
|
handler := app.routes()
|
|
getRequest := httptest.NewRequest(http.MethodGet, "/g/3/locations/new?return_to=/g/3/plants/new", nil)
|
|
getRequest.Header.Set("HX-Request", "true")
|
|
getRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
getResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(getResponse, getRequest)
|
|
if getResponse.Code != http.StatusOK || !strings.Contains(getResponse.Body.String(), "<dialog") || strings.Contains(getResponse.Body.String(), "<!doctype") {
|
|
t.Fatalf("inline GET: status=%d body=%s", getResponse.Code, getResponse.Body.String())
|
|
}
|
|
tokenMatch := regexp.MustCompile(`name='csrf_token' value='([^']+)'`).FindStringSubmatch(getResponse.Body.String())
|
|
if len(tokenMatch) != 2 {
|
|
t.Fatal("inline form has no CSRF token")
|
|
}
|
|
invalidForm := url.Values{"csrf_token": {html.UnescapeString(tokenMatch[1])}, "return_to": {"/g/3/plants/new"}}
|
|
invalidRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/new", strings.NewReader(invalidForm.Encode()))
|
|
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
invalidRequest.Header.Set("HX-Request", "true")
|
|
invalidRequest.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
invalidRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
for _, cookie := range getResponse.Result().Cookies() {
|
|
invalidRequest.AddCookie(cookie)
|
|
}
|
|
invalidResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(invalidResponse, invalidRequest)
|
|
if invalidResponse.Code != http.StatusOK || invalidResponse.Header().Get("HX-Retarget") != "#location-dialog-host" || !strings.Contains(invalidResponse.Body.String(), "Ein Name ist erforderlich") {
|
|
t.Fatalf("inline validation: status=%d retarget=%q body=%s", invalidResponse.Code, invalidResponse.Header().Get("HX-Retarget"), invalidResponse.Body.String())
|
|
}
|
|
form := url.Values{"csrf_token": {html.UnescapeString(tokenMatch[1])}, "name": {"Kräuterbeet"}, "return_to": {"/g/3/plants/new"}}
|
|
postRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/new", strings.NewReader(form.Encode()))
|
|
postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
postRequest.Header.Set("HX-Request", "true")
|
|
postRequest.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
postRequest.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"})
|
|
for _, cookie := range getResponse.Result().Cookies() {
|
|
postRequest.AddCookie(cookie)
|
|
}
|
|
postResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(postResponse, postRequest)
|
|
if postResponse.Code != http.StatusCreated || postResponse.Header().Get("HX-Trigger") != "locationCreated" || !strings.Contains(postResponse.Body.String(), "value='12' selected") {
|
|
t.Fatalf("inline POST: status=%d trigger=%q body=%s", postResponse.Code, postResponse.Header().Get("HX-Trigger"), postResponse.Body.String())
|
|
}
|
|
}
|