607 lines
34 KiB
Go
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)
|
|
}
|
|
}
|