package api import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "testing" "time" "gardomatic.kleiax.de/internal/storage" "github.com/julienschmidt/httprouter" ) type gardenTestModel struct { gardens map[int]storage.Garden nextID int insertOwner int listUserID int getCallCount int } func (m *gardenTestModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { m.nextID++ garden.ID = m.nextID garden.Version = 1 garden.CreatedAt = time.Now() garden.UpdatedAt = garden.CreatedAt m.gardens[garden.ID] = garden m.insertOwner = ownerID return garden, nil } func (m *gardenTestModel) Get(id int) (storage.Garden, error) { m.getCallCount++ garden, ok := m.gardens[id] if !ok { return storage.Garden{}, storage.ErrRecordNotFound } return garden, nil } func (m *gardenTestModel) GetAllForUser(userID int) ([]storage.Garden, error) { m.listUserID = userID gardens := make([]storage.Garden, 0, len(m.gardens)) for _, garden := range m.gardens { gardens = append(gardens, garden) } return gardens, nil } func (m *gardenTestModel) Update(garden storage.Garden) (storage.Garden, error) { if _, ok := m.gardens[garden.ID]; !ok { return storage.Garden{}, storage.ErrRecordNotFound } garden.Version++ m.gardens[garden.ID] = garden return garden, nil } func (m *gardenTestModel) Delete(id int) error { if _, ok := m.gardens[id]; !ok { return storage.ErrRecordNotFound } delete(m.gardens, id) return nil } type gardenMemberTestModel struct { members map[[2]int]storage.GardenMember } func (m *gardenMemberTestModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { m.members[[2]int{member.GardenID, member.UserID}] = member return member, nil } func (m *gardenMemberTestModel) Get(gardenID, userID int) (storage.GardenMember, error) { member, ok := m.members[[2]int{gardenID, userID}] if !ok { return storage.GardenMember{}, storage.ErrRecordNotFound } return member, nil } func (m *gardenMemberTestModel) GetAllForGarden(int) ([]storage.GardenMember, error) { return nil, nil } func (m *gardenMemberTestModel) Update(member storage.GardenMember) (storage.GardenMember, error) { return member, nil } func (m *gardenMemberTestModel) Delete(gardenID, userID int) error { delete(m.members, [2]int{gardenID, userID}) return nil } func (m *gardenMemberTestModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { from := m.members[[2]int{gardenID, fromUserID}] to := m.members[[2]int{gardenID, toUserID}] from.Role = storage.GardenRoleAdmin to.Role = storage.GardenRoleOwner m.members[[2]int{gardenID, fromUserID}] = from m.members[[2]int{gardenID, toUserID}] = to return nil } func newGardenTestApplication() (*application, *gardenTestModel, *gardenMemberTestModel) { gardens := &gardenTestModel{gardens: make(map[int]storage.Garden), nextID: 40} members := &gardenMemberTestModel{members: make(map[[2]int]storage.GardenMember)} app := &application{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), models: storage.Models{Gardens: gardens, GardenMembers: members}, } return app, gardens, members } func serveGardenRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { router := httprouter.New() router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler))) router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler))) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.updateGardenHandler))) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.deleteGardenHandler))) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r = app.contextSetAuthenticatedUser(r, user) router.ServeHTTP(w, r) }) request := httptest.NewRequest(method, path, bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response } func TestCreateAndListGardens(t *testing.T) { app, gardens, members := newGardenTestApplication() user := storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}} createResponse := serveGardenRequest(app, user, http.MethodPost, "/v1/gardens", []byte(`{"name":" Hinterhof ","description":" Gemüse "}`)) if createResponse.Code != http.StatusCreated { t.Fatalf("create status: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) } if gardens.insertOwner != user.ID { t.Errorf("owner: got %d, want %d", gardens.insertOwner, user.ID) } if got := createResponse.Header().Get("Location"); got != "/v1/gardens/41" { t.Errorf("Location: got %q, want %q", got, "/v1/gardens/41") } var createEnvelope struct { Garden storage.Garden `json:"garden"` } if err := json.Unmarshal(createResponse.Body.Bytes(), &createEnvelope); err != nil { t.Fatal(err) } if createEnvelope.Garden.Name != "Hinterhof" || createEnvelope.Garden.Description != "Gemüse" { t.Errorf("created garden was not normalized: %+v", createEnvelope.Garden) } members.members[[2]int{createEnvelope.Garden.ID, user.ID}] = storage.GardenMember{GardenID: createEnvelope.Garden.ID, UserID: user.ID, Role: storage.GardenRoleOwner} listResponse := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) if listResponse.Code != http.StatusOK { t.Fatalf("list status: got %d, want %d", listResponse.Code, http.StatusOK) } if gardens.listUserID != user.ID { t.Errorf("list user: got %d, want %d", gardens.listUserID, user.ID) } } func TestCreateGardenRequiresApplicationPermission(t *testing.T) { app, gardens, _ := newGardenTestApplication() response := serveGardenRequest(app, storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{}}, http.MethodPost, "/v1/gardens", []byte(`{"name":"Hinterhof"}`)) if response.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusForbidden, response.Body.String()) } if gardens.insertOwner != 0 { t.Fatal("garden was inserted without gardens:create permission") } } func TestGardenMembershipHidesForeignGarden(t *testing.T) { app, gardens, members := newGardenTestApplication() gardens.gardens[12] = storage.Garden{ID: 12, Name: "Geheim", Version: 1} members.members[[2]int{12, 1}] = storage.GardenMember{GardenID: 12, UserID: 1, Role: storage.GardenRoleOwner} memberResponse := serveGardenRequest(app, storage.User{ID: 1, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) if memberResponse.Code != http.StatusOK { t.Fatalf("member status: got %d, want %d", memberResponse.Code, http.StatusOK) } callsBefore := gardens.getCallCount foreignResponse := serveGardenRequest(app, storage.User{ID: 2, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) if foreignResponse.Code != http.StatusNotFound { t.Fatalf("foreign status: got %d, want %d", foreignResponse.Code, http.StatusNotFound) } if gardens.getCallCount != callsBefore { t.Error("garden was loaded even though membership check failed") } } func TestGardenResponsesIncludeEffectiveMembershipPermissions(t *testing.T) { app, gardens, members := newGardenTestApplication() user := storage.User{ID: 4, Activated: true} gardens.gardens[12] = storage.Garden{ID: 12, Name: "Gemeinschaftsgarten", Version: 1} members.members[[2]int{12, user.ID}] = storage.GardenMember{ GardenID: 12, UserID: user.ID, Role: "garden:custom", Permissions: []storage.GardenPermission{storage.GardenPermissionPlantCreate, storage.GardenPermissionTaskCompleteOther}, } response := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens/12", nil) if response.Code != http.StatusOK { t.Fatalf("show status: got %d, want %d", response.Code, http.StatusOK) } var showEnvelope struct { Garden storage.Garden `json:"garden"` } if err := json.Unmarshal(response.Body.Bytes(), &showEnvelope); err != nil { t.Fatal(err) } if got := showEnvelope.Garden.Permissions; len(got) != 2 || got[0] != storage.GardenPermissionPlantCreate || got[1] != storage.GardenPermissionTaskCompleteOther { t.Fatalf("show permissions: got %v", got) } response = serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) if response.Code != http.StatusOK { t.Fatalf("list status: got %d, want %d", response.Code, http.StatusOK) } var listEnvelope struct { Gardens []storage.Garden `json:"gardens"` } if err := json.Unmarshal(response.Body.Bytes(), &listEnvelope); err != nil { t.Fatal(err) } if len(listEnvelope.Gardens) != 1 || len(listEnvelope.Gardens[0].Permissions) != 2 { t.Fatalf("list permissions: got %+v", listEnvelope.Gardens) } } func TestUpdateAndDeleteGarden(t *testing.T) { app, gardens, members := newGardenTestApplication() user := storage.User{ID: 3, Activated: true} gardens.gardens[9] = storage.Garden{ID: 9, Name: "Alt", Description: "Alt", Version: 1} members.members[[2]int{9, user.ID}] = storage.GardenMember{GardenID: 9, UserID: user.ID, Role: storage.GardenRoleMember} updateResponse := serveGardenRequest(app, user, http.MethodPatch, "/v1/gardens/9", []byte(`{"name":"Neu"}`)) if updateResponse.Code != http.StatusOK { t.Fatalf("update status: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) } if got := gardens.gardens[9]; got.Name != "Neu" || got.Description != "Alt" || got.Version != 2 { t.Errorf("updated garden: got %+v", got) } deleteResponse := serveGardenRequest(app, user, http.MethodDelete, "/v1/gardens/9", nil) if deleteResponse.Code != http.StatusNoContent { t.Fatalf("delete status: got %d, want %d", deleteResponse.Code, http.StatusNoContent) } if _, exists := gardens.gardens[9]; exists { t.Error("garden still exists after delete") } } func TestCreateGardenValidation(t *testing.T) { app, _, _ := newGardenTestApplication() response := serveGardenRequest(app, storage.User{ID: 1, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}}, http.MethodPost, "/v1/gardens", []byte(`{"name":" "}`)) if response.Code != http.StatusUnprocessableEntity { t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusUnprocessableEntity, response.Body.String()) } }