276 lines
10 KiB
Go
276 lines
10 KiB
Go
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())
|
|
}
|
|
}
|