Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/internal/handler/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package handler
import (
"errors"
"log/slog"
"strings"

"github.com/generate/selfserve/internal/errs"
"github.com/generate/selfserve/internal/httpx"
"github.com/generate/selfserve/internal/models"
storage "github.com/generate/selfserve/internal/service/storage/postgres"
"github.com/generate/selfserve/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
)
Expand Down Expand Up @@ -79,6 +81,55 @@ func (h *GuestsHandler) GetGuest(c *fiber.Ctx) error {
return c.JSON(guest)
}

// SearchGuests godoc
// @Summary Search guests
// @Description Searches active guests with optional floors, group size range, and text search filters
// @Tags guests
// @Accept json
// @Produce json
// @Param X-Hotel-ID header string true "Hotel ID (UUID)"
// @Param request body models.GuestSearchFilter false "Search filters"
// @Success 200 {object} utils.CursorPage[models.GuestListItem]
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/guests/search [post]
func (h *GuestsHandler) SearchGuests(c *fiber.Ctx) error {
hotelID, err := hotelIDFromHeader(c)
if err != nil {
return err
}

filter := new(models.GuestSearchFilter)
if err := c.BodyParser(filter); err != nil {
return errs.InvalidJSON()
}

if filter.GroupSizeMin != nil && filter.GroupSizeMax != nil && *filter.GroupSizeMin > *filter.GroupSizeMax {
return errs.BadRequest("group_size_min must be less than or equal to group_size_max")
}

if filter.Cursor != nil {
cursor := strings.TrimSpace(*filter.Cursor)
if cursor != "" {
if _, err := uuid.Parse(cursor); err != nil {
return errs.BadRequest("cursor must be a valid UUID")
}
}
}

guests, err := h.GuestsRepository.FindGuestWithActiveBooking(c.Context(), hotelID, filter)
if err != nil {
slog.Error("failed to search guests", "hotel_id", hotelID, "error", err)
return errs.InternalServerError()
}

page := utils.BuildCursorPage(guests, filter.Limit, func(g *models.GuestListItem) string {
return g.GuestID
})

return c.JSON(page)
}

// UpdateGuest godoc
// @Summary Updates a guest
// @Description Updates fields on a guest
Expand Down
276 changes: 273 additions & 3 deletions backend/internal/handler/guests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
Expand All @@ -12,15 +13,17 @@ import (
"github.com/generate/selfserve/internal/errs"
"github.com/generate/selfserve/internal/models"
storage "github.com/generate/selfserve/internal/service/storage/postgres"
"github.com/generate/selfserve/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type mockGuestsRepository struct {
insertGuestFunc func(ctx context.Context, req *models.CreateGuest) (*models.Guest, error)
findGuestFunc func(ctx context.Context, id string) (*models.Guest, error)
updateGuestFunc func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error)
insertGuestFunc func(ctx context.Context, req *models.CreateGuest) (*models.Guest, error)
findGuestFunc func(ctx context.Context, id string) (*models.Guest, error)
updateGuestFunc func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error)
findGuestWithActiveBookingFunc func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error)
}

func (m *mockGuestsRepository) InsertGuest(ctx context.Context, guest *models.CreateGuest) (*models.Guest, error) {
Expand All @@ -35,6 +38,13 @@ func (m *mockGuestsRepository) UpdateGuest(ctx context.Context, id string, updat
return m.updateGuestFunc(ctx, id, update)
}

func (m *mockGuestsRepository) FindGuestWithActiveBooking(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
if m.findGuestWithActiveBookingFunc == nil {
return []*models.GuestListItem{}, nil
}
return m.findGuestWithActiveBookingFunc(ctx, hotelID, filter)
}

// Makes the compiler verify the mock
var _ storage.GuestsRepository = (*mockGuestsRepository)(nil)

Expand Down Expand Up @@ -567,3 +577,263 @@ func TestGuestsHandler_UpdateGuest(t *testing.T) {
assert.Equal(t, 500, resp.StatusCode)
})
}

