Files
Gardomatic/internal/api/gardens_test.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

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())
}
}