diff --git a/Makefile b/Makefile index 55588fe..7a3578a 100644 --- a/Makefile +++ b/Makefile @@ -6,14 +6,14 @@ docs: #@ Generate docs swag init -g cmd/api/main.go .PHONY:docs test: fmt vet #@ Run tests - go test -coverprofile=coverage-full.out ./... + go test -tags testutils -coverprofile=coverage-full.out ./... grep -v "_mocks.go" coverage-full.out | grep -v "collection.go" > coverage.out go tool cover -html=coverage.out -o coverage.html .PHONY:test fmt: #@ Format the code go fmt ./... vet: fmt #@ VET the code - go vet ./... + go vet -tags testutils ./... lint: fmt #@ Run the linter golint ./... run: test docs vet #@ Start locally diff --git a/cmd/api/main.go b/cmd/api/main.go index 97380e6..c7a1579 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "cards-110-api/pkg/game" "cards-110-api/pkg/profile" "cards-110-api/pkg/settings" + "cards-110-api/pkg/stats" "context" "log" "os" @@ -92,8 +93,8 @@ func main() { gamesColRec := db.Collection[game.Game]{Col: gameCol} gameService := game.Service{Col: &gamesColRec} gameHandler := game.Handler{S: &gameService} - statsService := game.StatsService{Col: &gamesColRec} - statsHandler := game.StatsHandler{S: &statsService} + statsService := stats.Service{Col: &gamesColRec} + statsHandler := stats.Handler{S: &statsService} // Set up the API routes. router := gin.Default() diff --git a/model-migration.md b/model-migration.md new file mode 100644 index 0000000..c1f8fe5 --- /dev/null +++ b/model-migration.md @@ -0,0 +1,7 @@ +# Notes about the model migration from v7 to v8 + +## Remove _class from the model +We can just ignore this. + +## Move Deck into Game +For completed games we can just delete the deck. For active games we can do this manually. \ No newline at end of file diff --git a/pkg/db/collection_mocks.go b/pkg/db/collection_mocks.go index 906c64d..2013eaf 100644 --- a/pkg/db/collection_mocks.go +++ b/pkg/db/collection_mocks.go @@ -13,6 +13,7 @@ type MockCollection[T any] struct { MockFindOneErr *[]error MockFindResult *[][]T MockFindErr *[]error + MockUpsertErr *[]error } func (m *MockCollection[T]) FindOne(ctx context.Context, filter bson.M) (T, bool, error) { @@ -62,6 +63,19 @@ func (m *MockCollection[T]) Find(ctx context.Context, filter bson.M) ([]T, error return result, err } +func (m *MockCollection[T]) Upsert(ctx context.Context, t T, id string) error { + // Get the first element of the error array and remove it from the array, return nil if the array is empty + var err error + if len(*m.MockUpsertErr) > 0 { + err = (*m.MockUpsertErr)[0] + *m.MockUpsertErr = (*m.MockUpsertErr)[1:] + } else { + err = nil + } + + return err +} + func (m *MockCollection[T]) FindOneAndUpdate(ctx context.Context, filter bson.M, update bson.M) (T, error) { var result T return result, nil @@ -71,7 +85,3 @@ func (m *MockCollection[T]) FindOneAndReplace(ctx context.Context, filter bson.M var result T return result, nil } - -func (m *MockCollection[T]) Upsert(ctx context.Context, t T, id string) error { - return nil -} diff --git a/pkg/game/deck-utils.go b/pkg/game/deck-utils.go index b7b3139..edc013c 100644 --- a/pkg/game/deck-utils.go +++ b/pkg/game/deck-utils.go @@ -1,6 +1,8 @@ package game -import "math/rand" +import ( + "math/rand" +) func ShuffleCards(cards []CardName) []CardName { shuffled := make([]CardName, len(cards)) diff --git a/pkg/game/game-service.go b/pkg/game/game-service.go index 623ac23..c74f6f6 100644 --- a/pkg/game/game-service.go +++ b/pkg/game/game-service.go @@ -3,6 +3,7 @@ package game import ( "cards-110-api/pkg/db" "context" + "errors" "go.mongodb.org/mongo-driver/bson" "log" ) @@ -21,6 +22,15 @@ type Service struct { func (s *Service) Create(ctx context.Context, playerIDs []string, name string, adminID string) (Game, error) { log.Printf("Creating new game (%s)", name) + // Check for duplicate player IDs. + uniquePlayerIDs := make(map[string]bool) + for _, id := range playerIDs { + uniquePlayerIDs[id] = true + } + if len(uniquePlayerIDs) != len(playerIDs) { + return Game{}, errors.New("duplicate player IDs") + } + // Create a new game. game, err := NewGame(playerIDs, name, adminID) if err != nil { diff --git a/pkg/game/game-service_test.go b/pkg/game/game-service_test.go index ad3947b..aab4d1a 100644 --- a/pkg/game/game-service_test.go +++ b/pkg/game/game-service_test.go @@ -3,41 +3,95 @@ package game import ( "cards-110-api/pkg/db" "context" - "fmt" + "errors" + "reflect" "testing" - "time" ) -var game = Game{ - ID: "1", - Name: "Test Game", - Status: ACTIVE, - Timestamp: time.Now(), - Players: []Player{ +func TestCreate(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + inputPlayerIDs []string + inputName string + inputAdminID string + mockError *[]error + expectingError bool + }{ { - ID: "1", - Seat: 1, - Call: 0, - Cards: []CardName{}, - Bought: 0, - Score: 0, - Rings: 0, - TeamID: "1", - Winner: false, + name: "simple create", + inputPlayerIDs: []string{"1", "2"}, + inputName: "test", + inputAdminID: "1", + mockError: &[]error{nil}, }, { - ID: "2", - Seat: 2, - Call: 0, - Cards: []CardName{}, - Bought: 0, - Score: 0, - Rings: 0, - TeamID: "2", - Winner: false, + name: "duplicate player IDs", + inputPlayerIDs: []string{ + "1", + "1", + }, + inputName: "test", + inputAdminID: "1", + mockError: &[]error{nil}, + expectingError: true, }, - }, - AdminID: "1", + { + name: "error thrown", + inputPlayerIDs: []string{ + "1", + "2", + }, + inputName: "test", + inputAdminID: "1", + mockError: &[]error{errors.New("failed to upsert")}, + expectingError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockCol := &db.MockCollection[Game]{ + MockUpsertErr: test.mockError, + } + + ds := &Service{ + Col: mockCol, + } + + result, err := ds.Create(ctx, test.inputPlayerIDs, test.name, test.inputAdminID) + + if test.expectingError { + if err == nil { + t.Errorf("expected error %v, got %v", test.expectingError, err) + } + } else { + if result.Name != test.name { + t.Errorf("expected name %s, got %s", test.inputName, result.Name) + } + if result.AdminID != test.inputAdminID { + t.Errorf("expected admin id %s, got %s", test.inputAdminID, result.AdminID) + } + if len(result.Players) != len(test.inputPlayerIDs) { + t.Errorf("expected %d players, got %d", len(test.inputPlayerIDs), len(result.Players)) + } + // Check that the players are in the game + for _, playerID := range test.inputPlayerIDs { + found := false + for _, player := range result.Players { + if player.ID == playerID { + found = true + break + } + } + if !found { + t.Errorf("expected player %s to be in the game", playerID) + } + } + } + }) + } } func TestGet(t *testing.T) { @@ -53,14 +107,34 @@ func TestGet(t *testing.T) { expectingError bool }{ { - name: "success", - mockResult: &[]Game{game}, + name: "simple get", + mockResult: &[]Game{TwoPlayerGame()}, mockExists: &[]bool{true}, mockError: &[]error{nil}, - expectedResult: game, + expectedResult: TwoPlayerGame(), expectedExists: true, expectingError: false, }, + { + name: "error thrown", + mockResult: &[]Game{ + {}, + }, + mockExists: &[]bool{false}, + mockError: &[]error{errors.New("something went wrong")}, + expectedResult: Game{}, + expectedExists: false, + expectingError: true, + }, + { + name: "not found", + mockResult: &[]Game{{}}, + mockExists: &[]bool{false}, + mockError: &[]error{nil}, + expectedResult: Game{}, + expectedExists: false, + expectingError: false, + }, } for _, test := range tests { @@ -77,16 +151,78 @@ func TestGet(t *testing.T) { result, exists, err := ds.Get(ctx, "1") - if fmt.Sprintf("%v", result) != fmt.Sprintf("%v", test.expectedResult) { - t.Errorf("expected result %v, got %v", test.expectedResult, result) + if test.expectingError { + if err == nil { + t.Errorf("expected error %v, got %v", test.expectingError, err) + } + } else { + if !reflect.DeepEqual(result, test.expectedResult) { + t.Errorf("expected result %v, got %v", test.expectedExists, exists) + } } if exists != test.expectedExists { t.Errorf("expected exists %v, got %v", test.expectedExists, exists) } - if (err != nil) != test.expectingError { - t.Errorf("expected error %v, got %v", test.expectingError, err) - } }) } } + +func TestGetAll(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + mockResult *[][]Game + mockError *[]error + expectedResult []Game + expectingError bool + }{ + { + name: "simple get", + mockResult: &[][]Game{{TwoPlayerGame()}}, + mockError: &[]error{nil}, + expectedResult: []Game{TwoPlayerGame()}, + expectingError: false, + }, + { + name: "error thrown", + mockResult: &[][]Game{{}}, + mockError: &[]error{errors.New("something went wrong")}, + expectedResult: []Game{}, + expectingError: true, + }, + { + name: "no results should return empty array", + mockResult: &[][]Game{}, + mockError: &[]error{nil}, + expectedResult: []Game{}, + expectingError: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockCol := &db.MockCollection[Game]{ + MockFindResult: test.mockResult, + MockFindErr: test.mockError, + } + + ds := &Service{ + Col: mockCol, + } + + result, err := ds.GetAll(ctx) + + if test.expectingError { + if err == nil { + t.Errorf("expected error %v, got %v", test.expectingError, err) + } + } else { + if !reflect.DeepEqual(result, test.expectedResult) && len(test.expectedResult) != 0 { + t.Errorf("expected result %v, got %v", test.expectedResult, result) + } + } + }) + } +} diff --git a/pkg/game/testdata.go b/pkg/game/testdata.go new file mode 100644 index 0000000..36dfe80 --- /dev/null +++ b/pkg/game/testdata.go @@ -0,0 +1,78 @@ +//go:build testutils + +package game + +import ( + "time" +) + +func Player1() Player { + return Player{ + ID: "1", + Seat: 1, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func Player2() Player { + return Player{ + ID: "2", + Seat: 2, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func Player3() Player { + return Player{ + ID: "3", + Seat: 3, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func Player4() Player { + return Player{ + ID: "4", + Seat: 4, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func Player5() Player { + return Player{ + ID: "5", + Seat: 5, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func Player6() Player { + return Player{ + ID: "6", + Seat: 6, + Call: 0, + Cards: []CardName{}, + Bought: 0, + } +} + +func TwoPlayerGame() Game { + return Game{ + ID: "1", + Name: "Test Game", + Status: ACTIVE, + Timestamp: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + Players: []Player{Player1(), Player2()}, + AdminID: "1", + } +} diff --git a/pkg/profile/profile-handler.go b/pkg/profile/profile-handler.go index 03ee517..a756b68 100644 --- a/pkg/profile/profile-handler.go +++ b/pkg/profile/profile-handler.go @@ -26,7 +26,7 @@ type Handler struct { // @Router /profile [get] func (h *Handler) Get(c *gin.Context) { // Check the user is correctly authenticated - authID, ok := auth.CheckValidated(c) + id, ok := auth.CheckValidated(c) if !ok { return } @@ -35,7 +35,6 @@ func (h *Handler) Get(c *gin.Context) { playerId := c.Query("playerId") // Use the provided playerId if it exists, otherwise use the authenticated user's ID - id := authID if playerId != "" { id = playerId } diff --git a/pkg/game/stats-handler.go b/pkg/stats/stats-handler.go similarity index 91% rename from pkg/game/stats-handler.go rename to pkg/stats/stats-handler.go index 08f2002..a1b94c5 100644 --- a/pkg/game/stats-handler.go +++ b/pkg/stats/stats-handler.go @@ -1,4 +1,4 @@ -package game +package stats import ( "cards-110-api/pkg/api" @@ -7,8 +7,8 @@ import ( "net/http" ) -type StatsHandler struct { - S StatsI +type Handler struct { + S ServiceI } // GetStats @Summary Get the user's stats @@ -21,7 +21,7 @@ type StatsHandler struct { // @Failure 400 {object} api.ErrorResponse // @Failure 500 {object} api.ErrorResponse // @Router /stats [get] -func (h *StatsHandler) GetStats(c *gin.Context) { +func (h *Handler) GetStats(c *gin.Context) { // Check the user is correctly authenticated id, ok := auth.CheckValidated(c) if !ok { @@ -51,7 +51,7 @@ func (h *StatsHandler) GetStats(c *gin.Context) { // @Failure 400 {object} api.ErrorResponse // @Failure 500 {object} api.ErrorResponse // @Router /stats/{playerId} [get] -func (h *StatsHandler) GetStatsForPlayer(c *gin.Context) { +func (h *Handler) GetStatsForPlayer(c *gin.Context) { // Check the user is correctly authenticated _, ok := auth.CheckValidated(c) if !ok { diff --git a/pkg/game/stats-service.go b/pkg/stats/stats-service.go similarity index 72% rename from pkg/game/stats-service.go rename to pkg/stats/stats-service.go index 495563c..23ded39 100644 --- a/pkg/game/stats-service.go +++ b/pkg/stats/stats-service.go @@ -1,7 +1,8 @@ -package game +package stats import ( "cards-110-api/pkg/db" + "cards-110-api/pkg/game" "context" "fmt" "go.mongodb.org/mongo-driver/bson" @@ -11,16 +12,16 @@ import ( "time" ) -type StatsI interface { - GetStats(ctx context.Context, playerId string) ([]PlayerStats, error) +type ServiceI interface { + GetStats(ctx context.Context, playerId string) ([]game.PlayerStats, error) } -type StatsService struct { - Col db.CollectionI[Game] +type Service struct { + Col db.CollectionI[game.Game] } // GetStats Get the stats for a player. -func (s *StatsService) GetStats(ctx context.Context, playerId string) ([]PlayerStats, error) { +func (s *Service) GetStats(ctx context.Context, playerId string) ([]game.PlayerStats, error) { pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "status", Value: "FINISHED"}, {Key: "players._id", Value: playerId}}}}, @@ -37,7 +38,7 @@ func (s *StatsService) GetStats(ctx context.Context, playerId string) ([]PlayerS cursor, err := s.Col.Aggregate(ctx, pipeline) if err != nil { - return []PlayerStats{}, err + return []game.PlayerStats{}, err } defer func(cursor *mongo.Cursor, ctx context.Context) { @@ -49,28 +50,28 @@ func (s *StatsService) GetStats(ctx context.Context, playerId string) ([]PlayerS // Return an empty slice if there are no results. if cursor.RemainingBatchLength() == 0 { - return []PlayerStats{}, nil + return []game.PlayerStats{}, nil } // Iterate over the cursor and decode each result. - var results []PlayerStats + var results []game.PlayerStats for cursor.Next(ctx) { var result bson.M if err = cursor.Decode(&result); err != nil { // Log the detailed error log.Printf("Error decoding cursor result: %v", err) - return []PlayerStats{}, err + return []game.PlayerStats{}, err } // Map the result to a PlayerStats struct. - playerStats := PlayerStats{} + playerStats := game.PlayerStats{} // Safely assert types if gameId, ok := result["gameId"].(string); ok { playerStats.GameID = gameId } else { // Handle missing or invalid gameId - return []PlayerStats{}, fmt.Errorf("failed to decode gameID") + return []game.PlayerStats{}, fmt.Errorf("failed to decode gameID") } if timestamp, ok := result["timestamp"].(primitive.DateTime); ok { @@ -78,25 +79,25 @@ func (s *StatsService) GetStats(ctx context.Context, playerId string) ([]PlayerS playerStats.Timestamp = time.Unix(int64(timestamp)/1000, 0) } else { // Handle missing or invalid timestamp - return []PlayerStats{}, fmt.Errorf("timestamp is not a valid DateTime") + return []game.PlayerStats{}, fmt.Errorf("timestamp is not a valid DateTime") } if winner, ok := result["winner"].(bool); ok { playerStats.Winner = winner } else { - return []PlayerStats{}, fmt.Errorf("winner is not a bool") + return []game.PlayerStats{}, fmt.Errorf("winner is not a bool") } if score, ok := result["score"].(int32); ok { playerStats.Score = int(score) } else { - return []PlayerStats{}, fmt.Errorf("score is not an int") + return []game.PlayerStats{}, fmt.Errorf("score is not an int") } if rings, ok := result["rings"].(int32); ok { playerStats.Rings = int(rings) } else { - return []PlayerStats{}, fmt.Errorf("rings is not an int") + return []game.PlayerStats{}, fmt.Errorf("rings is not an int") } results = append(results, playerStats)