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

277 lines
14 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gardomatic.kleiax.de/lib/client"
"github.com/julienschmidt/httprouter"
)
func TestPlantListUsesClickableCardsAndStatusMenu(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[{"id":5,"garden_id":3,"name":"Rose","status":"active"}]}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[]}`))
case "/v1/gardens/3/plants/5/locations":
_, _ = w.Write([]byte(`{"plant_locations":[]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/plants", nil)
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plants(response, request)
body := response.Body.String()
for _, want := range []string{"card--clickable plant-card", "class='card-link' href='/g/3/plants/edit/5'", "plant-card-menu", "/g/3/plants/status/5"} {
if !strings.Contains(body, want) {
t.Errorf("plant list does not contain %q: %s", want, body)
}
}
for _, unwanted := range []string{"Weiteren Ort zuordnen", ">Bearbeiten</a>", ">Löschen</button>"} {
if strings.Contains(body, unwanted) {
t.Errorf("plant list still contains %q: %s", unwanted, body)
}
}
}
func TestPlantEditRendersAllAssignmentsAndDeleteAction(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/plants/5":
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Rose","status":"active"}}`))
case "/v1/gardens/3/plants/5/locations":
_, _ = w.Write([]byte(`{"plant_locations":[{"id":9,"plant_id":5,"location_id":6,"quantity":2},{"id":10,"plant_id":5,"location_id":7,"quantity":4}]}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":6,"name":"Südbeet"},{"id":7,"name":"Topf"}]}`))
case "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true},{"id":2,"name":"Erhöht","value":3,"active":true}]}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/g/3/plants/edit/5", nil)
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantEdit(response, request)
body := response.Body.String()
if strings.Count(body, "data-assignment-row") != 3 { // two rows plus the template row
t.Fatalf("expected both assignments and the add-row template: %s", body)
}
for _, want := range []string{"value='9'", "value='10'", "data-add-assignment", "Pflanze löschen", "/g/3/plants/delete/5"} {
if !strings.Contains(body, want) {
t.Errorf("plant form does not contain %q: %s", want, body)
}
}
}
func TestPlantSaveUpdatesCreatesAndDeletesAssignments(t *testing.T) {
var updated, created, deleted bool
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5":
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Rose"}}`))
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9":
updated = true
_, _ = w.Write([]byte(`{"plant_location":{"id":9}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants/5/locations":
created = true
_, _ = w.Write([]byte(`{"plant_location":{"id":11}}`))
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/3/plants/5/locations/10":
deleted = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{
"name": {"Rose"},
"species_id": {"0"},
"status": {"active"},
"assignment_id": {"9", "0", "10"},
"location_id": {"6", "7", "0"},
"quantity": {"2", "3", "1"},
}
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantEditPost(response, request)
if response.Code != http.StatusSeeOther || !updated || !created || !deleted {
t.Fatalf("status=%d updated=%v created=%v deleted=%v body=%s", response.Code, updated, created, deleted, response.Body.String())
}
}
func TestPlantStatusPostOnlyUpdatesStatus(t *testing.T) {
var input client.PlantInput
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &input); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"plant":{"id":5,"status":"dormant"}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/status/5", strings.NewReader("status=dormant"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}})
response := httptest.NewRecorder()
app.plantStatusPost(response, request)
if response.Code != http.StatusSeeOther || input.Status == nil || *input.Status != "dormant" || input.Name != nil {
t.Fatalf("status=%d input=%+v body=%s", response.Code, input, response.Body.String())
}
}
func TestPlantTaskFragmentsAndAutomaticNameHooks(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/gardens/3":
_, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`))
case "/v1/gardens/3/species":
_, _ = w.Write([]byte(`{"species":[{"id":7,"common_name":"Tomate","cultivar":"Roma"}]}`))
case "/v1/gardens/3/locations":
_, _ = w.Write([]byte(`{"locations":[{"id":11,"garden_id":3,"name":"Gewächshaus"}]}`))
case "/v1/gardens/3/plants":
_, _ = w.Write([]byte(`{"plants":[]}`))
case "/v1/gardens/3/tags":
_, _ = w.Write([]byte(`{"tags":["Frühjahr"]}`))
case "/v1/task-priorities":
_, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true},{"id":2,"name":"Erhöht","value":3,"active":true}]}`))
case "/v1/gardens/3/species/7/task-templates":
_, _ = w.Write([]byte(`{"task_templates":[{"id":9,"species_id":7,"title":"Ausgeizen","active":true}]}`))
case "/v1/gardens/3/species/7/task-templates/9":
_, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Ausgeizen","description":"Seitentriebe entfernen","trigger_type":"month_of_year","month_from":5,"month_to":8,"active":true}}`))
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
params := httprouter.Params{{Key: "gardenID", Value: "3"}}
request := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plants/new", nil), app.apiClient, params)
response := httptest.NewRecorder()
app.plantCreate(response, request)
body := response.Body.String()
for _, want := range []string{"data-plant-name", "data-plant-species", "data-plant-name-value='Tomate · Roma'", "/g/3/plant-template-tasks", "/g/3/plant-task-row"} {
if !strings.Contains(body, want) {
t.Errorf("plant form does not contain %q: %s", want, body)
}
}
fragmentRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-template-tasks?species_id=7", nil), app.apiClient, params)
fragmentResponse := httptest.NewRecorder()
app.plantTemplateTasks(fragmentResponse, fragmentRequest)
if fragmentResponse.Code != http.StatusOK || !strings.Contains(fragmentResponse.Body.String(), "Ausgeizen") || !strings.Contains(fragmentResponse.Body.String(), "plant_template_active_9") {
t.Fatalf("template fragment: status=%d body=%s", fragmentResponse.Code, fragmentResponse.Body.String())
}
rowRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-task-row?new=true&name=Jungpflanze&location_id=11", nil), app.apiClient, params)
rowResponse := httptest.NewRecorder()
app.plantTaskRow(rowResponse, rowRequest)
rowBody := rowResponse.Body.String()
for _, want := range []string{
"plant_task_title", "plant_task_description", "plant_task_keywords", "plant_task_plant_id",
"plant_task_location_id", "plant_task_due_at_start", "plant_task_due_at_end",
"plant_task_recurrence_interval", "plant_task_recurrence", "plant_task_priority",
"plant_task_status_on_completion", "Jungpflanze · wird beim Speichern angelegt",
"value='11' selected", "data-tag-suggestion='Frühjahr'",
} {
if !strings.Contains(rowBody, want) {
t.Errorf("plant task dialog does not contain %q: %s", want, rowBody)
}
}
if rowResponse.Code != http.StatusOK || !strings.Contains(rowBody, "plant_task_active") {
t.Fatalf("task row fragment: status=%d body=%s", rowResponse.Code, rowResponse.Body.String())
}
viewRequest := taskWebRequest(httptest.NewRequest(http.MethodGet, "/g/3/plant-template-task/9?species_id=7", nil), app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "templateID", Value: "9"}})
viewResponse := httptest.NewRecorder()
app.plantTemplateTaskView(viewResponse, viewRequest)
if viewResponse.Code != http.StatusOK || !strings.Contains(viewResponse.Body.String(), "<dialog") || !strings.Contains(viewResponse.Body.String(), "Seitentriebe entfernen") || strings.Contains(viewResponse.Body.String(), "<form") {
t.Fatalf("template detail fragment: status=%d body=%s", viewResponse.Code, viewResponse.Body.String())
}
}
func TestPlantTaskDialogReturnsCompactEditableRow(t *testing.T) {
app := newAPIBackedTestApplication(t, http.NotFoundHandler())
form := url.Values{"new": {"true"}, "plant_task_row_key": {"task-new-1"}, "plant_task_id": {"0"}, "plant_task_title": {"Anbinden"}, "plant_task_description": {"Locker befestigen"}, "plant_task_priority": {"3"}, "plant_task_active": {"true"}}
request := httptest.NewRequest(http.MethodPost, "/g/3/plant-task-row", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("HX-Request", "true")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plantTaskRowPost(response, request)
body := response.Body.String()
if response.Code != http.StatusOK || response.Header().Get("HX-Trigger") != "plantTaskSaved" || !strings.Contains(body, ">Anbinden</button>") || !strings.Contains(body, "class='switch'") || strings.Contains(body, "<textarea") {
t.Fatalf("task row fragment: status=%d trigger=%q body=%s", response.Code, response.Header().Get("HX-Trigger"), body)
}
}
func TestPlantCreateSavesManualTaskAndTemplateState(t *testing.T) {
var taskInput client.TaskInput
var optedOut bool
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Tomate"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/tasks":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &taskInput); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"task":{"id":12,"garden_id":3,"title":"Anbinden"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7/task-templates":
_, _ = w.Write([]byte(`{"task_templates":[{"id":9,"species_id":7,"title":"Ausgeizen"}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/plants/5/task-template-opt-outs/9":
optedOut = true
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{
"name": {"Tomate"}, "species_id": {"7"}, "status": {"active"},
"plant_task_id": {"0"}, "plant_task_title": {"Anbinden"}, "plant_task_description": {"Locker befestigen"},
"plant_task_due_at_start": {""}, "plant_task_due_at_end": {""}, "plant_task_priority": {"3"}, "plant_task_active": {"true"}, "plant_task_delete": {"false"},
"plant_template_active_9": {"false"},
}
request := httptest.NewRequest(http.MethodPost, "/g/3/plants/new", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}})
response := httptest.NewRecorder()
app.plantCreatePost(response, request)
if response.Code != http.StatusSeeOther || taskInput.Title == nil || *taskInput.Title != "Anbinden" || taskInput.PlantID == nil || *taskInput.PlantID != 5 || taskInput.Active == nil || !*taskInput.Active || !optedOut {
t.Fatalf("status=%d task=%+v optedOut=%v body=%s", response.Code, taskInput, optedOut, response.Body.String())
}
}