diff --git a/api/openapi-spec/openapi.yaml b/api/openapi-spec/openapi.yaml index 990e218..eb79d58 100644 --- a/api/openapi-spec/openapi.yaml +++ b/api/openapi-spec/openapi.yaml @@ -561,7 +561,7 @@ paths: schema: $ref: '#/components/schemas/Error' '403': - description: You do not have permission to do this + description: You do not have permission to do this. This includes cases where the attendee does not have attending status. content: application/json: schema: @@ -573,7 +573,7 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: Duplicate assignment, this attendee is already in another group. + description: Duplicate assignment, or this attendee is already in another group. content: application/json: schema: @@ -1125,7 +1125,7 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: Duplicate assignment, this attendee is already in another room, or the room is full. + description: Duplicate assignment to same room, or this attendee is already in another room, or not in attending status, or the room is full. content: application/json: schema: @@ -1490,7 +1490,7 @@ components: - room.occupant.notfound (attendee is not in any/this room) - room.not.empty (cannot delete a room that isn't empty) - room.read.error (database error) - - room.size.full (not enough space in room to add another member) + - room.size.full (not enough space in room to add another attendee) - room.size.too.small (cannot reduce room size, new size not big enough for current number of occupants) - room.write.error (database error) example: group.read.error diff --git a/internal/application/common/errors.go b/internal/application/common/errors.go index b44a43f..bd3c207 100644 --- a/internal/application/common/errors.go +++ b/internal/application/common/errors.go @@ -69,31 +69,31 @@ const ( // construct specific API errors -func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewBadRequest(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusBadRequest, message, details, internalCauses...) } -func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewUnauthorized(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusUnauthorized, message, details, internalCauses...) } -func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewForbidden(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusForbidden, message, details, internalCauses...) } -func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewNotFound(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusNotFound, message, details, internalCauses...) } -func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewConflict(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusConflict, message, details, internalCauses...) } -func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewInternalServerError(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusInternalServerError, message, details, internalCauses...) } -func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) APIError { +func NewBadGateway(ctx context.Context, message ErrorMessageCode, details url.Values, internalCauses ...error) error { return NewAPIError(ctx, http.StatusBadGateway, message, details, internalCauses...) } diff --git a/internal/controller/v1/groupsctl/groups_get.go b/internal/controller/v1/groupsctl/groups_get.go index 8719b56..23235df 100644 --- a/internal/controller/v1/groupsctl/groups_get.go +++ b/internal/controller/v1/groupsctl/groups_get.go @@ -86,7 +86,7 @@ func (h *Controller) FindMyGroup(ctx context.Context, req *FindMyGroupRequest, w } func (h *Controller) FindMyGroupRequest(r *http.Request, w http.ResponseWriter) (*FindMyGroupRequest, error) { - // Endpoint only requires JWT token for now. + // Endpoint only requires logged-in user return &FindMyGroupRequest{}, nil } diff --git a/internal/controller/v1/roomsctl/rooms_get.go b/internal/controller/v1/roomsctl/rooms_get.go index 831c85c..6ffc851 100644 --- a/internal/controller/v1/roomsctl/rooms_get.go +++ b/internal/controller/v1/roomsctl/rooms_get.go @@ -4,6 +4,8 @@ import ( "context" "github.com/eurofurence/reg-room-service/internal/application/common" "github.com/eurofurence/reg-room-service/internal/application/web" + "github.com/eurofurence/reg-room-service/internal/controller/v1/util" + roomservice "github.com/eurofurence/reg-room-service/internal/service/rooms" "github.com/go-chi/chi/v5" "github.com/google/uuid" "net/http" @@ -12,24 +14,76 @@ import ( modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" ) -type ListRoomsRequest struct { - OccupantIDs []int64 - MinSize uint - MaxSize uint - MinOccupants uint - MaxOccupants uint -} +func (h *Controller) ListRooms(ctx context.Context, req *roomservice.FindRoomParams, w http.ResponseWriter) (*modelsv1.RoomList, error) { + rooms, err := h.svc.FindRooms(ctx, req) + if err != nil { + return nil, err + } -func (h *Controller) ListRooms(ctx context.Context, req *ListRoomsRequest, w http.ResponseWriter) (*modelsv1.RoomList, error) { - return nil, nil + return &modelsv1.RoomList{ + Rooms: rooms, + }, nil } -func (h *Controller) ListRoomsRequest(r *http.Request, w http.ResponseWriter) (*ListRoomsRequest, error) { - return nil, nil +func (h *Controller) ListRoomsRequest(r *http.Request, w http.ResponseWriter) (*roomservice.FindRoomParams, error) { + var req roomservice.FindRoomParams + + ctx := r.Context() + query := r.URL.Query() + + queryIDs := query.Get("occupant_ids") + memberIDs, err := util.ParseMemberIDs(queryIDs) + if err != nil { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) + } + req.MemberIDs = memberIDs + + if minSize := query.Get("min_size"); minSize != "" { + val, err := util.ParseUInt[uint](minSize) + if err != nil { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) + } + + req.MinSize = val + } + + if maxSize := query.Get("max_size"); maxSize != "" { + val, err := util.ParseUInt[uint](maxSize) + if err != nil { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) + } + + req.MaxSize = val + } + + if minOccupants := query.Get("min_occupants"); minOccupants != "" { + val, err := util.ParseUInt[uint](minOccupants) + if err != nil { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) + } + + req.MinOccupants = val + } + + if maxOccupants := query.Get("max_occupants"); maxOccupants != "" { + val, err := util.ParseInt[int](maxOccupants) + if err != nil { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details(err.Error())) + } + if val < -1 { + return nil, common.NewBadRequest(ctx, common.RequestParseFailed, common.Details("max_occupants cannot be less than -1")) + } + + req.MaxOccupants = val + } else { + req.MaxOccupants = -1 + } + + return &req, nil } func (h *Controller) ListRoomsResponse(ctx context.Context, res *modelsv1.RoomList, w http.ResponseWriter) error { - return nil + return web.EncodeWithStatus(http.StatusOK, res, w) } type FindMyRoomRequest struct{} @@ -38,15 +92,21 @@ type FindMyRoomRequest struct{} // // See OpenAPI Spec for further details. func (h *Controller) FindMyRoom(ctx context.Context, req *FindMyRoomRequest, w http.ResponseWriter) (*modelsv1.Room, error) { - return nil, nil + room, err := h.svc.FindMyRoom(ctx) + if err != nil { + return nil, err + } + + return room, nil } func (h *Controller) FindMyRoomRequest(r *http.Request, w http.ResponseWriter) (*FindMyRoomRequest, error) { - return nil, nil + // Endpoint only requires logged-in user + return &FindMyRoomRequest{}, nil } func (h *Controller) FindMyRoomResponse(ctx context.Context, res *modelsv1.Room, w http.ResponseWriter) error { - return nil + return web.EncodeWithStatus(http.StatusOK, res, w) } type GetRoomByIDRequest struct { diff --git a/internal/repository/database/inmemorydb/implementation.go b/internal/repository/database/inmemorydb/implementation.go index f34aeee..996df9d 100644 --- a/internal/repository/database/inmemorydb/implementation.go +++ b/internal/repository/database/inmemorydb/implementation.go @@ -329,8 +329,8 @@ func (r *InMemoryRepository) UpdateRoom(ctx context.Context, room *entity.Room) func (r *InMemoryRepository) GetRoomByID(ctx context.Context, id string) (*entity.Room, error) { // allow deleted so history and undelete work if result, ok := r.rooms[id]; ok { - grpCopy := result.Room - return &grpCopy, nil + roomCopy := result.Room + return &roomCopy, nil } else { return &entity.Room{}, gorm.ErrRecordNotFound } diff --git a/internal/repository/database/interface.go b/internal/repository/database/interface.go index dbcab85..825d8d7 100644 --- a/internal/repository/database/interface.go +++ b/internal/repository/database/interface.go @@ -45,6 +45,9 @@ type Repository interface { // // A room matches the list of badge numbers in anyOfMemberID if at least one of those badge numbers // is in the room. An empty list or nil means no condition. + // + // For minOccupancy, minSize, maxSize a value of 0 means no condition (because all rooms satisfy these), + // for maxOccupancy a value of -1 means no condition (maxOccupancy=0 searches for empty rooms). FindRooms(ctx context.Context, name string, minOccupancy uint, maxOccupancy int, minSize uint, maxSize uint, anyOfMemberID []int64) ([]string, error) // GetRooms returns all rooms. GetRooms(ctx context.Context) ([]*entity.Room, error) diff --git a/internal/service/rooms/helpers.go b/internal/service/rooms/helpers.go new file mode 100644 index 0000000..97b1f99 --- /dev/null +++ b/internal/service/rooms/helpers.go @@ -0,0 +1,69 @@ +package roomservice + +import ( + "context" + "errors" + aulogging "github.com/StephanHCB/go-autumn-logging" + "github.com/eurofurence/reg-room-service/internal/application/common" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" +) + +func (r *roomService) loggedInUserValidRegistrationBadgeNo(ctx context.Context) (attendeeservice.Attendee, error) { + myRegIDs, err := r.AttSrv.ListMyRegistrationIds(ctx) + if err != nil { + aulogging.WarnErrf(ctx, err, "failed to obtain registrations for currently logged in user: %s", err.Error()) + return attendeeservice.Attendee{}, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) + } + if len(myRegIDs) == 0 { + aulogging.InfoErr(ctx, err, "currently logged in user has no registrations - cannot be in a group") + return attendeeservice.Attendee{}, common.NewForbidden(ctx, common.NoSuchAttendee, common.Details("you do not have a valid registration")) + } + myID := myRegIDs[0] + + if err := r.checkAttending(ctx, myID, common.NewForbidden(ctx, common.NotAttending, common.Details("registration is not in attending status"))); err != nil { + return attendeeservice.Attendee{}, err + } + + attendee, err := r.AttSrv.GetAttendee(ctx, myID) + if err != nil { + return attendeeservice.Attendee{}, err + } + // ensure ID set in Attendee + attendee.ID = myID + + return attendee, nil +} + +func (r *roomService) validateRequestedAttendee(ctx context.Context, badgeNo int64) (attendeeservice.Attendee, error) { + if badgeNo <= 0 { + return attendeeservice.Attendee{}, common.NewBadRequest(ctx, common.RoomDataInvalid, common.Details("attendee badge number must be positive integer")) + } + + attendee, err := r.AttSrv.GetAttendee(ctx, badgeNo) + if err != nil { + if errors.Is(err, downstreams.ErrDownStreamNotFound) { + return attendeeservice.Attendee{}, common.NewNotFound(ctx, common.NoSuchAttendee, common.Details("no such attendee")) + } else { + aulogging.WarnErrf(ctx, err, "failed to query for attendee with badge number %d: %s", badgeNo, err.Error()) + return attendeeservice.Attendee{}, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("failed to look up invited attendee - internal error, see logs for details")) + } + } + + return attendee, nil +} + +func (r *roomService) checkAttending(ctx context.Context, badgeNo int64, notAttendingErr error) error { + status, err := r.AttSrv.GetStatus(ctx, badgeNo) + if err != nil { + aulogging.WarnErrf(ctx, err, "failed to obtain status for badge number %d: %s", badgeNo, err.Error()) + return common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) + } + + switch status { + case attendeeservice.StatusApproved, attendeeservice.StatusPartiallyPaid, attendeeservice.StatusPaid, attendeeservice.StatusCheckedIn: + return nil + default: + return notAttendingErr + } +} diff --git a/internal/service/rooms/interface.go b/internal/service/rooms/interface.go index 655db55..3d1cafe 100644 --- a/internal/service/rooms/interface.go +++ b/internal/service/rooms/interface.go @@ -19,17 +19,24 @@ type Service interface { RemoveOccupantFromRoom(ctx context.Context, roomID string, badgeNumber int64) error FindRooms(ctx context.Context, params *FindRoomParams) ([]*modelsv1.Room, error) + // FindMyRoom looks up the room the currently logged-in user is in. + // + // This works for admins just like for normal users, returning their room, + // but will fail for requests using an API Token (no currently logged-in user available). + // + // Only finds rooms that have the "final" flag, and only works if the user's registration has + // attending status. FindMyRoom(ctx context.Context) (*modelsv1.Room, error) } type FindRoomParams struct { - memberIDs []int64 + MemberIDs []int64 // empty list or nil means no condition - minSize uint - maxSize uint + MinSize uint // 0 means no condition + MaxSize uint // 0 means no condition - minOccupants uint - maxOccupants uint + MinOccupants uint // 0 means no condition + MaxOccupants int // -1 means no condition, 0 means search for empty rooms only } func New(db database.Repository, attsrv attendeeservice.AttendeeService, mailsrv mailservice.MailService) Service { diff --git a/internal/service/rooms/occupants.go b/internal/service/rooms/occupants.go index fbf5d63..624323e 100644 --- a/internal/service/rooms/occupants.go +++ b/internal/service/rooms/occupants.go @@ -6,8 +6,6 @@ import ( aulogging "github.com/StephanHCB/go-autumn-logging" "github.com/eurofurence/reg-room-service/internal/application/common" "github.com/eurofurence/reg-room-service/internal/entity" - "github.com/eurofurence/reg-room-service/internal/repository/downstreams" - "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" "github.com/eurofurence/reg-room-service/internal/service/rbac" "gorm.io/gorm" ) @@ -29,7 +27,7 @@ func (r *roomService) AddOccupantToRoom(ctx context.Context, roomID string, badg if err != nil { return err } - if err := r.checkAttending(ctx, badgeNumber); err != nil { + if err := r.checkAttending(ctx, badgeNumber, common.NewConflict(ctx, common.NotAttending, common.Details("registration is not in attending status"))); err != nil { return err } @@ -41,7 +39,9 @@ func (r *roomService) AddOccupantToRoom(ctx context.Context, roomID string, badg } } - // TODO check room size + if err := r.checkRoomFull(ctx, roomID, room.Size); err != nil { + return err + } newMembership := r.DB.NewEmptyRoomMembership(ctx, roomID, badgeNumber) newMembership.Nickname = occupant.Nickname @@ -81,7 +81,7 @@ func (r *roomService) RemoveOccupantFromRoom(ctx context.Context, roomID string, } if room.ID != existingMembership.RoomID { - return common.NewConflict(ctx, common.RoomOccupantConflict, common.Details("this attendee is not in this room")) + return common.NewConflict(ctx, common.RoomOccupantConflict, common.Details("this attendee is in a different room")) } if err := r.DB.DeleteRoomMembership(ctx, badgeNumber); err != nil { @@ -96,6 +96,24 @@ func (r *roomService) RemoveOccupantFromRoom(ctx context.Context, roomID string, // --- helpers --- +func (r *roomService) checkRoomFull(ctx context.Context, roomID string, roomSize int64) error { + memberIDs, err := r.DB.GetRoomMembersByRoomID(ctx, roomID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // empty room is acceptable + return nil + } else { + return errRoomRead(ctx, err.Error()) + } + } + + if len(memberIDs) >= int(roomSize) { + return errRoomFull(ctx) + } + + return nil +} + func (r *roomService) roomMembershipExisting(ctx context.Context, roomID string, badgeNumber int64) (*entity.Room, *entity.RoomMember, error) { room, err := r.DB.GetRoomByID(ctx, roomID) if err != nil { @@ -118,36 +136,3 @@ func (r *roomService) roomMembershipExisting(ctx context.Context, roomID string, return room, member, nil } - -func (r *roomService) validateRequestedAttendee(ctx context.Context, badgeNo int64) (attendeeservice.Attendee, error) { - if badgeNo <= 0 { - return attendeeservice.Attendee{}, common.NewBadRequest(ctx, common.RoomDataInvalid, common.Details("attendee badge number must be positive integer")) - } - - attendee, err := r.AttSrv.GetAttendee(ctx, badgeNo) - if err != nil { - if errors.Is(err, downstreams.ErrDownStreamNotFound) { - return attendeeservice.Attendee{}, common.NewNotFound(ctx, common.NoSuchAttendee, common.Details("no such attendee")) - } else { - aulogging.WarnErrf(ctx, err, "failed to query for attendee with badge number %d: %s", badgeNo, err.Error()) - return attendeeservice.Attendee{}, common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("failed to look up invited attendee - internal error, see logs for details")) - } - } - - return attendee, nil -} - -func (r *roomService) checkAttending(ctx context.Context, badgeNo int64) error { - status, err := r.AttSrv.GetStatus(ctx, badgeNo) - if err != nil { - aulogging.WarnErrf(ctx, err, "failed to obtain status for badge number %d: %s", badgeNo, err.Error()) - return common.NewBadGateway(ctx, common.DownstreamAttSrv, common.Details("downstream error when contacting attendee service")) - } - - switch status { - case attendeeservice.StatusApproved, attendeeservice.StatusPartiallyPaid, attendeeservice.StatusPaid, attendeeservice.StatusCheckedIn: - return nil - default: - return common.NewForbidden(ctx, common.NotAttending, common.Details("registration is not in attending status")) - } -} diff --git a/internal/service/rooms/rooms.go b/internal/service/rooms/rooms.go index 7c8c469..c16a9d6 100644 --- a/internal/service/rooms/rooms.go +++ b/internal/service/rooms/rooms.go @@ -19,13 +19,81 @@ import ( ) func (r *roomService) FindRooms(ctx context.Context, params *FindRoomParams) ([]*modelsv1.Room, error) { - //TODO implement me - panic("implement me") + validator, err := rbac.NewValidator(ctx) + if err != nil { + aulogging.ErrorErrf(ctx, err, "Could not retrieve RBAC validator from context. [error]: %v", err) + return nil, errCouldNotGetValidator(ctx) + } + + if validator.IsAdmin() || validator.IsAPITokenCall() { + return r.findRoomsLowlevel(ctx, params) + } else { + return nil, errNotAdminOrApiToken(ctx, "(not loaded)", "(not loaded)") + } +} + +func (r *roomService) findRoomsLowlevel(ctx context.Context, params *FindRoomParams) ([]*modelsv1.Room, error) { + result := make([]*modelsv1.Room, 0) + + roomIDs, err := r.DB.FindRooms(ctx, "", params.MinOccupants, params.MaxOccupants, params.MinSize, params.MaxSize, params.MemberIDs) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return result, nil + } + + aulogging.ErrorErrf(ctx, err, "find rooms failed: %s", err.Error()) + return result, errInternal(ctx, "database error while finding rooms - see logs for details") + } + + for _, id := range roomIDs { + room, err := r.getRoomByIDLowlevel(ctx, id) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + aulogging.WarnErrf(ctx, err, "find rooms failed to read room %s - maybe intermittent change: %s", id, err.Error()) + return make([]*modelsv1.Room, 0), errInternal(ctx, "database error while finding rooms - see logs for details") + } + } + + result = append(result, room) + } + + return result, nil } func (r *roomService) FindMyRoom(ctx context.Context) (*modelsv1.Room, error) { - //TODO implement me - panic("implement me") + attendee, err := r.loggedInUserValidRegistrationBadgeNo(ctx) + if err != nil { + return nil, err + } + + params := &FindRoomParams{ + MemberIDs: []int64{attendee.ID}, + MinSize: 0, + MaxSize: 0, + MinOccupants: 0, + MaxOccupants: -1, + } + rooms, err := r.findRoomsLowlevel(ctx, params) + if err != nil { + return nil, err + } + + if len(rooms) == 0 { + return nil, errNoRoom(ctx) + } + if len(rooms) > 1 { + return nil, errInternal(ctx, "multiple room memberships found - this is a bug") + } + + myRoom := rooms[0] + // ensure final flag is set on room + for _, flag := range myRoom.Flags { + if flag == "final" { + return myRoom, nil + } + } + + return nil, errNoRoom(ctx) } func (r *roomService) GetRoomByID(ctx context.Context, roomID string) (*modelsv1.Room, error) { @@ -36,35 +104,39 @@ func (r *roomService) GetRoomByID(ctx context.Context, roomID string) (*modelsv1 } if validator.IsAdmin() || validator.IsAPITokenCall() { - room, err := r.DB.GetRoomByID(ctx, roomID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errRoomNotFound(ctx) - } + return r.getRoomByIDLowlevel(ctx, roomID) + } else { + return nil, errNotAdminOrApiToken(ctx, roomID, "(not loaded)") + } +} - return nil, errRoomRead(ctx, err.Error()) +func (r *roomService) getRoomByIDLowlevel(ctx context.Context, roomID string) (*modelsv1.Room, error) { + room, err := r.DB.GetRoomByID(ctx, roomID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errRoomNotFound(ctx) } - roomMembers, err := r.DB.GetRoomMembersByRoomID(ctx, roomID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // acceptable, empty room - } else { - return nil, errRoomRead(ctx, err.Error()) - } - } + return nil, errRoomRead(ctx, err.Error()) + } - return &modelsv1.Room{ - ID: room.ID, - Name: room.Name, - Flags: aggregateFlags(room.Flags), - Comments: common.ToOmitEmpty(room.Comments), - Size: room.Size, - Occupants: toOccupants(roomMembers), - }, nil - } else { - return nil, errNotAdminOrApiToken(ctx, roomID, "(not loaded)") + roomMembers, err := r.DB.GetRoomMembersByRoomID(ctx, roomID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // acceptable, empty room + } else { + return nil, errRoomRead(ctx, err.Error()) + } } + + return &modelsv1.Room{ + ID: room.ID, + Name: room.Name, + Flags: aggregateFlags(room.Flags), + Comments: common.ToOmitEmpty(room.Comments), + Size: room.Size, + Occupants: toOccupants(roomMembers), + }, nil } func (r *roomService) CreateRoom(ctx context.Context, room *modelsv1.RoomCreate) (string, error) { @@ -285,6 +357,10 @@ func errNotAdminOrApiToken(ctx context.Context, uuid string, name string) error return common.NewForbidden(ctx, common.AuthForbidden, common.Details("you are not authorized for this operation - the attempt has been logged")) } +func errNoRoom(ctx context.Context) error { + return common.NewNotFound(ctx, common.RoomOccupantNotFound, common.Details("not in a room, or final flag not set on room")) +} + func errRoomNotFound(ctx context.Context) error { return common.NewNotFound(ctx, common.RoomIDNotFound, common.Details("room does not exist")) } @@ -293,6 +369,10 @@ func errRoomNotEmpty(ctx context.Context) error { return common.NewConflict(ctx, common.RoomNotEmpty, common.Details("room is not empty and room deletion is a dangerous operation - please remove all occupants first to ensure you really mean this (also prevents possible problems with concurrent updates)")) } +func errRoomFull(ctx context.Context) error { + return common.NewConflict(ctx, common.RoomSizeFull, common.Details("this room is full")) +} + func errCouldNotGetValidator(ctx context.Context) error { return common.NewInternalServerError(ctx, common.InternalErrorMessage, common.Details("unexpected error when parsing user claims")) } diff --git a/test/acceptance/groups_list_test.go b/test/acceptance/groups_list_test.go index 4c35319..a95ab95 100644 --- a/test/acceptance/groups_list_test.go +++ b/test/acceptance/groups_list_test.go @@ -201,7 +201,7 @@ func TestGroupsList_UserNonAttendingReg(t *testing.T) { tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.status.not.attending", "registration is not in attending status") } -func TestGroupsCreate_InvalidQueryParams(t *testing.T) { +func TestGroupsList_InvalidQueryParams(t *testing.T) { tstSetup(tstDefaultConfigFileRoomGroups) defer tstShutdown() diff --git a/test/acceptance/groups_member_add_test.go b/test/acceptance/groups_member_add_test.go index 33c8be9..e15bb21 100644 --- a/test/acceptance/groups_member_add_test.go +++ b/test/acceptance/groups_member_add_test.go @@ -532,10 +532,10 @@ func TestGroupsAddMember_BadgeNumberNegative(t *testing.T) { docs.When("When they attempt to leave the group, but supply a negative badge number") token := tstValidUserToken(t, 1234567890) - response := tstPerformPostNoBody(groupLocation+"/members/%2d144", token) + response := tstPerformPostNoBody(groupLocation+"/members/-144", token) docs.Then("Then the request fails with the appropriate error message") - tstRequireErrorResponse(t, response, http.StatusBadRequest, "request.parse.failed", "invalid badge number - must be positive integer") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "group.data.invalid", "invalid badge number - must be positive integer") } // TODO technical errors (downstream failures etc.) diff --git a/test/acceptance/rooms_create_test.go b/test/acceptance/rooms_create_test.go index 380506a..633eb0f 100644 --- a/test/acceptance/rooms_create_test.go +++ b/test/acceptance/rooms_create_test.go @@ -143,3 +143,5 @@ func TestRoomsCreate_NameTooLong(t *testing.T) { docs.Then("Then the request fails with the expected error") tstRequireErrorResponse(t, response, http.StatusBadRequest, "room.data.invalid", url.Values{"name": []string{"room name too long, max 50 characters"}}) } + +// TODO duplicate name diff --git a/test/acceptance/rooms_delete_test.go b/test/acceptance/rooms_delete_test.go index f41501b..6ef1cfb 100644 --- a/test/acceptance/rooms_delete_test.go +++ b/test/acceptance/rooms_delete_test.go @@ -13,7 +13,7 @@ func TestRoomsDelete_NotLoggedIn(t *testing.T) { defer tstShutdown() docs.Given("Given an empty room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.Given("Given an anonymous user") token := tstNoToken() @@ -36,7 +36,7 @@ func TestRoomsDelete_UserDeny(t *testing.T) { token := tstValidUserToken(t, 101) docs.Given("Given an empty room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.When("When they try to delete the room") response := tstPerformDelete(location, token) @@ -53,7 +53,7 @@ func TestRoomsDelete_AdminSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given an empty room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.Given("Given an admin") token := tstValidAdminToken(t) @@ -74,7 +74,7 @@ func TestRoomsDelete_ApiTokenSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given an empty room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.Given("Given a downstream service using a valid api token") token := tstValidApiToken() @@ -95,7 +95,7 @@ func TestRoomsDelete_NotEmpty(t *testing.T) { defer tstShutdown() docs.Given("Given a room that is not empty") - location := setupExistingRoom(t, "31415", squirrel) + location := setupExistingRoom(t, "31415", false, squirrel) docs.Given("Given an admin") token := tstValidAdminToken(t) diff --git a/test/acceptance/rooms_get_test.go b/test/acceptance/rooms_get_test.go index 24bc2c3..31b7a31 100644 --- a/test/acceptance/rooms_get_test.go +++ b/test/acceptance/rooms_get_test.go @@ -12,7 +12,7 @@ func TestRoomsGet_NotLoggedIn(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "31415", squirrel, snep) + location := setupExistingRoom(t, "31415", false, squirrel, snep) docs.Given("Given an anonymous user") token := tstNoToken() @@ -32,7 +32,7 @@ func TestRoomsGet_UserDeny(t *testing.T) { token := tstValidUserToken(t, 101) docs.Given("Given a room they are in") - location := setupExistingRoom(t, "31415", squirrel, snep) + location := setupExistingRoom(t, "31415", false, squirrel, snep) docs.When("When they try to access the room information, but do not use the special 'find my room' endpoint") response := tstPerformGet(location, token) @@ -46,7 +46,7 @@ func TestRoomsGet_AdminSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given an empty room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.Given("Given an admin") token := tstValidAdminToken(t) @@ -63,7 +63,7 @@ func TestRoomsGet_ApiTokenSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "31415", squirrel, snep) + location := setupExistingRoom(t, "31415", false, squirrel, snep) docs.Given("Given a downstream service using a valid api token") token := tstValidApiToken() diff --git a/test/acceptance/rooms_list_test.go b/test/acceptance/rooms_list_test.go new file mode 100644 index 0000000..16c5715 --- /dev/null +++ b/test/acceptance/rooms_list_test.go @@ -0,0 +1,161 @@ +package acceptance + +import ( + "github.com/eurofurence/reg-room-service/docs" + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "net/http" + "net/url" + "testing" +) + +func TestRoomsList_AdminSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given two registered attendees with an active registration who are in a room each") + location1 := setupExistingRoom(t, "rodents", false, squirrel) + location2 := setupExistingRoom(t, "cats", false, snep) + + docs.When("When an admin requests to list all rooms") + token := tstValidAdminToken(t) + response := tstPerformGet("/api/rest/v1/rooms", token) + + docs.Then("Then the request is successful and the response includes all room information") + actual := modelsv1.RoomList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + rm1 := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location1), + Name: "rodents", + Flags: []string{}, + Comments: p("A nice comment for rodents"), + Size: 2, + Occupants: []modelsv1.Member{squirrel}, + } + rm2 := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location2), + Name: "cats", + Flags: []string{}, + Comments: p("A nice comment for cats"), + Size: 2, + Occupants: []modelsv1.Member{snep}, + } + expected := modelsv1.RoomList{} + if rm1.ID < rm2.ID { + expected.Rooms = append(expected.Rooms, &rm1, &rm2) + } else { + expected.Rooms = append(expected.Rooms, &rm2, &rm1) + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestRoomsList_AdminSuccess_Filtered(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given two registered attendees with an active registration who are in a room each") + location1 := setupExistingRoom(t, "rodents", true, squirrel) + _ = setupExistingRoom(t, "cats", true, snep) + + docs.When("When an admin requests to list rooms containing a certain attendee") + token := tstValidAdminToken(t) + response := tstPerformGet("/api/rest/v1/rooms?occupant_ids=42", token) + + docs.Then("Then the request is successful and the response includes the requested room information") + actual := modelsv1.RoomList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + rm1 := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location1), + Name: "rodents", + Flags: []string{"final"}, + Comments: p("A nice comment for rodents"), + Size: 2, + Occupants: []modelsv1.Member{squirrel}, + } + expected := modelsv1.RoomList{Rooms: []*modelsv1.Room{&rm1}} + tstEqualResponseBodies(t, expected, actual) +} + +func TestRoomsList_ApiTokenSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given two registered attendees with an active registration who are in a room each") + location1 := setupExistingRoom(t, "rodents", false, squirrel) + location2 := setupExistingRoom(t, "cats", false, snep) + + docs.Given("Given a downstream service using a valid api token") + token := tstValidApiToken() + + docs.When("When it requests to list all rooms") + response := tstPerformGet("/api/rest/v1/rooms", token) + + docs.Then("Then the request is successful and the response includes all room information") + actual := modelsv1.RoomList{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + rm1 := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location1), + Name: "rodents", + Flags: []string{}, + Comments: p("A nice comment for rodents"), + Size: 2, + Occupants: []modelsv1.Member{squirrel}, + } + rm2 := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location2), + Name: "cats", + Flags: []string{}, + Comments: p("A nice comment for cats"), + Size: 2, + Occupants: []modelsv1.Member{snep}, + } + expected := modelsv1.RoomList{} + if rm1.ID < rm2.ID { + expected.Rooms = append(expected.Rooms, &rm1, &rm2) + } else { + expected.Rooms = append(expected.Rooms, &rm2, &rm1) + } + tstEqualResponseBodies(t, expected, actual) +} + +func TestRoomsList_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they attempt to list rooms") + response := tstPerformGet("/api/rest/v1/rooms", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestRoomsList_NonAdminDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a registered attendee with an active registration who is in a room") + _ = setupExistingRoom(t, "rodents", false, squirrel) + + docs.When("When they attempt to list rooms using the find rooms endpoint") + token := tstValidUserToken(t, subjectUint(squirrel)) + response := tstPerformGet("/api/rest/v1/rooms", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") +} + +func TestRoomsList_InvalidQueryParams(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to list rooms, but supply invalid parameters") + response := tstPerformGet("/api/rest/v1/rooms?occupant_ids=kittycat,-999", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "request.parse.failed", url.Values{"details": []string{"member ids must be numeric and valid. Invalid member id: kittycat"}}) +} diff --git a/test/acceptance/rooms_my_test.go b/test/acceptance/rooms_my_test.go new file mode 100644 index 0000000..31cc340 --- /dev/null +++ b/test/acceptance/rooms_my_test.go @@ -0,0 +1,100 @@ +package acceptance + +import ( + "github.com/eurofurence/reg-room-service/docs" + modelsv1 "github.com/eurofurence/reg-room-service/internal/api/v1" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "net/http" + "testing" +) + +func TestRoomsMy_UserSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a registered attendee with an active registration who is in a finalized room") + location1 := setupExistingRoom(t, "rodents", true, squirrel) + + docs.When("When the user requests their room") + token := tstValidUserToken(t, subjectUint(squirrel)) + response := tstPerformGet("/api/rest/v1/rooms/my", token) + + docs.Then("Then the request is successful and the response is as expected") + actual := modelsv1.Room{} + tstRequireSuccessResponse(t, response, http.StatusOK, &actual) + expected := modelsv1.Room{ + ID: tstRoomLocationToRoomID(location1), + Name: "rodents", + Flags: []string{"final"}, + Comments: p("A nice comment for rodents"), + Size: 2, + Occupants: []modelsv1.Member{squirrel}, + } + tstEqualResponseBodies(t, expected, actual) +} + +// TODO admin success + +// TODO api token produces correct error + +func TestRoomsMy_AnonymousDeny(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an unauthenticated user") + token := tstNoToken() + + docs.When("When they request their room") + response := tstPerformGet("/api/rest/v1/rooms/my", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") +} + +func TestRoomsMy_UserNoReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with NO registration") + token := tstValidUserToken(t, 101) + + docs.When("When they request their room") + response := tstPerformGet("/api/rest/v1/rooms/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.notfound", "you do not have a valid registration") +} + +func TestRoomsMy_UserNonAttendingReg(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in non-attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusNew, "Squirrel", "squirrel@example.com") + token := tstValidUserToken(t, 101) + + docs.When("When they request their room") + response := tstPerformGet("/api/rest/v1/rooms/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusForbidden, "attendee.status.not.attending", "registration is not in attending status") +} + +func TestRoomsMy_UserNoRoom(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an authorized user with a registration in attending status") + attMock.SetupRegistered("101", 42, attendeeservice.StatusPartiallyPaid, "Squirrel", "squirrel@example.com") + token := tstValidUserToken(t, 101) + + docs.Given("Given they are not in any room") + + docs.When("When they request their room") + response := tstPerformGet("/api/rest/v1/rooms/my", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusNotFound, "room.occupant.notfound", "not in a room, or final flag not set on room") +} + +// TODO not finalized = 404 diff --git a/test/acceptance/rooms_occupant_add_test.go b/test/acceptance/rooms_occupant_add_test.go new file mode 100644 index 0000000..28d4154 --- /dev/null +++ b/test/acceptance/rooms_occupant_add_test.go @@ -0,0 +1,304 @@ +package acceptance + +import ( + "fmt" + "github.com/eurofurence/reg-room-service/docs" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestRoomsAddOccupant_NotLoggedIn(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given an anonymous user") + token := tstNoToken() + + docs.When("When they try to add the attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") + + docs.Then("And the room is unchanged") + tstRoomState(t, location) +} + +func TestRoomsAddOccupant_UserDenyOtherAdd(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given another user, who is not an admin") + token := tstValidUserToken(t, 101) + + docs.When("When they try to add the other attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") + + docs.Then("And the room is unchanged") + tstRoomState(t, location) +} + +func TestRoomsAddOccupant_UserDenySelfAdd(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.When("When they try to add themselves to the room") + token := tstValidUserToken(t, 84) + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") + + docs.Then("And the room is unchanged") + tstRoomState(t, location) +} + +func TestRoomsAddOccupant_AdminSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When the admin adds the attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request is successful") + require.Equal(t, http.StatusNoContent, response.status) + + docs.Then("And the attendee has been added to the room") + tstRoomState(t, location, panther) +} + +func TestRoomsAddOccupant_ApiTokenSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given a downstream service using a valid api token") + token := tstValidApiToken() + + docs.When("When it adds the attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request is successful") + require.Equal(t, http.StatusNoContent, response.status) + + docs.Then("And the attendee has been added to the room") + tstRoomState(t, location, snep, panther) +} + +func TestRoomsAddOccupant_Full(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a full room with no free beds") + location := setupExistingRoom(t, "31415", false, squirrel, snep) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to add the attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusConflict, "room.size.full", "this room is full") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel, snep) +} + +func TestRoomsAddOccupant_Duplicate(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with an occupied bed") + location := setupExistingRoom(t, "31415", false, squirrel) + occupantLoc := fmt.Sprintf("%s/occupants/%d", location, squirrel.ID) + + docs.Given("Given an attendee with an active registration who is already in the room") + registerSubject(subject(squirrel)) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When the admin tries to add the attendee to the room again") + response := tstPerformPostNoBody(occupantLoc, token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusConflict, "room.occupant.duplicate", "this attendee is already in this room") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel) +} + +func TestRoomsAddOccupant_InAnotherRoom(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false) + occupantLoc := fmt.Sprintf("%s/occupants/%d", location, squirrel.ID) + + docs.Given("Given an attendee who is already in another room") + _ = setupExistingRoom(t, "27182", false, squirrel) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When the admin tries to also add the attendee to the first room") + response := tstPerformPostNoBody(occupantLoc, token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusConflict, "room.occupant.conflict", "this attendee is already in another room") + + docs.Then("And the room is unchanged") + tstRoomState(t, location) +} + +func TestRoomsAddOccupant_RoomNotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to add the attendee to a room, but specify a room that does not exist") + response := tstPerformPostNoBody("/api/rest/v1/rooms/7a8d1116-d656-44eb-89dd-51eefef8a83b/occupants/84", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusNotFound, "room.id.notfound", "room does not exist") +} + +func TestRoomsAddOccupant_AttendeeNotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to add an attendee to the room, but specify a badge number that does not exist") + response := tstPerformPostNoBody(location+"/occupants/4711", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.notfound", "no such attendee") +} + +func TestRoomsAddOccupant_AttendeeNotAttending(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given a cancelled attendee") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusCancelled, "Panther", "panther@example.com") + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to add the cancelled attendee to the room") + response := tstPerformPostNoBody(location+"/occupants/84", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusConflict, "attendee.status.not.attending", "registration is not in attending status") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, snep) +} + +func TestRoomsAddOccupant_InvalidRoomID(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.Given("Given an attendee with an active registration who is not in any room") + attMock.SetupRegistered("1234567890", 84, attendeeservice.StatusApproved, "Panther", "panther@example.com") + + docs.When("When they attempt to add the attendee to a room, but specify an invalid room id") + response := tstPerformPostNoBody("/api/rest/v1/rooms/kittycats/occupants/84", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "room.id.invalid", "you must specify a valid uuid") +} + +func TestRoomsAddOccupant_BadgeNumberInvalid(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they attempt to add an occupant to the room, but supply an invalid badge number") + response := tstPerformPostNoBody(location+"/occupants/floof", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "request.parse.failed", "invalid badge number - must be positive integer") +} + +func TestRoomsAddOccupant_BadgeNumberNegative(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they attempt to add an occupant to the room, but supply a negative badge number") + response := tstPerformPostNoBody(location+"/occupants/-144", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "room.data.invalid", "invalid badge number - must be positive integer") +} + +// TODO technical errors (downstream failures etc.) diff --git a/test/acceptance/rooms_occupant_remove_test.go b/test/acceptance/rooms_occupant_remove_test.go new file mode 100644 index 0000000..79b31af --- /dev/null +++ b/test/acceptance/rooms_occupant_remove_test.go @@ -0,0 +1,263 @@ +package acceptance + +import ( + "fmt" + "github.com/eurofurence/reg-room-service/docs" + "github.com/eurofurence/reg-room-service/internal/repository/downstreams/attendeeservice" + "github.com/stretchr/testify/require" + "net/http" + "testing" +) + +func TestRoomsRemoveOccupant_NotLoggedIn(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with an occupied bed") + location := setupExistingRoom(t, "31415", false, squirrel) + occupantLoc := fmt.Sprintf("%s/occupants/%d", location, squirrel.ID) + + docs.Given("Given an anonymous user") + token := tstNoToken() + + docs.When("When they try to remove the attendee from the room") + response := tstPerformDelete(occupantLoc, token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel) +} + +func TestRoomsRemoveOccupant_UserDenyOtherRemove(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with an occupied bed") + location := setupExistingRoom(t, "31415", false, snep) + occupantLoc := fmt.Sprintf("%s/occupants/%d", location, snep.ID) + + docs.Given("Given another user, who is not an admin") + token := tstValidUserToken(t, 101) + + docs.When("When they try to remove the other attendee from the room") + response := tstPerformDelete(occupantLoc, token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, snep) +} + +func TestRoomsRemoveOccupant_UserDenySelfRemove(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with an occupied bed") + location := setupExistingRoom(t, "31415", false, squirrel) + occupantLoc := fmt.Sprintf("%s/occupants/%d", location, squirrel.ID) + + docs.Given("Given the attendee occupying the bed") + token := tstValidUserToken(t, 101) + + docs.When("When they try to remove themselves from the room") + response := tstPerformDelete(occupantLoc, token) + + docs.Then("Then the request is denied") + tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel) +} + +func TestRoomsRemoveOccupant_AdminSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with occupied beds") + location := setupExistingRoom(t, "31415", false, squirrel, snep) + squirrelLoc := fmt.Sprintf("%s/occupants/%d", location, squirrel.ID) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When the admin removes the attendee from the room") + response := tstPerformDelete(squirrelLoc, token) + + docs.Then("Then the request is successful") + require.Equal(t, http.StatusNoContent, response.status) + + docs.Then("And the attendee has been removed from the room") + tstRoomState(t, location, snep) +} + +func TestRoomsRemoveOccupant_ApiTokenSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with occupied beds") + location := setupExistingRoom(t, "31415", false, squirrel, snep) + snepLoc := fmt.Sprintf("%s/occupants/%d", location, snep.ID) + + docs.Given("Given a downstream service using a valid api token") + token := tstValidApiToken() + + docs.When("When it removes an attendee from the room") + response := tstPerformDelete(snepLoc, token) + + docs.Then("Then the request is successful") + require.Equal(t, http.StatusNoContent, response.status) + + docs.Then("And the attendee has been removed from the room") + tstRoomState(t, location, squirrel) +} + +func TestRoomsRemoveOccupant_NotInRoom(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with occupied beds") + location := setupExistingRoom(t, "31415", false, squirrel) + + docs.Given("Given an attendee with an active registration who is not in any room") + registerSubject(subject(snep)) + snepLoc := fmt.Sprintf("%s/occupants/%d", location, snep.ID) // not actually in the room + + docs.When("When an admin tries to remove the attendee from the room (which they are not actually in)") + token := tstValidAdminToken(t) + response := tstPerformDelete(snepLoc, token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusNotFound, "room.occupant.notfound", "this attendee is not in any room") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel) +} + +func TestRoomsRemoveOccupant_InAnotherRoom(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with occupied beds") + location := setupExistingRoom(t, "31415", false, squirrel) + + docs.Given("Given an attendee with an active registration who is in another room") + _ = setupExistingRoom(t, "27182", false, snep) + + docs.When("When an admin tries to remove the attendee from the room (which they are not actually in)") + token := tstValidAdminToken(t) + wrongSnepLoc := fmt.Sprintf("%s/occupants/%d", location, snep.ID) // not actually in this room + response := tstPerformDelete(wrongSnepLoc, token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusConflict, "room.occupant.conflict", "this attendee is in a different room") + + docs.Then("And the room is unchanged") + tstRoomState(t, location, squirrel) +} + +func TestRoomsRemoveOccupant_RoomNotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to remove an attendee from a room, but specify a room that does not exist") + response := tstPerformDelete("/api/rest/v1/rooms/7a8d1116-d656-44eb-89dd-51eefef8a83b/occupants/84", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusNotFound, "room.id.notfound", "room does not exist") +} + +func TestRoomsRemoveOccupant_AttendeeNotFound(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with occupied beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they try to remove an attendee to the room, but specify a badge number that does not exist") + response := tstPerformDelete(location+"/occupants/4711", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.notfound", "no such attendee") +} + +func TestRoomsRemoveOccupant_CancelledRemoveSuccess(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with a bed occupied by a cancelled attendee") + location := setupExistingRoom(t, "31415", false, snep) + snepLoc := fmt.Sprintf("%s/occupants/%d", location, snep.ID) + // now cancel snep - has to be done after adding to room + attMock.SetupRegistered("202", 43, attendeeservice.StatusCancelled, "Snep", "snep@example.com") + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When the admin removes the attendee from the room") + response := tstPerformDelete(snepLoc, token) + + docs.Then("Then the request is successful, even though their registration is not in attending status") + require.Equal(t, http.StatusNoContent, response.status) + + docs.Then("And the attendee has been removed from the room") + tstRoomState(t, location) +} + +func TestRoomsRemoveOccupant_InvalidRoomID(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they attempt to remove an attendee from a room, but specify an invalid room id") + response := tstPerformDelete("/api/rest/v1/rooms/kittycats/occupants/84", token) + + docs.Then("Then the request fails with the expected error") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "room.id.invalid", "you must specify a valid uuid") +} + +func TestRoomsRemoveOccupant_BadgeNumberInvalid(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they attempt to remove an occupant from the room, but supply an invalid badge number") + response := tstPerformDelete(location+"/occupants/floof", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "request.parse.failed", "invalid badge number - must be positive integer") +} + +func TestRoomsRemoveOccupant_BadgeNumberNotPositive(t *testing.T) { + tstSetup(tstDefaultConfigFileRoomGroups) + defer tstShutdown() + + docs.Given("Given a room with free beds") + location := setupExistingRoom(t, "31415", false, snep) + + docs.Given("Given an admin") + token := tstValidAdminToken(t) + + docs.When("When they attempt to remove an occupant from the room, but supply a zero badge number") + response := tstPerformDelete(location+"/occupants/0", token) + + docs.Then("Then the request fails with the appropriate error message") + tstRequireErrorResponse(t, response, http.StatusBadRequest, "room.data.invalid", "invalid badge number - must be positive integer") +} + +// TODO technical errors (downstream failures etc.) diff --git a/test/acceptance/rooms_update_test.go b/test/acceptance/rooms_update_test.go index 8885858..91c8dae 100644 --- a/test/acceptance/rooms_update_test.go +++ b/test/acceptance/rooms_update_test.go @@ -14,7 +14,7 @@ func TestRoomsUpdate_NotLoggedIn(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "31415", squirrel) + location := setupExistingRoom(t, "31415", false, squirrel) room := tstReadRoom(t, location) docs.Given("Given an anonymous user") @@ -39,7 +39,7 @@ func TestRoomsUpdate_UserDeny(t *testing.T) { token := tstValidUserToken(t, 101) docs.Given("Given a room") - location := setupExistingRoom(t, "31415", squirrel, snep) + location := setupExistingRoom(t, "31415", false, squirrel, snep) room := tstReadRoom(t, location) docs.When("When they try to update the room") @@ -58,7 +58,7 @@ func TestRoomsUpdate_AdminSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "27182", squirrel, snep) + location := setupExistingRoom(t, "27182", false, squirrel, snep) room := tstReadRoom(t, location) docs.Given("Given an admin") @@ -82,7 +82,7 @@ func TestRoomsUpdate_ApiTokenSuccess(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "27182", squirrel, snep) + location := setupExistingRoom(t, "27182", false, squirrel, snep) room := tstReadRoom(t, location) docs.Given("Given a downstream service using a valid api token") @@ -106,7 +106,7 @@ func TestRoomsUpdate_TooSmall(t *testing.T) { defer tstShutdown() docs.Given("Given a room that is not empty") - location := setupExistingRoom(t, "31415", squirrel, snep) + location := setupExistingRoom(t, "31415", false, squirrel, snep) room := tstReadRoom(t, location) docs.Given("Given an admin") @@ -129,8 +129,8 @@ func TestRoomsUpdate_DuplicateName(t *testing.T) { defer tstShutdown() docs.Given("Given two rooms") - location1 := setupExistingRoom(t, "31415", squirrel) - location2 := setupExistingRoom(t, "27182", snep) + location1 := setupExistingRoom(t, "31415", false, squirrel) + location2 := setupExistingRoom(t, "27182", false, snep) room1 := tstReadRoom(t, location1) room2 := tstReadRoom(t, location2) @@ -153,7 +153,7 @@ func TestRoomsUpdate_InvalidID(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) room := tstReadRoom(t, location) docs.Given("Given an admin") @@ -173,7 +173,7 @@ func TestRoomsUpdate_InvalidBody(t *testing.T) { defer tstShutdown() docs.Given("Given a room") - location := setupExistingRoom(t, "31415") + location := setupExistingRoom(t, "31415", false) docs.Given("Given an admin") token := tstValidAdminToken(t) diff --git a/test/acceptance/testcase_attendees_test.go b/test/acceptance/testcase_attendees_test.go index d2a79b9..694da2c 100644 --- a/test/acceptance/testcase_attendees_test.go +++ b/test/acceptance/testcase_attendees_test.go @@ -22,7 +22,7 @@ var squirrel = modelsv1.Member{ // snep has attending status, subject "202" var snep = modelsv1.Member{ ID: 43, - Nickname: "Snep", + Nickname: "Snep", // popular abbreviation for Snow Leopard } // panther has non-attending status by default (but some test cases may set her up differently), subject "1234567890" @@ -44,17 +44,21 @@ func tstInfosBySubject(subject string) (string, string, string) { // --- test helper functions for use with these --- -func subject(member modelsv1.Member) string { +func subjectUint(member modelsv1.Member) uint { switch member.ID { case 42: - return "101" + return 101 case 43: - return "202" + return 202 default: - return "1234567890" + return 1234567890 } } +func subject(member modelsv1.Member) string { + return fmt.Sprintf("%d", subjectUint(member)) +} + func setupExistingGroup(t *testing.T, name string, public bool, subject string, additionalMemberSubjects ...string) string { flags := []string{} if public { @@ -83,13 +87,16 @@ func setupExistingGroup(t *testing.T, name string, public bool, subject string, return locs[len(locs)-1] } -func setupExistingRoom(t *testing.T, name string, occupants ...modelsv1.Member) string { +func setupExistingRoom(t *testing.T, name string, final bool, occupants ...modelsv1.Member) string { roomSent := modelsv1.RoomCreate{ Name: name, Flags: []string{}, Comments: p("A nice comment for " + name), Size: 2, } + if final { + roomSent.Flags = []string{"final"} + } response := tstPerformPost("/api/rest/v1/rooms", tstRenderJson(roomSent), tstValidAdminToken(t)) require.Equal(t, http.StatusCreated, response.status, "unexpected http response status") @@ -121,6 +128,11 @@ func registerSubject(subject string) int64 { } } +func tstRoomLocationToRoomID(location string) string { + locs := strings.Split(location, "/") + return locs[len(locs)-1] +} + func tstSetupBan(t *testing.T, groupId string, subject uint) string { t.Helper() diff --git a/test/acceptance/utils_test.go b/test/acceptance/utils_test.go index f6ae743..982ee92 100644 --- a/test/acceptance/utils_test.go +++ b/test/acceptance/utils_test.go @@ -194,7 +194,7 @@ func tstRequireErrorResponse(t *testing.T, response tstWebResponse, expectedStat require.Equal(t, expectedStatus, response.status, "unexpected http response status") errorDto := modelsv1.Error{} tstParseJson(response.body, &errorDto) - require.Equal(t, expectedMessage, string(errorDto.Message), "unexpected error code") + require.Equal(t, expectedMessage, errorDto.Message, "unexpected error code") expectedDetailsStr, ok := expectedDetails.(string) if ok && expectedDetailsStr != "" { require.EqualValues(t, url.Values{"details": []string{expectedDetailsStr}}, errorDto.Details, "unexpected error details")