diff --git a/backend/internal/handler/guests_test.go b/backend/internal/handler/guests_test.go index 8d93cabf1..8032fe2f9 100644 --- a/backend/internal/handler/guests_test.go +++ b/backend/internal/handler/guests_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http/httptest" + "strings" "testing" "time" @@ -349,236 +350,6 @@ func TestGuestsHandler_GetGuest(t *testing.T) { }) } -func TestGuestsHandler_UpdateGuest(t *testing.T) { - t.Parallel() - - validID := "530e8400-e458-41d4-a716-446655440000" - - t.Run("returns 200 on valid update with required fields", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{ - updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { - require.Equal(t, validID, id) - require.Equal(t, "Jane", update.FirstName) - require.Equal(t, "Smith", update.LastName) - - return &models.Guest{ - ID: validID, - CreatedAt: time.Now().Add(-time.Hour), - UpdatedAt: time.Now(), - CreateGuest: models.CreateGuest{ - FirstName: update.FirstName, - LastName: update.LastName, - }, - }, nil - }, - } - - app := fiber.New() - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith"}`), - ) - req.Header.Set("Content-Type", "application/json") - - 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), "Jane") - assert.Contains(t, string(body), "Smith") - }) - - t.Run("returns 400 when first_name is missing", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"last_name":"Smith"}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "first_name") - }) - - t.Run("returns 400 when last_name is missing", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"Jane"}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "last_name") - }) - - t.Run("returns 400 when both required fields are empty strings", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"","last_name":""}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "first_name") - assert.Contains(t, string(body), "last_name") - }) - - t.Run("returns 400 on invalid UUID", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/not-a-uuid", - bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith"}`), - ) - 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{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{invalid json`), - ) - 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 timezone", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{} - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith","timezone":"Eastern"}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) - - body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "timezone") - }) - - t.Run("returns 404 when guest not found", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{ - updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { - return nil, errs.ErrNotFoundInDB - }, - } - - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith"}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 404, resp.StatusCode) - }) - - t.Run("returns 500 on repository error", func(t *testing.T) { - t.Parallel() - - mock := &mockGuestsRepository{ - updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { - return nil, errors.New("db failure") - }, - } - - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewGuestsHandler(mock, nil) - app.Put("/guests/:id", h.UpdateGuest) - - req := httptest.NewRequest( - "PUT", - "/guests/"+validID, - bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith"}`), - ) - req.Header.Set("Content-Type", "application/json") - - resp, err := app.Test(req) - require.NoError(t, err) - assert.Equal(t, 500, resp.StatusCode) - }) -} - func TestGuestsHandler_GetGuests(t *testing.T) { t.Parallel() @@ -1017,3 +788,337 @@ func TestGuestsHandler_GetGuestWithStays(t *testing.T) { assert.Equal(t, 200, resp.StatusCode) }) } + +func TestGuestsHandler_UpdateGuest(t *testing.T) { + t.Parallel() + + validID := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 200 when updating first and last name", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + require.Equal(t, validID, id) + require.Equal(t, "Jane", *update.FirstName) + require.Equal(t, "Smith", *update.LastName) + + return &models.Guest{ + ID: validID, + CreatedAt: time.Now().Add(-time.Hour), + UpdatedAt: time.Now(), + CreateGuest: models.CreateGuest{ + FirstName: *update.FirstName, + LastName: *update.LastName, + }, + }, nil + }, + } + + app := fiber.New() + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"first_name":"Jane","last_name":"Smith"}`), + ) + req.Header.Set("Content-Type", "application/json") + + 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), "Jane") + assert.Contains(t, string(body), "Smith") + }) + + t.Run("returns 200 when only first_name is updated", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + require.Equal(t, "Jane", *update.FirstName) + require.Nil(t, update.LastName) + return &models.Guest{ + ID: validID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreateGuest: models.CreateGuest{ + FirstName: *update.FirstName, + LastName: "Doe", + }, + }, nil + }, + } + + app := fiber.New() + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"first_name":"Jane"}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("returns 200 when only notes is updated", func(t *testing.T) { + t.Parallel() + + notes := "VIP guest" + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + require.Nil(t, update.FirstName) + require.Nil(t, update.LastName) + require.Equal(t, notes, *update.Notes) + return &models.Guest{ + ID: validID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Notes: update.Notes, + }, nil + }, + } + + app := fiber.New() + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"notes":"VIP guest"}`), + ) + req.Header.Set("Content-Type", "application/json") + + 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), "VIP guest") + }) + + t.Run("returns 200 when no fields are provided", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + require.Nil(t, update.FirstName) + require.Nil(t, update.LastName) + require.Nil(t, update.Notes) + return &models.Guest{ + ID: validID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + + app := fiber.New() + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("returns 400 when first_name is blank string", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"first_name":""}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "first_name") + }) + + t.Run("returns 400 when last_name is blank string", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"last_name":""}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "last_name") + }) + + t.Run("returns 400 when notes exceeds 1000 chars", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + longNotes := `{"notes":"` + strings.Repeat("a", 1001) + `"}` + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(longNotes), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "notes") + }) + + t.Run("returns 400 on invalid UUID", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/not-a-uuid", + bytes.NewBufferString(`{"first_name":"Jane"}`), + ) + 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{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{invalid json`), + ) + 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 timezone", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"timezone":"Eastern"}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "timezone") + }) + + t.Run("returns 404 when guest not found", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + return nil, errs.ErrNotFoundInDB + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"first_name":"Jane"}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) + + t.Run("returns 500 on repository error", func(t *testing.T) { + t.Parallel() + + mock := &mockGuestsRepository{ + updateGuestFunc: func(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { + return nil, errors.New("db failure") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewGuestsHandler(mock, nil) + app.Put("/guests/:id", h.UpdateGuest) + + req := httptest.NewRequest( + "PUT", + "/guests/"+validID, + bytes.NewBufferString(`{"first_name":"Jane"}`), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 500, resp.StatusCode) + }) +} diff --git a/backend/internal/models/guests.go b/backend/internal/models/guests.go index c404701b7..8599a5956 100644 --- a/backend/internal/models/guests.go +++ b/backend/internal/models/guests.go @@ -29,16 +29,18 @@ type CreateGuest struct { } // @name CreateGuest type UpdateGuest struct { - FirstName string `json:"first_name" validate:"notblank" example:"Jane"` - LastName string `json:"last_name" validate:"notblank" example:"Doe"` + FirstName *string `json:"first_name,omitempty" validate:"omitempty,notblank" example:"Jane"` + LastName *string `json:"last_name,omitempty" validate:"omitempty,notblank" example:"Doe"` ProfilePicture *string `json:"profile_picture,omitempty" validate:"omitempty,url" example:"https://example.com/john.jpg"` Timezone *string `json:"timezone,omitempty" validate:"omitempty,timezone" example:"America/New_York"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000" example:"VIP guest"` } //@name UpdateGuest type Guest struct { ID string `json:"id" example:"530e8400-e458-41d4-a716-446655440000"` CreatedAt time.Time `json:"created_at" example:"2024-01-02T00:00:00Z"` UpdatedAt time.Time `json:"updated_at" example:"2024-01-02T00:00:00Z"` + Notes *string `json:"notes,omitempty" example:"VIP guest"` CreateGuest } //@name Guest diff --git a/backend/internal/repository/guests.go b/backend/internal/repository/guests.go index 7cdd65988..132026e18 100644 --- a/backend/internal/repository/guests.go +++ b/backend/internal/repository/guests.go @@ -155,20 +155,22 @@ func (r *GuestsRepository) UpdateGuest(ctx context.Context, id string, update *m row := r.db.QueryRow(ctx, ` UPDATE guests SET - first_name = $2, - last_name = $3, - profile_picture = $4, - timezone = $5, + first_name = COALESCE($2, first_name), + last_name = COALESCE($3, last_name), + profile_picture = COALESCE($4, profile_picture), + timezone = COALESCE($5, timezone), + notes = COALESCE($6, notes), updated_at = NOW() WHERE id = $1 RETURNING id, created_at, updated_at, - first_name, last_name, profile_picture, timezone`, + first_name, last_name, profile_picture, timezone, notes`, id, update.FirstName, update.LastName, update.ProfilePicture, update.Timezone, + update.Notes, ) err := row.Scan( @@ -179,6 +181,7 @@ func (r *GuestsRepository) UpdateGuest(ctx context.Context, id string, update *m &guest.LastName, &guest.ProfilePicture, &guest.Timezone, + &guest.Notes, ) if err != nil {