Files
Gardomatic/internal/web/templates_test.go
T
2026-09-16 20:21:41 +02:00

607 lines
34 KiB
Go

package web
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gardomatic.kleiax.de/lib/client"
)
func TestHumanDate(t *testing.T) {
tests := []struct {
name string
tm time.Time
want string
}{
{
name: "UTC",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
want: "17 Mar 2022 at 10:15",
},
{
name: "Empty",
tm: time.Time{},
want: "",
},
{
name: "CET",
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
want: "17 Mar 2022 at 09:15",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hd := humanDate(tt.tm)
if hd != tt.want {
t.Errorf("humanDate(): got %q, want %q", hd, tt.want)
}
})
}
}
func TestGlobalSpeciesControlsArePermissionAware(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
for _, test := range []struct {
name string
user client.User
wantGlobal bool
wantAdmin bool
}{
{"admin", client.User{ID: 1, Activated: true, Role: "application:admin", Permissions: []string{"global_species:write", "roles:manage"}}, true, true},
{"user", client.User{ID: 2, Activated: true, Role: "application:user"}, false, false},
} {
t.Run(test.name, func(t *testing.T) {
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &test.user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
body := response.Body.String()
if strings.Contains(body, "name='global'") != test.wantGlobal {
t.Errorf("global checkbox visibility mismatch: %s", body)
}
if strings.Contains(body, "href='/admin") != test.wantAdmin {
t.Errorf("admin menu visibility mismatch: %s", body)
}
})
}
}
func TestSpeciesCardsAndReadOnlyGlobalDetails(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 2, Activated: true, Role: "application:user"}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
species := client.Species{ID: 7, CommonName: "Tomate", Cultivar: "Roma"}
listData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}, speciesTemplateData: speciesTemplateData{Species: []client.Species{species}}}
listResponse := httptest.NewRecorder()
app.render(listResponse, http.StatusOK, "species.tmpl", listData)
listBody := listResponse.Body.String()
if !strings.Contains(listBody, "data-card-href='/g/3/species/edit/7'") || strings.Contains(listBody, ">Bearbeiten</a>") {
t.Fatalf("species card is not exclusively clickable: %s", listBody)
}
generalData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Global: true, CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "general", TaskTemplates: map[int][]client.SpeciesTaskTemplate{7: {{ID: 9, SpeciesID: 7, Title: "Ausgeizen"}}}}}
generalResponse := httptest.NewRecorder()
app.render(generalResponse, http.StatusOK, "species_form.tmpl", generalData)
if body := generalResponse.Body.String(); !strings.Contains(body, "Diese Art ist nur lesbar") || !strings.Contains(body, "disabled") || strings.Contains(body, "Art löschen") {
t.Fatalf("global species general details are not read-only: %s", body)
}
tasksData := &templateData{commonTemplateData: generalData.commonTemplateData, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "tasks", TaskTemplates: map[int][]client.SpeciesTaskTemplate{7: {{ID: 9, SpeciesID: 7, Title: "Ausgeizen"}}}}}
tasksResponse := httptest.NewRecorder()
app.render(tasksResponse, http.StatusOK, "species_form.tmpl", tasksData)
if body := tasksResponse.Body.String(); !strings.Contains(body, "Ausgeizen") || !strings.Contains(body, "Bearbeitungsbereiche") || strings.Contains(body, "Aufgabenvorlage hinzufügen") {
t.Fatalf("global species task templates are not read-only: %s", body)
}
}
func TestEditableSpeciesDetailOffersDelete(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true, Role: "application:admin", Permissions: []string{"global_species:write", "roles:manage"}}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "viewer"}
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Global: true, CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
if body := response.Body.String(); !strings.Contains(body, "formaction='/g/3/species/delete/7'") || !strings.Contains(body, "Art löschen") || strings.Contains(body, "class='delete-form'") {
t.Fatalf("delete action missing from editable species: %s", body)
}
}
func TestGardenContextIsKeptInAccountAndSettingsLinks(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Alice", Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "settings.tmpl", data)
body := response.Body.String()
for _, want := range []string{"href='/account?garden=3'", "href='/settings?garden=3'", "href='/g/3'>Hinterhof</a>", "name='journalEditor'", "Einfaches Textfeld"} {
if !strings.Contains(body, want) {
t.Errorf("garden-aware navigation is missing %q: %s", want, body)
}
}
}
func TestProtectedDemoAccountHidesAccountMutationForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Demo", Email: "demo@example.com", Activated: true}
data := &templateData{
commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Form: accountForm{Errors: map[string]string{}}},
accountTemplateData: accountTemplateData{AccountProtected: true},
}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "account.tmpl", data)
body := response.Body.String()
if !strings.Contains(body, "Gemeinsames Demokonto") {
t.Fatalf("protected account notice is missing: %s", body)
}
for _, forbidden := range []string{"action='/account/profile'", "action='/account/password'", "action='/account/email'", "Widerrufen"} {
if strings.Contains(body, forbidden) {
t.Errorf("protected account still offers %q: %s", forbidden, body)
}
}
}
func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
editor := globalRoleEditor("roles-settings", "Instanzrollen", "Instanz", "application", nil, applicationPermissionOptions(), "token")
editor.Garden = &garden
data := &templateData{
commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden},
adminTemplateData: adminTemplateData{ApplicationSettings: &client.ApplicationSettings{}, RoleEditors: []roleEditorData{editor}},
}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "admin.tmpl", data)
body := response.Body.String()
for _, want := range []string{"href='/admin?garden=3'", "action='/admin/application-settings?garden=3'", "action='/admin/user-invite?garden=3'", "action='/admin/roles/new?garden=3'"} {
if !strings.Contains(body, want) {
t.Errorf("admin page does not retain garden context in %q: %s", want, body)
}
}
}
func TestFooterAndPublicInformationPages(t *testing.T) {
app := newTestApplication(t)
health := client.Health{Status: "available", ServerTime: time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC), SystemInfo: client.SystemInfo{Environment: "production", Version: "v1.2.3"}}
data := &templateData{commonTemplateData: commonTemplateData{CurrentYear: 2026, Health: &health, SystemTime: time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local), WebVersion: "v1.2.3"}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "healthcheck.tmpl", data)
body := response.Body.String()
for _, want := range []string{"Systemstatus", "Serverzeit", "Systemzeit", "production", "v1.2.3", "https://git.kleiax.de/kleiax/Gardomatic", "https://kleiax.de", "href='/datenschutz'"} {
if !strings.Contains(body, want) {
t.Errorf("health page or footer is missing %q: %s", want, body)
}
}
response = httptest.NewRecorder()
app.render(response, http.StatusOK, "privacy.tmpl", &templateData{})
if body = response.Body.String(); !strings.Contains(body, "Verarbeitete Daten") || !strings.Contains(body, "Verantwortlicher") {
t.Fatalf("privacy page is incomplete: %s", body)
}
}
func TestGardenCreationControlRequiresPermission(t *testing.T) {
app := newTestApplication(t)
for _, test := range []struct {
name string
user client.User
want bool
}{
{"allowed", client.User{Permissions: []string{"gardens:create"}}, true},
{"denied", client.User{Permissions: []string{}}, false},
} {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "gardens.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &test.user, Filters: map[string]string{}}})
if got := strings.Contains(response.Body.String(), "href='/gardens/new'"); got != test.want {
t.Fatalf("garden creation link visibility = %v, want %v", got, test.want)
}
})
}
}
func TestJournalFormOffersNativeCaptureAndCameraSwitching(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Form: journalForm{Errors: map[string]string{}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "journal_form.tmpl", data)
body := response.Body.String()
for _, want := range []string{"Foto mit Gerät aufnehmen", "data-journal-generated-files", "data-camera-switch", "data-video-switch-camera"} {
if !strings.Contains(body, want) {
t.Errorf("mobile journal form is missing %q: %s", want, body)
}
}
if strings.Contains(body, "capture='environment'") {
t.Errorf("journal form still contains the redundant native camera picker: %s", body)
}
}
func TestServerErrorRendersFriendlyPage(t *testing.T) {
app := newTestApplication(t)
response := httptest.NewRecorder()
app.serverError(response, errors.New("database unavailable"))
if response.Code != http.StatusInternalServerError {
t.Fatalf("status: got %d, want %d", response.Code, http.StatusInternalServerError)
}
body := response.Body.String()
if !strings.Contains(body, "Etwas ist schiefgelaufen") || strings.Contains(body, "database unavailable") {
t.Fatalf("friendly error page missing or leaks internal details: %s", body)
}
}
func TestSpeciesFormUsesCategoryDropdown(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{CategoryID: 2, Errors: map[string]string{}}}, adminTemplateData: adminTemplateData{SpeciesCategories: []client.SpeciesCategory{{ID: 1, Name: "Gemüse", Active: true}, {ID: 2, Name: "Alt", Active: false}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
body := response.Body.String()
if !strings.Contains(body, "<select id='category_id' name='category_id'>") || !strings.Contains(body, "value='2' selected>Alt (inaktiv)</option>") || strings.Contains(body, "<input name='category'") {
t.Fatalf("category dropdown missing: %s", body)
}
}
func TestSpeciesFormShowsCategoryLifecycle(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
lifecycle := "perennial"
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}, adminTemplateData: adminTemplateData{SpeciesCategories: []client.SpeciesCategory{{ID: 1, Name: "Kräuter", Active: true, Lifecycle: &lifecycle}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
if !strings.Contains(response.Body.String(), "Kräuter (Mehrjährig)") {
t.Fatalf("category lifecycle missing: %s", response.Body.String())
}
}
func TestSpeciesCreationWizardAndEditNavigation(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
create := httptest.NewRecorder()
app.render(create, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}})
if body := create.Body.String(); !strings.Contains(body, "name='continue' value='care'>Speichern und weiter") || !strings.Contains(body, "Speichern und beenden") {
t.Fatalf("species creation does not offer the wizard: %s", body)
}
care := httptest.NewRecorder()
app.render(care, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "care", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
if body := care.Body.String(); !strings.Contains(body, "Pflegeanweisungen") || !strings.Contains(body, "step=tasks") || !strings.Contains(body, "Überspringen und beenden") {
t.Fatalf("care wizard step is incomplete: %s", body)
}
tasks := httptest.NewRecorder()
app.render(tasks, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "tasks", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
if body := tasks.Body.String(); !strings.Contains(body, "Aufgabenvorlagen") || !strings.Contains(body, ">Fertig</a>") || !strings.Contains(body, "Bearbeitungsbereiche") {
t.Fatalf("task-template wizard step is incomplete: %s", body)
}
normalEdit := httptest.NewRecorder()
app.render(normalEdit, http.StatusOK, "species_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: speciesForm{CommonName: "Tomate", Errors: map[string]string{}}}, speciesTemplateData: speciesTemplateData{SpeciesID: 7, WizardStep: "all", TaskTemplates: map[int][]client.SpeciesTaskTemplate{}}})
if body := normalEdit.Body.String(); !strings.Contains(body, "id='species-general'") || !strings.Contains(body, "id='species-care'") || !strings.Contains(body, "id='species-tasks'") || !strings.Contains(body, "href='#species-care'") || strings.Contains(body, "Weiter zu Aufgabenvorlagen") {
t.Fatalf("normal species edit does not load all navigable sections: %s", body)
}
}
func TestPlantAndLocationEditFormsHaveSectionNavigation(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
plant := httptest.NewRecorder()
app.render(plant, http.StatusOK, "plant_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: plantForm{Errors: map[string]string{}}}, plantTemplateData: plantTemplateData{PlantID: 7}, adminTemplateData: adminTemplateData{}, speciesTemplateData: speciesTemplateData{TaskTemplates: map[int][]client.SpeciesTaskTemplate{}, TemplateOptOuts: map[int]bool{}}})
if body := plant.Body.String(); !strings.Contains(body, "Bearbeitungsbereiche") || !strings.Contains(body, "href='#plant-general'") || !strings.Contains(body, "href='#plant-tasks'") {
t.Fatalf("plant edit navigation is missing: %s", body)
}
location := httptest.NewRecorder()
app.render(location, http.StatusOK, "location_form.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Form: locationForm{Errors: map[string]string{}}}, locationTemplateData: locationTemplateData{LocationID: 8}})
if body := location.Body.String(); !strings.Contains(body, "href='#location-general'") || !strings.Contains(body, "href='#location-plants'") || !strings.Contains(body, "href='#location-history'") {
t.Fatalf("location edit navigation is missing: %s", body)
}
}
func TestImageEditorRestoresSavedImageThroughJavaScript(t *testing.T) {
app := newTestApplication(t)
imageData := "data:image/jpeg;base64,/9j/test"
data := &templateData{commonTemplateData: commonTemplateData{Form: gardenForm{Name: "Garten", ImageData: imageData, Errors: map[string]string{}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "garden_new.tmpl", data)
body := response.Body.String()
if !strings.Contains(body, "name='image_data' value='"+imageData+"'") {
t.Fatalf("saved image data missing from editor: %s", body)
}
if strings.Contains(body, "#ZgotmplZ") || strings.Contains(body, "background-image:url") {
t.Fatalf("image preview must be initialized by JavaScript, not an unsafe inline URL: %s", body)
}
}
func TestGardenPresentationIsLimitedToHeaderAndDashboard(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Description: "Sonniger Stadtgarten", ImageData: "data:image/jpeg;base64,/9j/test", Role: "owner"}
dashboardResponse := httptest.NewRecorder()
app.render(dashboardResponse, http.StatusOK, "dashboard.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}})
dashboardBody := dashboardResponse.Body.String()
for _, want := range []string{"class='site-header-garden' href='/g/3'>Hinterhof</a>", "garden-summary card--image", "data-background-image='" + garden.ImageData + "'", ">Sonniger Stadtgarten</p>", "href='/g/3/tasks/calendar'>Kalender</a>", "href='/gardens/edit/3'>Bearbeiten</a>"} {
if !strings.Contains(dashboardBody, want) {
t.Errorf("garden dashboard is missing %q: %s", want, dashboardBody)
}
}
viewerGarden := garden
viewerGarden.Role = "viewer"
viewerResponse := httptest.NewRecorder()
app.render(viewerResponse, http.StatusOK, "dashboard.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &viewerGarden}})
if strings.Contains(viewerResponse.Body.String(), "/gardens/edit/3") {
t.Fatalf("read-only garden dashboard contains edit link: %s", viewerResponse.Body.String())
}
plantsResponse := httptest.NewRecorder()
app.render(plantsResponse, http.StatusOK, "plant.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden}})
plantsBody := plantsResponse.Body.String()
if strings.Count(plantsBody, garden.Name) != 1 || strings.Contains(plantsBody, garden.Description) {
t.Fatalf("garden details should only appear once in the header away from the dashboard: %s", plantsBody)
}
gardensResponse := httptest.NewRecorder()
app.render(gardensResponse, http.StatusOK, "gardens.tmpl", &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user}, gardenTemplateData: gardenTemplateData{Gardens: []client.Garden{garden}}})
if gardensBody := gardensResponse.Body.String(); strings.Contains(gardensBody, "/gardens/edit/3") {
t.Fatalf("garden list still contains edit link: %s", gardensBody)
}
}
func TestEmptyOptionalSpeciesDimensionsDoNotBlockSubmit(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Activated: true}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
data := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: speciesForm{Errors: map[string]string{}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
body := response.Body.String()
for _, field := range []string{"spacing_cm", "height_cm_from", "height_cm_to", "width_cm_from", "width_cm_to"} {
fieldStart := strings.Index(body, "name='"+field+"'")
fieldEnd := -1
if fieldStart >= 0 {
fieldEnd = strings.Index(body[fieldStart:], ">")
}
if fieldStart < 0 || fieldEnd < 0 || !strings.Contains(body[fieldStart:fieldStart+fieldEnd], "value=''") {
t.Errorf("optional field %s should render empty: %s", field, body)
}
}
if got := strings.Count(body, "class='dimension-range'"); got != 2 {
t.Errorf("dimension ranges: got %d composite controls, want 2: %s", got, body)
}
}
func TestTaskDueFormatsWindows(t *testing.T) {
start := time.Date(2026, 9, 2, 8, 0, 0, 0, time.Local)
end := time.Date(2026, 9, 3, 18, 0, 0, 0, time.Local)
if got := taskDue(client.Task{DueAtStart: &start, DueAtEnd: &end}); got != "02.09.2026 08:00 - 03.09.2026 18:00" {
t.Errorf("taskDue: %q", got)
}
if got := taskDue(client.Task{}); got != "Ohne Fälligkeit" {
t.Errorf("taskDue empty: %q", got)
}
if got := taskDueDate(client.Task{DueAtStart: &start, DueAtEnd: &end}); got != "02.09.2026 - 03.09.2026" {
t.Errorf("taskDueDate: %q", got)
}
}
func TestDashboardTaskCardIsClickableAndDateOnly(t *testing.T) {
app := newTestApplication(t)
due := time.Date(2026, 9, 2, 8, 30, 0, 0, time.Local)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, taskTemplateData: taskTemplateData{Tasks: []client.Task{{ID: 7, GardenID: 3, Title: "Tomaten gießen", DueAtStart: &due}}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "dashboard.tmpl", data)
body := response.Body.String()
for _, want := range []string{"data-card-href='/g/3/tasks/edit/7'", ">ab 02.09.2026</strong>"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard task is missing %q: %s", want, body)
}
}
if strings.Contains(body, "08:30") || strings.Contains(body, ">Öffnen</a>") {
t.Fatalf("dashboard task still renders time or open link: %s", body)
}
}
func TestTaskPaginationOnlyAppearsForMultiplePages(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
base := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{"status": "open"}}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "tasks.tmpl", base)
if strings.Contains(response.Body.String(), "class='pagination'") {
t.Fatalf("single-page task list contains pagination: %s", response.Body.String())
}
base.Pagination = &paginationData{Page: 1, TotalPages: 2, NextURL: "/g/3/tasks?page=2&status=open"}
response = httptest.NewRecorder()
app.render(response, http.StatusOK, "tasks.tmpl", base)
body := response.Body.String()
if !strings.Contains(body, "class='pagination'") || !strings.Contains(body, "Seite 1 von 2") {
t.Fatalf("multi-page task list is missing pagination: %s", body)
}
}
func TestSettingsOfferCollectionPageSizes(t *testing.T) {
app := newTestApplication(t)
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "settings.tmpl", &templateData{})
body := response.Body.String()
for _, want := range []string{"name='entriesPerPage'", "Einträge pro Seite", "href='#list-settings'", "href='#view-settings'", "value='10'", "value='20'", "value='50'", "value='100'"} {
if !strings.Contains(body, want) {
t.Errorf("settings are missing %q: %s", want, body)
}
}
}
func TestAdminSettingsOfferSectionNavigation(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Role: "application:admin", Permissions: []string{"roles:manage"}}
response := httptest.NewRecorder()
applicationRoles := []client.Role{{Name: "application:admin", Scope: "application", Label: "Administrator", Permissions: []string{"roles:manage"}}}
gardenRoles := []client.Role{{Name: "worker", Scope: "garden", Label: "Mitarbeiter", Permissions: []string{"garden:read"}}}
applicationPermissions, gardenPermissions := applicationPermissionOptions(), gardenPermissionOptions()
app.render(response, http.StatusOK, "admin.tmpl", &templateData{commonTemplateData: commonTemplateData{CurrentUser: &user}, adminTemplateData: adminTemplateData{AdminUsers: []client.User{{ID: 2, Name: "Ada", Email: "ada@example.com", Role: "application:user"}}, ApplicationSettings: &client.ApplicationSettings{}, ApplicationRoles: applicationRoles, GardenRoles: gardenRoles, ApplicationPermissions: applicationPermissions, GardenPermissions: gardenPermissions, EnvironmentVariables: []client.EnvironmentVariable{{Component: "API", Name: "GARDOMATIC_SMTP_PASSWORD", Value: "•••••••• (gesetzt)"}}, RoleEditors: []roleEditorData{globalRoleEditor("roles-settings", "Instanzrollen", "Instanz", "application", applicationRoles, applicationPermissions, ""), globalRoleEditor("garden-role-templates", "Gartenrollen", "Garten", "garden", gardenRoles, gardenPermissions, "")}}})
body := response.Body.String()
for _, want := range []string{"class='settings-layout'", "href='#lifecycle-settings'", "href='#mail-settings'", "href='#environment-settings'", "href='#users-settings'", "href='#roles-settings'", "href='#species-categories'", "href='#task-priorities'", "id='users-settings'", "action='/admin/user-invite'", "Name<input id='invite-user-name'", "E-Mail-Adresse<input id='invite-user-email'", "Nutzer einladen", "name='email'", "Testmail senden", "GARDOMATIC_SMTP_PASSWORD", "•••••••• (gesetzt)", "Instanzrollen", "Gartenrollen", "Mitarbeiter", "data-role-select", "Nutzer löschen", "Endgültig löschen", ">Neu</option>"} {
if !strings.Contains(body, want) {
t.Errorf("admin settings are missing %q: %s", want, body)
}
}
}
func TestInvitationActivationOffersInitialPasswordFields(t *testing.T) {
app := newTestApplication(t)
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "activate.tmpl", &templateData{commonTemplateData: commonTemplateData{Form: activationForm{Token: "invite-token", SetPassword: true, Errors: map[string]string{}}}})
body := response.Body.String()
for _, want := range []string{"name='set_password'", "name='password'", "name='password_confirm'", "Passwort festlegen"} {
if !strings.Contains(body, want) {
t.Errorf("invitation activation is missing %q: %s", want, body)
}
}
}
func TestCollectionPagesUseSharedPagination(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
user := client.User{ID: 1, Activated: true}
for _, test := range []struct {
name string
data *templateData
}{
{"gardens.tmpl", &templateData{}},
{"plant.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}, plantTemplateData: plantTemplateData{PlantLocations: map[int][]client.PlantLocation{}}, locationTemplateData: locationTemplateData{LocationNames: map[int]string{}}}},
{"species.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, CurrentUser: &user}}},
{"locations.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}}},
{"tasks.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{}}}},
{"journal.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden}}},
{"images.tmpl", &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Filters: map[string]string{}}}},
} {
t.Run(test.name, func(t *testing.T) {
test.data.Pagination = &paginationData{Page: 1, TotalPages: 2, NextURL: "/next?page=2"}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, test.name, test.data)
if body := response.Body.String(); !strings.Contains(body, "class='pagination'") || !strings.Contains(body, "href='/next?page=2'") {
t.Fatalf("page does not render shared pagination: %s", body)
}
})
}
}
func TestTaskCardsAndEditDeleteAction(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 2, Activated: true, Role: "application:user"}
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "member"}
task := client.Task{ID: 7, GardenID: 3, Title: "Tomaten gießen", CreatedBy: user.ID}
listData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Filters: map[string]string{}}, taskTemplateData: taskTemplateData{Tasks: []client.Task{task}}}
listResponse := httptest.NewRecorder()
app.render(listResponse, http.StatusOK, "tasks.tmpl", listData)
listBody := listResponse.Body.String()
for _, want := range []string{"data-card-href='/g/3/tasks/edit/7'", "task-card-actions", ">Erledigt</button>"} {
if !strings.Contains(listBody, want) {
t.Fatalf("task card is missing %q: %s", want, listBody)
}
}
for _, unwanted := range []string{">Bearbeiten</a>", "action='/g/3/tasks/delete/7'", ">Erledigen</button>"} {
if strings.Contains(listBody, unwanted) {
t.Fatalf("task card still contains %q: %s", unwanted, listBody)
}
}
formData := &templateData{commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Garden: &garden, Form: taskForm{Title: task.Title, Errors: map[string]string{}}}, taskTemplateData: taskTemplateData{TaskID: 7, TaskCreatedBy: user.ID}}
formResponse := httptest.NewRecorder()
app.render(formResponse, http.StatusOK, "task_form.tmpl", formData)
formBody := formResponse.Body.String()
if !strings.Contains(formBody, "action='/g/3/tasks/delete/7'") || !strings.Contains(formBody, ">Aufgabe löschen</button>") {
t.Fatalf("delete action missing from task edit page: %s", formBody)
}
}
func TestCustomGardenPermissionsControlResourceActions(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 2, Activated: true}
garden := client.Garden{
ID: 3,
Name: "Hinterhof",
Role: "garden:custom",
Permissions: []string{"plants:create", "plants:update:own", "tasks:complete:other"},
}
plantsResponse := httptest.NewRecorder()
app.render(plantsResponse, http.StatusOK, "plant.tmpl", &templateData{
commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Filters: map[string]string{}},
plantTemplateData: plantTemplateData{
Plants: []client.Plant{{ID: 5, GardenID: 3, Name: "Eigene", CreatedBy: user.ID}, {ID: 6, GardenID: 3, Name: "Fremde", CreatedBy: 9}},
PlantLocations: map[int][]client.PlantLocation{},
},
locationTemplateData: locationTemplateData{LocationNames: map[int]string{}},
})
plantsBody := plantsResponse.Body.String()
for _, want := range []string{"href='/g/3/plants/new'", "action='/g/3/plants/status/5'"} {
if !strings.Contains(plantsBody, want) {
t.Errorf("permitted plant action is missing %q", want)
}
}
if strings.Contains(plantsBody, "action='/g/3/plants/status/6'") {
t.Error("update action for another user's plant was shown")
}
tasksResponse := httptest.NewRecorder()
app.render(tasksResponse, http.StatusOK, "tasks.tmpl", &templateData{
commonTemplateData: commonTemplateData{CurrentUser: &user, Garden: &garden, Filters: map[string]string{}},
taskTemplateData: taskTemplateData{Tasks: []client.Task{
{ID: 7, GardenID: 3, Title: "Eigene", CreatedBy: user.ID},
{ID: 8, GardenID: 3, Title: "Fremde", CreatedBy: 9},
}},
})
tasksBody := tasksResponse.Body.String()
if strings.Contains(tasksBody, "href='/g/3/tasks/new'") || strings.Contains(tasksBody, "data-card-href='/g/3/tasks/edit/") {
t.Error("task create or update action was shown without permission")
}
if strings.Contains(tasksBody, "action='/g/3/tasks/complete/7'") || !strings.Contains(tasksBody, "action='/g/3/tasks/complete/8'") {
t.Error("own/other task completion permissions were not distinguished")
}
}
func TestTaskTemplateFormAdaptsToTriggerType(t *testing.T) {
app := newTestApplication(t)
garden := client.Garden{ID: 3, Name: "Hinterhof", Role: "owner"}
species := client.Species{ID: 7, CommonName: "Tomate"}
priorities := []client.TaskPriority{{ID: 1, Name: "Dringend", Value: 8, Active: true}}
data := &templateData{commonTemplateData: commonTemplateData{Garden: &garden, Form: taskTemplateForm{TriggerType: "month_of_year", DayFrom: 1, TriggerOffsetUnit: "day", DurationUnit: "week", RecurrenceInterval: 2, Priority: 8, Errors: map[string]string{}}}, adminTemplateData: adminTemplateData{TaskPriorities: priorities}, speciesTemplateData: speciesTemplateData{Species: []client.Species{species}, SpeciesID: 7}}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "task_template_form.tmpl", data)
body := response.Body.String()
for _, want := range []string{"<label for='template-trigger-type'>Typ</label>", ">Datum</option>", "<legend>Ab</legend>", "name='day_from' value='1'", "<legend>Nach</legend>", "<legend>Bis in</legend>", "data-trigger-fields='relative' hidden disabled", ">Wochen</option>", ">Dringend</option>", "Die nächste Aufgabe wird erst beim Erledigen angelegt."} {
if !strings.Contains(body, want) {
t.Errorf("task template form is missing %q: %s", want, body)
}
}
for _, unwanted := range []string{"Jährlicher Zeitraum", ">Auslöser</label>", "id='template-title' name='title' value='' required", "Startdatum", "Beginn relativ zum Typ", "Aufgabenzeitraum", "Nach letzter Erledigung"} {
if strings.Contains(body, unwanted) {
t.Errorf("task template form still contains %q: %s", unwanted, body)
}
}
if strings.Index(body, "<label>Tag<input") > strings.Index(body, "<label>Monat<select") {
t.Errorf("day field must be rendered before month field: %s", body)
}
}