func TestGuestsHandler_SearchGuests(t *testing.T) {
t.Parallel()

const testHotelID = "00000000-0000-0000-0000-000000000001"

t.Run("returns 200 with paginated guest items", func(t *testing.T) {
t.Parallel()

items := make([]*models.GuestListItem, 6) // limit=5, repo returns 6
for i := range items {
items[i] = &models.GuestListItem{
GuestID: "530e8400-e458-41d4-a716-44665544000" + string(rune('1'+i)),
GovernmentName: "Guest " + string(rune('A'+i)),
PreferredName: "Pref " + string(rune('A'+i)),
Floor: 1,
RoomNumber: 100 + i,
GroupSize: 2,
}
}

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
require.Equal(t, testHotelID, hotelID)
require.Equal(t, 5, filter.Limit)
return items, nil
},
}

app := fiber.New()
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest(
"POST",
"/guests/search",
bytes.NewBufferString(`{"limit":5}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)

body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), `"items"`)
assert.Contains(t, string(body), `"has_more":true`)
assert.Contains(t, string(body), `"next_cursor"`)
})

t.Run("passes filters and cursor to repository", func(t *testing.T) {
t.Parallel()

search := "jane"
cursor := "530e8400-e458-41d4-a716-446655440000"

var capturedHotelID string
var capturedFilter *models.GuestSearchFilter

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
capturedHotelID = hotelID
capturedFilter = filter
return []*models.GuestListItem{}, nil
},
}

app := fiber.New()
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

reqBody := `{
"floors":[1,3],
"group_size_min":2,
"group_size_max":4,
"search_term":"` + search + `",
"cursor":"` + cursor + `",
"limit":10
}`

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)

assert.Equal(t, 200, resp.StatusCode)
require.NotNil(t, capturedFilter)
assert.Equal(t, testHotelID, capturedHotelID)
require.NotNil(t, capturedFilter.Floors)
assert.Equal(t, []int{1, 3}, *capturedFilter.Floors)
require.NotNil(t, capturedFilter.GroupSizeMin)
require.NotNil(t, capturedFilter.GroupSizeMax)
assert.Equal(t, 2, *capturedFilter.GroupSizeMin)
assert.Equal(t, 4, *capturedFilter.GroupSizeMax)
require.NotNil(t, capturedFilter.SearchTerm)
assert.Equal(t, search, *capturedFilter.SearchTerm)
require.NotNil(t, capturedFilter.Cursor)
assert.Equal(t, cursor, *capturedFilter.Cursor)
assert.Equal(t, 10, capturedFilter.Limit)
})

t.Run("returns 400 when hotel header missing", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return []*models.GuestListItem{}, nil
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)

assert.Equal(t, 400, resp.StatusCode)
})

t.Run("returns 400 on invalid json", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return []*models.GuestListItem{}, nil
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{invalid`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})

t.Run("returns 400 when group range invalid", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return []*models.GuestListItem{}, nil
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest(
"POST",
"/guests/search",
bytes.NewBufferString(`{"group_size_min":5,"group_size_max":2}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})

t.Run("returns 400 when cursor is invalid uuid", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return []*models.GuestListItem{}, nil
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest(
"POST",
"/guests/search",
bytes.NewBufferString(`{"cursor":"not-a-uuid"}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})

t.Run("returns 500 when repository errors", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return nil, errors.New("db failure")
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"limit":5}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
})

t.Run("defaults limit when omitted", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestWithActiveBookingFunc: func(ctx context.Context, hotelID string, filter *models.GuestSearchFilter) ([]*models.GuestListItem, error) {
return []*models.GuestListItem{
{
GuestID: "530e8400-e458-41d4-a716-446655440000",
GovernmentName: "Jane Doe",
PreferredName: "Jane",
Floor: 2,
RoomNumber: 202,
GroupSize: 2,
},
}, nil
},
}

app := fiber.New()
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.SearchGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hotelIDHeader, testHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)

body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), `"has_more":false`)

var page utils.CursorPage[*models.GuestListItem]
err = json.Unmarshal(body, &page)
require.NoError(t, err)
assert.Len(t, page.Items, 1)
})
}
18 changes: 18 additions & 0 deletions backend/internal/models/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,21 @@ type Guest struct {
UpdatedAt time.Time `json:"updated_at" example:"2024-01-02T00:00:00Z"`
CreateGuest
} //@name Guest

type GuestSearchFilter struct {
Floors *[]int `json:"floors" example:"[1, 2, 3]"`
GroupSizeMin *int `json:"group_size_min" example:"2"`
GroupSizeMax *int `json:"group_size_max" example:"4"`
SearchTerm *string `json:"search_term" example:"Jane"`
Cursor *string `json:"cursor" example:""`
Limit int `json:"limit" example:"10"`
}

type GuestListItem struct {
GuestID string `json:"guest_id" example:"530e8400-e458-41d4-a716-446655440000"`
GovernmentName string `json:"government_name" example:"Jane Doe"`
PreferredName string `json:"preferred_name" example:"Jane"`
Floor int `json:"floor" example:"1"`
RoomNumber int `json:"room_number" example:"101"`
GroupSize int `json:"group_size" example:"2"`
}
Loading