From ae5505c9e32b12870009287c47194af190dce1d5 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Thu, 2 Feb 2023 00:15:37 +0000 Subject: [PATCH 1/6] Added `WS /profile/live/leaderboard/` --- gateway/services/profile.go | 10 ++ go.mod | 3 +- go.sum | 4 +- services/profile/controller/controller.go | 31 ++++- services/profile/service/profile_service.go | 8 +- services/profile/service/ws_service.go | 134 ++++++++++++++++++++ 6 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 services/profile/service/ws_service.go diff --git a/gateway/services/profile.go b/gateway/services/profile.go index 5969ef4a..55301b39 100644 --- a/gateway/services/profile.go +++ b/gateway/services/profile.go @@ -49,6 +49,12 @@ var ProfileRoutes = arbor.RouteCollection{ "/profile/leaderboard/", alice.New(middleware.IdentificationMiddleware).ThenFunc(GetProfileLeaderboard).ServeHTTP, }, + arbor.Route{ + "GetProfileLeaderboard", + "GET", + "/profile/live/leaderboard/", + alice.New(middleware.IdentificationMiddleware).ThenFunc(GetLiveProfileLeaderboard).ServeHTTP, + }, arbor.Route{ "GetValidFilteredProfiles", "GET", @@ -132,6 +138,10 @@ func GetProfileLeaderboard(w http.ResponseWriter, r *http.Request) { arbor.GET(w, config.PROFILE_SERVICE+r.URL.String(), ProfileFormat, "", r) } +func GetLiveProfileLeaderboard(w http.ResponseWriter, r *http.Request) { + arbor.ProxyWebsocket(w, config.PROFILE_SERVICE+r.URL.String(), ProfileFormat, "", r) +} + func RedeemEvent(w http.ResponseWriter, r *http.Request) { arbor.POST(w, config.PROFILE_SERVICE+r.URL.String(), ProfileFormat, "", r) } diff --git a/go.mod b/go.mod index fabc7082..00375d75 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/HackIllinois/api -replace github.com/arbor-dev/arbor => github.com/HackIllinois/arbor v0.3.1-0.20220625214746-96b56633f2e3 +replace github.com/arbor-dev/arbor => github.com/HackIllinois/arbor v0.3.1-0.20230201230413-b6f79f9e5736 require ( github.com/arbor-dev/arbor v0.3.0 @@ -9,6 +9,7 @@ require ( github.com/go-playground/validator/v10 v10.10.0 github.com/golang-jwt/jwt/v4 v4.4.3 github.com/gorilla/mux v1.6.2 + github.com/gorilla/websocket v1.5.0 github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da github.com/levigross/grequests v0.0.0-20181123014746-f3f67e7783bb github.com/prometheus/client_golang v1.11.0 diff --git a/go.sum b/go.sum index 7cf3fa08..26b25629 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/HackIllinois/arbor v0.3.1-0.20220625214746-96b56633f2e3 h1:6OwmTZCWPOFEjRilwzMMD++PwjJfR9+hG3EXWR/EEAc= -github.com/HackIllinois/arbor v0.3.1-0.20220625214746-96b56633f2e3/go.mod h1:CilbmeWfrwvOtRRxg4oQs5NpGtklqUWCQvW9rxw+Xfk= +github.com/HackIllinois/arbor v0.3.1-0.20230201230413-b6f79f9e5736 h1:qtfomoIx6ggEIOR5U5zinYMu1ZsueqNYHBRd6pd7ttA= +github.com/HackIllinois/arbor v0.3.1-0.20230201230413-b6f79f9e5736/go.mod h1:CilbmeWfrwvOtRRxg4oQs5NpGtklqUWCQvW9rxw+Xfk= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= diff --git a/services/profile/controller/controller.go b/services/profile/controller/controller.go index e510aab6..2c271231 100644 --- a/services/profile/controller/controller.go +++ b/services/profile/controller/controller.go @@ -3,6 +3,7 @@ package controller import ( "encoding/json" "net/http" + "strconv" "github.com/HackIllinois/api/common/errors" "github.com/HackIllinois/api/common/metrics" @@ -11,10 +12,14 @@ import ( "github.com/HackIllinois/api/services/profile/models" "github.com/HackIllinois/api/services/profile/service" "github.com/gorilla/mux" + "github.com/gorilla/websocket" "github.com/prometheus/client_golang/prometheus/promhttp" ) +var wsUpgrader = websocket.Upgrader{} + func SetupController(route *mux.Route) { + go service.LiveLeaderboardManager() router := route.Subrouter() router.Handle("/internal/metrics/", promhttp.Handler()).Methods("GET") @@ -26,6 +31,7 @@ func SetupController(route *mux.Route) { metrics.RegisterHandler("/list/", GetFilteredProfiles, "GET", router) metrics.RegisterHandler("/leaderboard/", GetProfileLeaderboard, "GET", router) + metrics.RegisterHandler("/live/leaderboard/", GetLiveProfileLeaderboard, "GET", router) metrics.RegisterHandler("/search/", GetValidFilteredProfiles, "GET", router) metrics.RegisterHandler("/event/checkin/", RedeemEvent, "POST", router) @@ -212,6 +218,28 @@ func GetProfileLeaderboard(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(user_profile_list) } +func GetLiveProfileLeaderboard(w http.ResponseWriter, r *http.Request) { + parameters := r.URL.Query() + + limit_param, ok := parameters["limit"] + + if !ok { + limit_param = []string{"0"} + } + + limit, err := strconv.Atoi(limit_param[0]) + if err != nil { + errors.WriteError(w, r, errors.MalformedRequestError(err.Error(), "Limit was not correctly set")) + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + errors.WriteError(w, r, errors.InternalError(err.Error(), "Failed to upgrade to websocket connection")) + } + + service.HandleIncomingLeaderboardWS(conn, limit) +} + /* Filters the profiles by TeamStatus and Interests */ @@ -301,8 +329,9 @@ func AwardPoints(w http.ResponseWriter, r *http.Request) { return } - updated_profile, err := service.GetProfile(profile_id) + service.ChanUpdateLiveLeaderboard <- struct{}{} + updated_profile, err := service.GetProfile(profile_id) if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get updated profile details after awarding points.")) return diff --git a/services/profile/service/profile_service.go b/services/profile/service/profile_service.go index 497c8750..b920295e 100644 --- a/services/profile/service/profile_service.go +++ b/services/profile/service/profile_service.go @@ -217,11 +217,14 @@ func GetProfileLeaderboard(parameters map[string][]string) (*models.LeaderboardE } limit, err := strconv.Atoi(limit_param[0]) - if err != nil { return nil, errors.New("Could not convert 'limit' to int.") } + return GetProfileLeaderboardWithLimit(limit) +} + +func GetProfileLeaderboardWithLimit(limit int) (*models.LeaderboardEntryList, error) { leaderboard_entries := []models.LeaderboardEntry{} sort_field := bson.D{ @@ -231,8 +234,7 @@ func GetProfileLeaderboard(parameters map[string][]string) (*models.LeaderboardE }, } - err = db.FindAllSorted("profiles", nil, sort_field, &leaderboard_entries, nil) - + err := db.FindAllSorted("profiles", nil, sort_field, &leaderboard_entries, nil) if err != nil { return nil, err } diff --git a/services/profile/service/ws_service.go b/services/profile/service/ws_service.go new file mode 100644 index 00000000..51f3c370 --- /dev/null +++ b/services/profile/service/ws_service.go @@ -0,0 +1,134 @@ +package service + +import ( + "log" + "time" + + "github.com/HackIllinois/api/services/profile/models" + "github.com/gorilla/websocket" +) + +type LiveLeaderboardConnection struct { + Conn *websocket.Conn `json:"-"` + Limit int `json:"limit"` +} + +var ( + leaderboardConnections map[*websocket.Conn]LiveLeaderboardConnection = make( + map[*websocket.Conn]LiveLeaderboardConnection, + ) + leaderboardUpdateCooldown = 1 * time.Second + leaderboardInactiveUpdate = 5 * time.Minute + ChanUpdateLiveLeaderboard = make(chan struct{}, 32) +) + +func LiveLeaderboardManager() { + for { + select { + case <-ChanUpdateLiveLeaderboard: + case <-time.After(leaderboardInactiveUpdate): // failsafe case + } + // send updated leaderboard to websocket + go BroadcastUpdatedLeaderboard() + + // Prevents spamming websockets + cooldown_ticker := time.NewTicker(leaderboardUpdateCooldown) + reupdate_after_cooldown := false + cooldown: + for { + select { + case <-ChanUpdateLiveLeaderboard: + reupdate_after_cooldown = true + case <-cooldown_ticker.C: + break cooldown + } + } + if reupdate_after_cooldown { + ChanUpdateLiveLeaderboard <- struct{}{} + } + } +} + +func BroadcastUpdatedLeaderboard() error { + full_leaderboard, err := GetProfileLeaderboard(make(map[string][]string)) + if err != nil { + return err + } + + for _, connection_info := range leaderboardConnections { + leaderboard_to_send := *full_leaderboard + if connection_info.Limit > 0 { + leaderboard_to_send.LeaderboardEntries = leaderboard_to_send.LeaderboardEntries[:connection_info.Limit] + } + go SendUpdatedLeaderboard(connection_info.Conn, leaderboard_to_send) + } + + return nil +} + +// The leaderboard size should be no more than the limit that the client wants +func SendUpdatedLeaderboard(conn *websocket.Conn, leaderboard models.LeaderboardEntryList) { + err := conn.WriteJSON(leaderboard) + if err != nil { + log.Printf("error occurred on writing to ws: %v", err) + // Note: We will not close to socket as the read handler (HandleIncomingLeaderboardWS) will + // do that for us + } +} + +func HandleIncomingLeaderboardWS(conn *websocket.Conn, limit int) { + defer conn.Close() + + leaderboardConnections[conn] = LiveLeaderboardConnection{ + Conn: conn, + Limit: limit, + } + + defer delete(leaderboardConnections, conn) + + leaderboard, err := GetProfileLeaderboardWithLimit(limit) + if err != nil { + conn.WriteMessage( + websocket.CloseInternalServerErr, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "Could not fetch leaderboard"), + ) + return + } + + err = conn.WriteJSON(leaderboard) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("Connection failed before we could send back leadderboard", err) + } + return + } + + for { + var updated_settings LiveLeaderboardConnection + err := conn.ReadJSON(&updated_settings) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("IsUnexpectedCloseError()", err) + } + conn.Close() + break + } + + updated_settings.Conn = conn + leaderboardConnections[conn] = updated_settings + + leaderboard, err := GetProfileLeaderboardWithLimit(updated_settings.Limit) + if err != nil { + conn.WriteMessage( + websocket.CloseInternalServerErr, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "Could not fetch leaderboard"), + ) + return + } + + err = conn.WriteJSON(leaderboard) + if err != nil { + return + } + } +} From 0dd0f1e9909ee8b9de39447faa33fb892818ede4 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Thu, 2 Feb 2023 00:27:20 +0000 Subject: [PATCH 2/6] Added `WS /profile/live/leaderboard/` to docs --- .../docs/reference/services/Profile.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/documentation/docs/reference/services/Profile.md b/documentation/docs/reference/services/Profile.md index b7f322a2..4a85cb32 100644 --- a/documentation/docs/reference/services/Profile.md +++ b/documentation/docs/reference/services/Profile.md @@ -203,6 +203,47 @@ Request requires no body. } ``` +WS /profile/live/leaderboard/?limit= +------------------------- + +!!! note + This is a public endpoint + +This endpoint expects a websocket connection. + +Returns a list of profiles sorted by points descending. If a `limit` parameter is provided, it will +return the first `limit` profiles. Otherwise, it will return all of the profiles. + +Once, the connection has been established, `limit` can be easily changed by simply sending the key +in a JSON. + +The API will send back a list of profiles whenever a user gets awarded points (ratelimited to 1 +response per second) or if 5 minutes elapse with no activity (i.e. no point values change). The +latter is more or less a failsafe in order to ensure the leaderboard is the most up-to-date. + +```json title="Exmaple WS message request" +{ + "limit": 20 +} +``` + +```json title="Example WS response" +{ + "profiles": [ + { + "id": "profileid123456", + "points": 2021, + "discord": "patrick#1234" + }, + { + "id": "profileid123456", + "points": 2021, + "discord": "patrick#1234" + }, + ] +} +``` + GET /profile/search/?teamStatus=value&interests=value,value,value&limit=value ------------------------- From 1ef3292c2c82db51c86ae2c12ec2e58d79087cfc Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Thu, 2 Feb 2023 00:29:18 +0000 Subject: [PATCH 3/6] Ran GoFmt --- gateway/services/profile.go | 48 +++++++++++++++------ services/profile/controller/controller.go | 38 ++++++++-------- services/profile/service/profile_service.go | 13 ------ 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/gateway/services/profile.go b/gateway/services/profile.go index 55301b39..880ab0a5 100644 --- a/gateway/services/profile.go +++ b/gateway/services/profile.go @@ -17,31 +17,41 @@ var ProfileRoutes = arbor.RouteCollection{ "GetCurrentUserProfile", "GET", "/profile/", - alice.New(middleware.IdentificationMiddleware, middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole})).ThenFunc(GetProfile).ServeHTTP, + alice.New(middleware.IdentificationMiddleware, middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole})). + ThenFunc(GetProfile). + ServeHTTP, }, arbor.Route{ "CreateCurrentUserProfile", "POST", "/profile/", - alice.New(middleware.IdentificationMiddleware, middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole})).ThenFunc(CreateProfile).ServeHTTP, + alice.New(middleware.IdentificationMiddleware, middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole})). + ThenFunc(CreateProfile). + ServeHTTP, }, arbor.Route{ "UpdateCurrentUserProfile", "PUT", "/profile/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(UpdateProfile).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(UpdateProfile). + ServeHTTP, }, arbor.Route{ "DeleteCurrentUserProfile", "DELETE", "/profile/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole}), middleware.IdentificationMiddleware).ThenFunc(DeleteProfile).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole}), middleware.IdentificationMiddleware). + ThenFunc(DeleteProfile). + ServeHTTP, }, arbor.Route{ "GetAllProfiles", "GET", "/profile/list/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware).ThenFunc(GetFilteredProfiles).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware). + ThenFunc(GetFilteredProfiles). + ServeHTTP, }, arbor.Route{ "GetProfileLeaderboard", @@ -59,37 +69,49 @@ var ProfileRoutes = arbor.RouteCollection{ "GetValidFilteredProfiles", "GET", "/profile/search/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(GetValidFilteredProfiles).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(GetValidFilteredProfiles). + ServeHTTP, }, arbor.Route{ "RedeemEvent", "POST", "/profile/event/checkin/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware).ThenFunc(RedeemEvent).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware). + ThenFunc(RedeemEvent). + ServeHTTP, }, arbor.Route{ "AwardPoints", "POST", "/profile/points/award/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware).ThenFunc(AwardPoints).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.StaffRole}), middleware.IdentificationMiddleware). + ThenFunc(AwardPoints). + ServeHTTP, }, arbor.Route{ "GetProfileFavorites", "GET", "/profile/favorite/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(GetProfileFavorites).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(GetProfileFavorites). + ServeHTTP, }, arbor.Route{ "AddProfileFavorite", "POST", "/profile/favorite/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(AddProfileFavorite).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(AddProfileFavorite). + ServeHTTP, }, arbor.Route{ "RemoveProfileFavorite", "DELETE", "/profile/favorite/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(RemoveProfileFavorite).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(RemoveProfileFavorite). + ServeHTTP, }, arbor.Route{ "GetTierThresholds", @@ -102,7 +124,9 @@ var ProfileRoutes = arbor.RouteCollection{ "GetUserProfileById", "GET", "/profile/{id}/", - alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware).ThenFunc(GetProfileById).ServeHTTP, + alice.New(middleware.AuthMiddleware([]authtoken.Role{authtoken.AdminRole, authtoken.AttendeeRole, authtoken.ApplicantRole, authtoken.StaffRole, authtoken.MentorRole}), middleware.IdentificationMiddleware). + ThenFunc(GetProfileById). + ServeHTTP, }, } diff --git a/services/profile/controller/controller.go b/services/profile/controller/controller.go index 2c271231..4af4a643 100644 --- a/services/profile/controller/controller.go +++ b/services/profile/controller/controller.go @@ -53,14 +53,12 @@ func GetProfile(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("HackIllinois-Identity") profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return } user_profile, err := service.GetProfile(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get current user's profile.")) return @@ -76,7 +74,6 @@ func GetProfileById(w http.ResponseWriter, r *http.Request) { profile_id := mux.Vars(r)["id"] user_profile, err := service.GetProfile(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile for profile id "+profile_id)) return @@ -116,7 +113,6 @@ func CreateProfile(w http.ResponseWriter, r *http.Request) { } created_profile, err := service.GetProfile(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get created profile.")) return @@ -137,7 +133,6 @@ func UpdateProfile(w http.ResponseWriter, r *http.Request) { } profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return @@ -147,7 +142,6 @@ func UpdateProfile(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&profile) old_profile, err := service.GetProfile(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile associated with this profile id.")) return @@ -165,7 +159,6 @@ func UpdateProfile(w http.ResponseWriter, r *http.Request) { } updated_profile, err := service.GetProfile(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get updated profile details.")) return @@ -281,16 +274,21 @@ func RedeemEvent(w http.ResponseWriter, r *http.Request) { id := request.ID profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return } redemption_status, err := service.RedeemEvent(profile_id, request.EventID) - if err != nil { - errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not check if event was redeemed for id "+request.ID+" and event id "+request.EventID+". "+redemption_status.Status)) + errors.WriteError( + w, + r, + errors.DatabaseError( + err.Error(), + "Could not check if event was redeemed for id "+request.ID+" and event id "+request.EventID+". "+redemption_status.Status, + ), + ) return } @@ -307,16 +305,18 @@ func AwardPoints(w http.ResponseWriter, r *http.Request) { id := request.ID profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return } user_profile, err := service.GetProfile(profile_id) - if err != nil { - errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile for id "+request.ID+" when trying to award points.")) + errors.WriteError( + w, + r, + errors.DatabaseError(err.Error(), "Could not get profile for id "+request.ID+" when trying to award points."), + ) return } @@ -333,7 +333,11 @@ func AwardPoints(w http.ResponseWriter, r *http.Request) { updated_profile, err := service.GetProfile(profile_id) if err != nil { - errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get updated profile details after awarding points.")) + errors.WriteError( + w, + r, + errors.DatabaseError(err.Error(), "Could not get updated profile details after awarding points."), + ) return } @@ -352,14 +356,12 @@ func GetProfileFavorites(w http.ResponseWriter, r *http.Request) { } profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return } favorites, err := service.GetProfileFavorites(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get user's profile favorites.")) return @@ -380,7 +382,6 @@ func AddProfileFavorite(w http.ResponseWriter, r *http.Request) { } profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return @@ -397,7 +398,6 @@ func AddProfileFavorite(w http.ResponseWriter, r *http.Request) { } favorites, err := service.GetProfileFavorites(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get updated user profile favorites.")) return @@ -418,7 +418,6 @@ func RemoveProfileFavorite(w http.ResponseWriter, r *http.Request) { } profile_id, err := service.GetProfileIdFromUserId(id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get profile id associated with the user")) return @@ -435,7 +434,6 @@ func RemoveProfileFavorite(w http.ResponseWriter, r *http.Request) { } favorites, err := service.GetProfileFavorites(profile_id) - if err != nil { errors.WriteError(w, r, errors.DatabaseError(err.Error(), "Could not get updated user profile favorites.")) return diff --git a/services/profile/service/profile_service.go b/services/profile/service/profile_service.go index b920295e..1808995b 100644 --- a/services/profile/service/profile_service.go +++ b/services/profile/service/profile_service.go @@ -44,7 +44,6 @@ func GetProfileIdFromUserId(id string) (string, error) { var id_map models.IdMap err := db.FindOne("profileids", query, &id_map, nil) - // Returns error if no mapping was found if err != nil { return "", err @@ -63,7 +62,6 @@ func GetProfile(profile_id string) (*models.Profile, error) { var profile models.Profile err := db.FindOne("profiles", query, &profile, nil) - if err != nil { return nil, err } @@ -79,7 +77,6 @@ func GetProfile(profile_id string) (*models.Profile, error) { func DeleteProfile(profile_id string) (*models.Profile, error) { // Gets profile to be able to return it later profile, err := GetProfile(profile_id) - if err != nil { return nil, err } @@ -127,7 +124,6 @@ func DeleteProfile(profile_id string) (*models.Profile, error) { func CreateProfile(id string, profile_id string, profile models.Profile) error { profile.ID = profile_id err := validate.Struct(profile) - if err != nil { return err } @@ -191,7 +187,6 @@ func CreateProfile(id string, profile_id string, profile models.Profile) error { func UpdateProfile(profile_id string, profile models.Profile) error { profile.ID = profile_id err := validate.Struct(profile) - if err != nil { return err } @@ -262,7 +257,6 @@ func GetFilteredProfiles(parameters map[string][]string) (*models.ProfileList, e } limit, err := strconv.Atoi(limit_param[0]) - if err != nil { return nil, errors.New("Could not convert 'limit' to int.") } @@ -271,7 +265,6 @@ func GetFilteredProfiles(parameters map[string][]string) (*models.ProfileList, e delete(parameters, "limit") query, err := database.CreateFilterQuery(parameters, models.Profile{}) - if err != nil { return nil, err } @@ -303,7 +296,6 @@ func GetFilteredProfiles(parameters map[string][]string) (*models.ProfileList, e */ func GetValidFilteredProfiles(parameters map[string][]string) (*models.ProfileList, error) { filtered_profile_list, err := GetFilteredProfiles(parameters) - if err != nil { return nil, errors.New("Could not get filtered profiles") } @@ -324,7 +316,6 @@ func RedeemEvent(profile_id string, event_id string) (*models.RedeemEventRespons var attended_events models.AttendanceTracker err := db.FindOne("profileattendance", selector, &attended_events, nil) - if err != nil { if err == database.ErrNotFound { attended_events = models.AttendanceTracker{ @@ -365,7 +356,6 @@ func GetProfileFavorites(profile_id string) (*models.ProfileFavorites, error) { var profile_favorites models.ProfileFavorites err := db.FindOne("profilefavorites", query, &profile_favorites, nil) - if err != nil { return nil, err } @@ -386,13 +376,11 @@ func AddProfileFavorite(profile_id string, profile string) error { } _, err := GetProfile(profile) - if err != nil { return errors.New("Could not find profile with the given id.") } profile_favorites, err := GetProfileFavorites(profile_id) - if err != nil { return err } @@ -415,7 +403,6 @@ func RemoveProfileFavorite(profile_id string, profile string) error { } profile_favorites, err := GetProfileFavorites(profile_id) - if err != nil { return err } From c78a2e83b2adec9caf228efe1570a81cdb90c7da Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Thu, 2 Feb 2023 00:41:36 +0000 Subject: [PATCH 4/6] Renamed profile live leaderboard gateway endpoint --- gateway/services/profile.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/services/profile.go b/gateway/services/profile.go index 880ab0a5..caebc4e4 100644 --- a/gateway/services/profile.go +++ b/gateway/services/profile.go @@ -60,7 +60,7 @@ var ProfileRoutes = arbor.RouteCollection{ alice.New(middleware.IdentificationMiddleware).ThenFunc(GetProfileLeaderboard).ServeHTTP, }, arbor.Route{ - "GetProfileLeaderboard", + "WSLiveProfileLeaderboard", "GET", "/profile/live/leaderboard/", alice.New(middleware.IdentificationMiddleware).ThenFunc(GetLiveProfileLeaderboard).ServeHTTP, From 3d81a19f84d6ed1c64f49b61c1d1a0b232a5627e Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 22 Feb 2023 08:16:02 +0000 Subject: [PATCH 5/6] Added E2E tests for `WS /profile/live/leaderboard/` --- tests/e2e/profile_test/profile_test.go | 9 + tests/e2e/profile_test/ws_leaderboard_test.go | 315 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 tests/e2e/profile_test/ws_leaderboard_test.go diff --git a/tests/e2e/profile_test/profile_test.go b/tests/e2e/profile_test/profile_test.go index 360398fd..917f7cc9 100644 --- a/tests/e2e/profile_test/profile_test.go +++ b/tests/e2e/profile_test/profile_test.go @@ -18,6 +18,7 @@ var ( admin_client *sling.Sling client *mongo.Client profile_db_name string + event_db_name string unauthenticated_client *sling.Sling ) @@ -38,6 +39,13 @@ func TestMain(m *testing.M) { fmt.Printf("ERROR: %v\n", err) os.Exit(1) } + + event_db_name, err = cfg.Get("EVENT_DB_NAME") + if err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + DropDatabases() return_code := m.Run() @@ -46,6 +54,7 @@ func TestMain(m *testing.M) { func DropDatabases() { client.Database(profile_db_name).Drop(context.Background()) + client.Database(event_db_name).Drop(context.Background()) } func CheckDatabaseProfileNotFound(t *testing.T, filter bson.M) { diff --git a/tests/e2e/profile_test/ws_leaderboard_test.go b/tests/e2e/profile_test/ws_leaderboard_test.go new file mode 100644 index 00000000..176db477 --- /dev/null +++ b/tests/e2e/profile_test/ws_leaderboard_test.go @@ -0,0 +1,315 @@ +package tests + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "reflect" + "sort" + "testing" + "time" + + api_errors "github.com/HackIllinois/api/common/errors" + event_models "github.com/HackIllinois/api/services/event/models" + profile_models "github.com/HackIllinois/api/services/profile/models" + "github.com/gorilla/websocket" + "go.mongodb.org/mongo-driver/bson" +) + +var ( + current_time = time.Now() + expected_full_leaderboard = []profile_models.LeaderboardEntry{ + { + ID: "testuser5", + Points: 13337, + Discord: "testuser#0005", + }, + { + ID: "testuser19", + Points: 2050, + Discord: "testuser#0019", + }, + { + ID: "testuser0", + Points: 1337, + Discord: "testuser#0000", + }, + { + ID: "testuser13", + Points: 1001, + Discord: "testuser#0013", + }, + { + ID: "testuser4", + Points: 1000, + Discord: "testuser#0004", + }, + { + ID: "testuser8", + Points: 990, + Discord: "testuser#0008", + }, + { + ID: "testuser6", + Points: 500, + Discord: "testuser#0006", + }, + { + ID: "testuser7", + Points: 469, + Discord: "testuser#0007", + }, + { + ID: "testuser1", + Points: 420, + Discord: "testuser#0001", + }, + { + ID: "testuser12", + Points: 401, + Discord: "testuser#0012", + }, + } +) + +func CreateProfiles() { + point_values := []int{1337, 420, 100, 0, 1000, 13337, 500, 469, 990, 320, 0, 400, 401, 1001, 42, 10, 0, 99, 120, 2050} + for i := 0; i < len(point_values); i++ { + id := fmt.Sprintf("testuser%d", i) + profile := profile_models.Profile{ + ID: id, + FirstName: fmt.Sprintf("Test%d", i), + LastName: fmt.Sprintf("User%d", i), + Points: point_values[i], + Discord: fmt.Sprintf("testuser#%04d", i), + AvatarUrl: "someimage.uri", + } + + profile_attendance := profile_models.AttendanceTracker{ + ID: id, + Events: []string{}, + } + + profile_id_map := profile_models.IdMap{ + UserID: id, + ProfileID: id, + } + + client.Database(profile_db_name).Collection("profiles").InsertOne(context.Background(), profile) + client.Database(profile_db_name).Collection("profileattendance").InsertOne(context.Background(), profile_attendance) + client.Database(profile_db_name).Collection("profileids").InsertOne(context.Background(), profile_id_map) + } +} + +func CreateEvents() { + test_event := event_models.EventDB{ + EventPublic: event_models.EventPublic{ + ID: "testevent12345", + Name: "An epic workshop", + Description: "This is an epic workshop that you can learn from!", + StartTime: current_time.Unix(), + EndTime: current_time.Add(time.Hour).Unix(), + Locations: []event_models.EventLocation{ + { + Description: "Siebel Center for Computer Science Room 2404", + Tags: []string{"SEIBEL2"}, + Latitude: 40.1138038, + Longitude: -88.2254524, + }, + }, + Sponsor: "", + + EventType: "WORKSHOP", + Points: 200, + IsAsync: false, + }, + IsPrivate: false, + DisplayOnStaffCheckin: false, + } + + test_eventcode := event_models.EventCode{ + ID: "testevent12345", + Code: "abc123", + Expiration: current_time.Add(time.Hour).Unix(), + } + client.Database(event_db_name).Collection("events").InsertOne(context.Background(), test_event) + client.Database(event_db_name).Collection("eventcodes").InsertOne(context.Background(), test_eventcode) +} + +func ClearEvents() { + client.Database(event_db_name).Collection("events").DeleteMany(context.Background(), bson.D{}) + client.Database(event_db_name).Collection("eventcodes").InsertOne(context.Background(), bson.D{}) +} + +func ClearProfiles() { + client.Database(profile_db_name).Collection("profiles").DeleteMany(context.Background(), bson.D{}) + client.Database(profile_db_name).Collection("profileattendance").DeleteOne(context.Background(), bson.D{}) + client.Database(profile_db_name).Collection("profileids").DeleteOne(context.Background(), bson.D{}) +} + +func TestWSLeaderboardOneConnection(t *testing.T) { + CreateProfiles() + defer ClearProfiles() + limit := 10 + u := url.URL{ + Scheme: "ws", + Host: "localhost:8000", + Path: "/profile/live/leaderboard/", + RawQuery: fmt.Sprintf("limit=%d", limit), + } + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatal(err) + } + + defer c.Close() + + msg_type, message, err := c.ReadMessage() + if err != nil { + t.Fatal(err) + } + + if msg_type != websocket.TextMessage { + t.Fatalf("Message recieved was not of type text message (got %v)", msg_type) + } + + var leaderboard profile_models.LeaderboardEntryList + + err = json.Unmarshal(message, &leaderboard) + if err != nil { + t.Fatal(err) + } + + expected_leaderboard := profile_models.LeaderboardEntryList{ + LeaderboardEntries: expected_full_leaderboard[:limit], + } + + if !reflect.DeepEqual(expected_leaderboard, leaderboard) { + t.Errorf("Wrong leaderboard. Expected %v, got %v", expected_leaderboard, leaderboard) + } +} + +func TestWSLeaderboardOnRedeem(t *testing.T) { + CreateProfiles() + CreateEvents() + defer ClearProfiles() + defer ClearEvents() + limit := 10 + u := url.URL{ + Scheme: "ws", + Host: "localhost:8000", + Path: "/profile/live/leaderboard/", + RawQuery: fmt.Sprintf("limit=%d", limit), + } + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatal(err) + } + + defer c.Close() + + msg_type, message, err := c.ReadMessage() + if err != nil { + t.Fatal(err) + } + + if msg_type != websocket.TextMessage { + t.Fatalf("Message recieved was not of type text message (got %v)", msg_type) + } + + var leaderboard profile_models.LeaderboardEntryList + + err = json.Unmarshal(message, &leaderboard) + if err != nil { + t.Fatal(err) + } + + expected_leaderboard := profile_models.LeaderboardEntryList{ + LeaderboardEntries: expected_full_leaderboard[:limit], + } + + if !reflect.DeepEqual(expected_leaderboard, leaderboard) { + t.Errorf("Wrong leaderboard. Expected %v, got %v", expected_leaderboard, leaderboard) + } + + // Attendee redeems event + req := event_models.CheckinRequest{ + Code: "abc123", + } + id := "testuser11" + received_res := event_models.CheckinResponse{} + api_err := api_errors.ApiError{} + response, err := admin_client.New(). + Post("/event/checkin/"). + Set("HackIllinois-Impersonation", id). + BodyJSON(req). + Receive(&received_res, &api_err) + if err != nil { + t.Fatal("Unable to make request") + return + } + if response.StatusCode != http.StatusOK { + t.Fatalf("Request returned HTTP error %d, %v", response.StatusCode, api_err) + return + } + + expected_res := event_models.CheckinResponse{ + NewPoints: 200, + TotalPoints: 600, + Status: "Success", + } + + if !reflect.DeepEqual(received_res, expected_res) { + t.Fatalf("Wrong result received. Expected %v, got %v", expected_res, received_res) + } + + res := client.Database(profile_db_name).Collection("profiles").FindOne(context.Background(), bson.M{"id": id}) + + profile := profile_models.Profile{} + err = res.Decode(&profile) + + if err != nil { + t.Fatalf("Had trouble finding profile in database: %v", err) + return + } + + if expected_res.TotalPoints != profile.Points { + t.Fatalf("Wrong amount of points in profile database. Expected %v, got %v", expected_res.TotalPoints, profile.Points) + } + + // Read from leaderboard socket (because the leaderboard should be updated) + msg_type, message, err = c.ReadMessage() + if err != nil { + t.Fatal(err) + } + + if msg_type != websocket.TextMessage { + t.Fatalf("Message recieved was not of type text message (got %v)", msg_type) + } + + err = json.Unmarshal(message, &leaderboard) + if err != nil { + t.Fatal(err) + } + + modified_leaderboard := append( + expected_full_leaderboard[:limit], + profile_models.LeaderboardEntry{ + ID: "testuser11", + Points: 600, + Discord: "testuser#0011", + }, + ) + sort.Slice(modified_leaderboard[:], func(i, j int) bool { + return modified_leaderboard[i].Points > modified_leaderboard[j].Points + }) + expected_leaderboard = profile_models.LeaderboardEntryList{ + LeaderboardEntries: modified_leaderboard[:limit], + } + + if !reflect.DeepEqual(expected_leaderboard, leaderboard) { + t.Errorf("Wrong leaderboard. Expected %v, got %v", expected_leaderboard, leaderboard) + } +} From 392c8746a1d71f5c6f75b4f68c378bf39a1c415e Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 22 Feb 2023 08:34:02 +0000 Subject: [PATCH 6/6] Added E2E test for updating live leaderboard limit --- tests/e2e/profile_test/ws_leaderboard_test.go | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/e2e/profile_test/ws_leaderboard_test.go b/tests/e2e/profile_test/ws_leaderboard_test.go index 176db477..13585096 100644 --- a/tests/e2e/profile_test/ws_leaderboard_test.go +++ b/tests/e2e/profile_test/ws_leaderboard_test.go @@ -14,6 +14,7 @@ import ( api_errors "github.com/HackIllinois/api/common/errors" event_models "github.com/HackIllinois/api/services/event/models" profile_models "github.com/HackIllinois/api/services/profile/models" + profile_service "github.com/HackIllinois/api/services/profile/service" "github.com/gorilla/websocket" "go.mongodb.org/mongo-driver/bson" ) @@ -71,6 +72,56 @@ var ( Points: 401, Discord: "testuser#0012", }, + { + ID: "testuser11", + Points: 400, + Discord: "testuser#0011", + }, + { + ID: "testuser9", + Points: 320, + Discord: "testuser#0009", + }, + { + ID: "testuser18", + Points: 120, + Discord: "testuser#0018", + }, + { + ID: "testuser2", + Points: 100, + Discord: "testuser#0002", + }, + { + ID: "testuser17", + Points: 99, + Discord: "testuser#0017", + }, + { + ID: "testuser14", + Points: 42, + Discord: "testuser#0014", + }, + { + ID: "testuser15", + Points: 10, + Discord: "testuser#0015", + }, + { + ID: "testuser3", + Points: 0, + Discord: "testuser#0003", + }, + { + ID: "testuser7", + Points: 0, + Discord: "testuser#0007", + }, + { + ID: "testuser16", + Points: 0, + Discord: "testuser#0016", + }, } ) @@ -191,6 +242,76 @@ func TestWSLeaderboardOneConnection(t *testing.T) { } } +func TestWSLeaderboardUpdateLimitAfterConnect(t *testing.T) { + CreateProfiles() + defer ClearProfiles() + limit := 3 + u := url.URL{ + Scheme: "ws", + Host: "localhost:8000", + Path: "/profile/live/leaderboard/", + RawQuery: fmt.Sprintf("limit=%d", limit), + } + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + t.Fatal(err) + } + + defer c.Close() + + msg_type, message, err := c.ReadMessage() + if err != nil { + t.Fatal(err) + } + + if msg_type != websocket.TextMessage { + t.Fatalf("Message recieved was not of type text message (got %v)", msg_type) + } + + var leaderboard profile_models.LeaderboardEntryList + + err = json.Unmarshal(message, &leaderboard) + if err != nil { + t.Fatal(err) + } + + expected_leaderboard := profile_models.LeaderboardEntryList{ + LeaderboardEntries: expected_full_leaderboard[:limit], + } + + if !reflect.DeepEqual(expected_leaderboard, leaderboard) { + t.Errorf("Wrong leaderboard. Expected %v, got %v", expected_leaderboard, leaderboard) + } + + limit = 15 + update_limit_req := profile_service.LiveLeaderboardConnection{ + Limit: limit, + } + c.WriteJSON(update_limit_req) + + msg_type, message, err = c.ReadMessage() + if err != nil { + t.Fatal(err) + } + + if msg_type != websocket.TextMessage { + t.Fatalf("Message recieved was not of type text message (got %v)", msg_type) + } + + err = json.Unmarshal(message, &leaderboard) + if err != nil { + t.Fatal(err) + } + + expected_leaderboard = profile_models.LeaderboardEntryList{ + LeaderboardEntries: expected_full_leaderboard[:limit], + } + + if !reflect.DeepEqual(expected_leaderboard, leaderboard) { + t.Errorf("Wrong leaderboard. Expected %v, got %v", expected_leaderboard, leaderboard) + } +} + func TestWSLeaderboardOnRedeem(t *testing.T) { CreateProfiles() CreateEvents()