package api import ( "bytes" "net/http" "net/http/httptest" "testing" "gardomatic.kleiax.de/internal/storage" "github.com/julienschmidt/httprouter" ) type speciesTestModel struct { items map[int]storage.Species nextID int lastGardenID int } func (m *speciesTestModel) Insert(species storage.Species) (storage.Species, error) { m.nextID++ species.ID = m.nextID species.Version = 1 m.items[species.ID] = species return species, nil } func (m *speciesTestModel) Get(gardenID, id int) (storage.Species, error) { m.lastGardenID = gardenID species, ok := m.items[id] if !ok || (species.GardenID != nil && *species.GardenID != gardenID) { return storage.Species{}, storage.ErrRecordNotFound } return species, nil } func (m *speciesTestModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { m.lastGardenID = gardenID result := []storage.Species{} for _, species := range m.items { if species.GardenID == nil || *species.GardenID == gardenID { result = append(result, species) } } return result, nil } func (m *speciesTestModel) Update(gardenID int, species storage.Species) (storage.Species, error) { m.lastGardenID = gardenID species.Version++ m.items[species.ID] = species return species, nil } func (m *speciesTestModel) Delete(gardenID, id int) error { species, ok := m.items[id] if !ok || species.GardenID == nil && gardenID != 0 || species.GardenID != nil && *species.GardenID != gardenID { return storage.ErrRecordNotFound } m.lastGardenID = gardenID delete(m.items, id) return nil } type plantTestModel struct { items map[int]storage.Plant nextID int lastGardenID int } func (m *plantTestModel) Insert(plant storage.Plant) (storage.Plant, error) { m.nextID++ plant.ID = m.nextID plant.Version = 1 m.items[plant.ID] = plant return plant, nil } func (m *plantTestModel) Get(gardenID, id int) (storage.Plant, error) { m.lastGardenID = gardenID plant, ok := m.items[id] if !ok || plant.GardenID != gardenID { return storage.Plant{}, storage.ErrRecordNotFound } return plant, nil } func (m *plantTestModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) { m.lastGardenID = gardenID result := []storage.Plant{} for _, plant := range m.items { if plant.GardenID == gardenID { result = append(result, plant) } } return result, nil } func (m *plantTestModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) { m.lastGardenID = gardenID plant.Version++ m.items[plant.ID] = plant return plant, nil } func (m *plantTestModel) Delete(gardenID, id int) error { plant, ok := m.items[id] if !ok || plant.GardenID != gardenID { return storage.ErrRecordNotFound } delete(m.items, id) return nil } func serveResourceRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { router := httprouter.New() protect := func(handler http.HandlerFunc) http.HandlerFunc { return app.requireActivatedUser(app.requireGardenMember(handler)) } router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", protect(app.createSpeciesHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", protect(app.listSpeciesHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", protect(app.showSpeciesHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", protect(app.updateSpeciesHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", protect(app.deleteSpeciesHandler)) router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.createSpeciesTaskTemplateHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.listSpeciesTaskTemplatesHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.showSpeciesTaskTemplateHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.updateSpeciesTaskTemplateHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.deleteSpeciesTaskTemplateHandler)) router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", protect(app.createPlantHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", protect(app.listPlantsHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", protect(app.showPlantHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", protect(app.updatePlantHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", protect(app.deletePlantHandler)) router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", protect(app.createLocationHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", protect(app.listLocationsHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", protect(app.showLocationHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", protect(app.updateLocationHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", protect(app.deleteLocationHandler)) router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", protect(app.createTaskHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", protect(app.listTasksHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", protect(app.showTaskHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", protect(app.updateTaskHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", protect(app.deleteTaskHandler)) router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.createPlantLocationHandler)) router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.listPlantLocationsHandler)) router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.updatePlantLocationHandler)) router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.deletePlantLocationHandler)) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.ServeHTTP(w, app.contextSetAuthenticatedUser(r, user)) }) 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 TestSpeciesAndPlantsAreScopedToGarden(t *testing.T) { app, _, members := newGardenTestApplication() user := storage.User{ID: 5, Activated: true} members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin} speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} plantsModel := &plantTestModel{items: make(map[int]storage.Plant), nextID: 20} app.models.Species = speciesModel app.models.Plants = plantsModel speciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate"}`)) if speciesResponse.Code != http.StatusCreated { t.Fatalf("create species status: got %d, want %d; body: %s", speciesResponse.Code, http.StatusCreated, speciesResponse.Body.String()) } createdSpecies := speciesModel.items[11] if createdSpecies.GardenID == nil || *createdSpecies.GardenID != 3 { t.Errorf("species garden: got %v, want 3", createdSpecies.GardenID) } plantResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":11,"name":"Tomate am Zaun"}`)) if plantResponse.Code != http.StatusCreated { t.Fatalf("create plant status: got %d, want %d; body: %s", plantResponse.Code, http.StatusCreated, plantResponse.Body.String()) } if got := plantsModel.items[21].GardenID; got != 3 { t.Errorf("plant garden: got %d, want 3", got) } if speciesModel.lastGardenID != 3 { t.Errorf("species lookup garden: got %d, want 3", speciesModel.lastGardenID) } clearSpecies := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/plants/21", []byte(`{"clear_species_id":true,"clear_acquired_at":true}`)) if clearSpecies.Code != http.StatusOK || plantsModel.items[21].SpeciesID != nil { t.Fatalf("clear plant species: status=%d body=%s", clearSpecies.Code, clearSpecies.Body.String()) } foreignGardenID := 4 speciesModel.items[99] = storage.Species{ID: 99, GardenID: &foreignGardenID, CommonName: "Fremd"} foreignSpeciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":99,"name":"Nicht erlaubt"}`)) if foreignSpeciesResponse.Code != http.StatusUnprocessableEntity { t.Fatalf("foreign species status: got %d, want %d; body: %s", foreignSpeciesResponse.Code, http.StatusUnprocessableEntity, foreignSpeciesResponse.Body.String()) } } func TestGlobalSpeciesRequiresApplicationPermission(t *testing.T) { app, _, members := newGardenTestApplication() user := storage.User{ID: 6, Activated: true} members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} speciesModel := &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global", Version: 1}}} app.models.Species = speciesModel showResponse := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/1", nil) if showResponse.Code != http.StatusOK { t.Fatalf("show global species: got %d, want %d", showResponse.Code, http.StatusOK) } updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/1", []byte(`{"common_name":"GeƤndert"}`)) if updateResponse.Code != http.StatusForbidden { t.Fatalf("update global species: got %d, want %d", updateResponse.Code, http.StatusForbidden) } deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/1", nil) if deleteResponse.Code != http.StatusForbidden { t.Fatalf("delete global species: got %d, want %d", deleteResponse.Code, http.StatusForbidden) } } func TestAdminCanCreateAndUpdateGlobalSpeciesWithoutGardenWriteRole(t *testing.T) { app, _, members := newGardenTestApplication() user := storage.User{ID: 7, Activated: true, Role: storage.ApplicationRoleAdmin} members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleViewer} speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} app.models.Species = speciesModel createResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate","global":true}`)) if createResponse.Code != http.StatusCreated { t.Fatalf("create global species: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) } if speciesModel.items[11].GardenID != nil { t.Fatalf("global species has garden id %v", speciesModel.items[11].GardenID) } updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/11", []byte(`{"common_name":"Rispentomate"}`)) if updateResponse.Code != http.StatusOK { t.Fatalf("update global species: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) } if got := speciesModel.lastGardenID; got != 0 { t.Fatalf("global update scope: got %d, want 0", got) } deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/11", nil) if deleteResponse.Code != http.StatusNoContent { t.Fatalf("delete global species: got %d, want %d; body: %s", deleteResponse.Code, http.StatusNoContent, deleteResponse.Body.String()) } if got := speciesModel.lastGardenID; got != 0 { t.Fatalf("global delete scope: got %d, want 0", got) } }