Files
Gardomatic/internal/web/flows_test.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

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