diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 4a6e70c..e1fbc86 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -11,6 +11,7 @@ import ( "keep-it-up/internal/application/usecase" "keep-it-up/internal/infrastructure/database" + "keep-it-up/internal/infrastructure/driven" clidriver "keep-it-up/internal/infrastructure/driver" "keep-it-up/internal/infrastructure/util" @@ -27,13 +28,13 @@ func run() int { // --- Driven side: infrastructure dependencies --------------------- util.LoadEnv(".env") - + dbString, err := filepath.Abs(os.Getenv("GOOSE_DBSTRING")) if err != nil { fmt.Printf("failed to get dbstring absolute path: %v\n", err) return 1 } - + sqlDB, err := sql.Open("sqlite", fmt.Sprintf( "file:%s?mode=rw", dbString, @@ -58,7 +59,7 @@ func run() int { Players: usecase.NewPlayerManagement(q, auth), Auth: auth, Data: usecase.NewDataFetching(q), - Commands: usecase.NewGameCommands(), + Commands: usecase.NewGameCommands(q, &driven.DefaultTimeProvider{}), Stdout: os.Stdout, Stderr: os.Stderr, } diff --git a/database/migrations/20260815203118_initial.sql b/database/migrations/20260815203118_initial.sql index bbd05db..f000164 100644 --- a/database/migrations/20260815203118_initial.sql +++ b/database/migrations/20260815203118_initial.sql @@ -36,7 +36,7 @@ CREATE TABLE interactions ( saved_by INTEGER, -- duration in seconds, not a timestamp; NULL unless action = 'saved' CONSTRAINT valid_occurred_at_iso - CHECK (occurred_at = strftime('%Y-%m-%d %H:%M:%S', occurred_at)), + CHECK (occurred_at = strftime('%Y-%m-%dT%H:%M:%S%z', occurred_at)), CONSTRAINT saved_by_matches_action CHECK ((action = 'saved') = (saved_by IS NOT NULL)), @@ -92,10 +92,7 @@ END; DROP TRIGGER IF EXISTS trg_interactions_state_machine; -DROP INDEX idx_players_username; -DROP INDEX idx_interactions_game_occurred; - -DROP TABLE interactions; -DROP TABLE access; -DROP TABLE players; -DROP TABLE games; \ No newline at end of file +DROP TABLE IF EXISTS interactions; +DROP TABLE IF EXISTS access; +DROP TABLE IF EXISTS players; +DROP TABLE IF EXISTS games; \ No newline at end of file diff --git a/database/queries/commands.sql b/database/queries/commands.sql new file mode 100644 index 0000000..d7cffab --- /dev/null +++ b/database/queries/commands.sql @@ -0,0 +1,33 @@ +-- name: SaveGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at, + saved_by +) +VALUES (?, ?, 'saved', ?, ?) +RETURNING id, game_id, player_id, action, occurred_at, saved_by; + +-- name: ResumeGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at +) +VALUES (?, ?, 'resumed', ?) +RETURNING id, game_id, player_id, action, occurred_at; + +-- name: PauseGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at +) +VALUES (?, ?, 'paused', ?) +RETURNING id, game_id, player_id, action, occurred_at; diff --git a/internal/application/usecase/access_management_test.go b/internal/application/usecase/access_management_test.go new file mode 100644 index 0000000..8de1c0f --- /dev/null +++ b/internal/application/usecase/access_management_test.go @@ -0,0 +1,106 @@ +package usecase + +import ( + "context" + "strings" + "testing" +) + +func requireErrContains(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), want) { + t.Errorf("expected error to contain %q, got %q", want, err.Error()) + } +} + +func TestAccessManagement_GrantPlayerAccess(t *testing.T) { + ctx := context.Background() + + t.Run("nil queries", func(t *testing.T) { + uc := NewAccessManagement(nil) + err := uc.GrantPlayerAccess(ctx, 1, 1) + requireErrContains(t, err, "not initialized") + }) + + for _, tt := range []struct { + name string + gameID int64 + playerID int64 + wantErr string + }{ + {"invalid game id: zero", 0, 1, "invalid game ID"}, + {"invalid game id: negative", -1, 1, "invalid game ID"}, + {"invalid player id: zero", 1, 0, "invalid player ID"}, + {"invalid player id: negative", 1, -1, "invalid player ID"}, + } { + t.Run(tt.name, func(t *testing.T) { + uc := NewAccessManagement(newTestDB(t)) + err := uc.GrantPlayerAccess(ctx, tt.gameID, tt.playerID) + requireErrContains(t, err, tt.wantErr) + }) + } + + t.Run("valid pair succeeds", func(t *testing.T) { + // SQLite disables foreign-key enforcement per-connection unless a + // pragma turns it on, and nothing in the CLI-visible path does + // that here, so this insert isn't expected to need pre-existing + // game/player rows. If this fails against the real DB, FK + // enforcement is on somewhere in the connection setup and + // games/players need seeding first. + uc := NewAccessManagement(newTestDB(t)) + if err := uc.GrantPlayerAccess(ctx, 1, 1); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("duplicate grant fails on the composite primary key", func(t *testing.T) { + uc := NewAccessManagement(newTestDB(t)) + if err := uc.GrantPlayerAccess(ctx, 1, 1); err != nil { + t.Fatalf("first grant: expected no error, got %v", err) + } + if err := uc.GrantPlayerAccess(ctx, 1, 1); err == nil { + t.Fatal("second grant: expected a primary-key violation, got nil") + } + }) +} + +func TestAccessManagement_RevokePlayerAccess(t *testing.T) { + ctx := context.Background() + + t.Run("nil queries", func(t *testing.T) { + uc := NewAccessManagement(nil) + err := uc.RevokePlayerAccess(ctx, 1, 1) + requireErrContains(t, err, "not initialized") + }) + + for _, tt := range []struct { + name string + gameID int64 + playerID int64 + wantErr string + }{ + {"invalid game id: zero", 0, 1, "invalid game ID"}, + {"invalid game id: negative", -1, 1, "invalid game ID"}, + {"invalid player id: zero", 1, 0, "invalid player ID"}, + {"invalid player id: negative", 1, -1, "invalid player ID"}, + } { + t.Run(tt.name, func(t *testing.T) { + uc := NewAccessManagement(newTestDB(t)) + err := uc.RevokePlayerAccess(ctx, tt.gameID, tt.playerID) + requireErrContains(t, err, tt.wantErr) + }) + } + + t.Run("revoke after grant succeeds", func(t *testing.T) { + uc := NewAccessManagement(newTestDB(t)) + if err := uc.GrantPlayerAccess(ctx, 1, 1); err != nil { + t.Fatalf("setup grant: expected no error, got %v", err) + } + if err := uc.RevokePlayerAccess(ctx, 1, 1); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} diff --git a/internal/application/usecase/data_fetching_test.go b/internal/application/usecase/data_fetching_test.go new file mode 100644 index 0000000..a127f12 --- /dev/null +++ b/internal/application/usecase/data_fetching_test.go @@ -0,0 +1,118 @@ +package usecase + +import ( + "context" + "testing" +) + +func TestDataFetching_ListPlayerGames(t *testing.T) { + ctx := context.Background() + + t.Run("nil queries", func(t *testing.T) { + uc := NewDataFetching(nil) + _, err := uc.ListPlayerGames(ctx, 1) + requireErrContains(t, err, "not initialized") + }) + + for _, tt := range []struct { + name string + playerID int64 + wantErr string + }{ + {"invalid player id: zero", 0, "Invalid player ID"}, + {"invalid player id: negative", -1, "Invalid player ID"}, + } { + t.Run(tt.name, func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + _, err := uc.ListPlayerGames(ctx, tt.playerID) + requireErrContains(t, err, tt.wantErr) + }) + } + + t.Run("player with no games returns empty, no error", func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + games, err := uc.ListPlayerGames(ctx, 1) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(games) != 0 { + t.Errorf("expected no games, got %v", games) + } + }) +} + +func TestDataFetching_GetSharedData(t *testing.T) { + ctx := context.Background() + + t.Run("nil queries", func(t *testing.T) { + uc := NewDataFetching(nil) + _, err := uc.GetSharedData(ctx, 1) + requireErrContains(t, err, "not initialized") + }) + + for _, tt := range []struct { + name string + gameID int64 + wantErr string + }{ + {"invalid game id: zero", 0, "Invalid game ID"}, + {"invalid game id: negative", -1, "Invalid game ID"}, + } { + t.Run(tt.name, func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + _, err := uc.GetSharedData(ctx, tt.gameID) + requireErrContains(t, err, tt.wantErr) + }) + } + + // Success path not covered: it calls service.BuildSharedData after + // ListInteractionsForReplay, and that function's contract (does it + // error on a game with zero interactions? what shape does it return?) + // isn't visible from access_management.go/data_fetching.go. Send the + // service package if you want this closed out too. +} + +func TestDataFetching_ListInteractions(t *testing.T) { + ctx := context.Background() + + t.Run("nil queries", func(t *testing.T) { + uc := NewDataFetching(nil) + _, err := uc.ListInteractions(ctx, 1, 10) + requireErrContains(t, err, "not initialized") + }) + + for _, tt := range []struct { + name string + gameID int64 + limit int64 + wantErr string + }{ + {"invalid game id: zero", 0, 10, "Invalid game ID"}, + {"invalid game id: negative", -1, 10, "Invalid game ID"}, + {"negative limit", 1, -1, "query limit cannot be less than 0"}, + } { + t.Run(tt.name, func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + _, err := uc.ListInteractions(ctx, tt.gameID, tt.limit) + requireErrContains(t, err, tt.wantErr) + }) + } + + t.Run("game with no interactions returns empty, no error", func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + interactions, err := uc.ListInteractions(ctx, 1, 10) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(interactions) != 0 { + t.Errorf("expected no interactions, got %v", interactions) + } + }) + + t.Run("limit zero is a valid boundary", func(t *testing.T) { + uc := NewDataFetching(newTestDB(t)) + if _, err := uc.ListInteractions(ctx, 1, 0); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} diff --git a/internal/application/usecase/game_commands.go b/internal/application/usecase/game_commands.go index feef00b..f2ea392 100644 --- a/internal/application/usecase/game_commands.go +++ b/internal/application/usecase/game_commands.go @@ -2,28 +2,125 @@ package usecase import ( "context" + "database/sql" + "errors" + "fmt" + "keep-it-up/internal/constant" + "keep-it-up/internal/core/interface/driven" + "keep-it-up/internal/infrastructure/database" ) -type GameCommands struct{} +type GameCommands struct { + q *database.Queries + tp driven.TimeProvider +} -func NewGameCommands() *GameCommands { - return &GameCommands{} +func NewGameCommands(q *database.Queries, tp driven.TimeProvider) *GameCommands { + return &GameCommands{ + q: q, tp: tp, + } } func (uc *GameCommands) SaveGame( ctx context.Context, gameId int64, playerId int64, duration int64, ) error { - return nil + if uc.q == nil { + return errors.New("database queries are not initialized") + } + + if uc.tp == nil { + return errors.New("time provider is not initialized") + } + + if gameId < 1 { + return fmt.Errorf("invalid game ID: %d", gameId) + } + + if playerId < 1 { + return fmt.Errorf("invalid player ID: %d", playerId) + } + + if duration < 1 { + return errors.New("save duration cannot be less than 1 second") + } + + t, err := uc.tp.Time() + if err != nil { + return fmt.Errorf("failed to get current time: %w", err) + } + + _, err = uc.q.SaveGame(ctx, database.SaveGameParams{ + GameID: gameId, + PlayerID: sql.NullInt64{Int64: playerId, Valid: true}, + OccurredAt: t.Format(constant.DBDatetimeFormat), + SavedBy: sql.NullInt64{Int64: duration, Valid: true}, + }) + + return err } func (uc *GameCommands) ResumeGame( ctx context.Context, gameId int64, playerId int64, ) error { - return nil + if uc.q == nil { + return errors.New("database queries are not initialized") + } + + if uc.tp == nil { + return errors.New("time provider is not initialized") + } + + if gameId < 1 { + return fmt.Errorf("invalid game ID: %d", gameId) + } + + if playerId < 1 { + return fmt.Errorf("invalid player ID: %d", playerId) + } + + t, err := uc.tp.Time() + if err != nil { + return fmt.Errorf("failed to get current time: %w", err) + } + + _, err = uc.q.ResumeGame(ctx, database.ResumeGameParams{ + GameID: gameId, + PlayerID: sql.NullInt64{Int64: playerId, Valid: true}, + OccurredAt: t.Format(constant.DBDatetimeFormat), + }) + + return err } func (uc *GameCommands) PauseGame( ctx context.Context, gameId int64, playerId int64, ) error { - return nil + if uc.q == nil { + return errors.New("database queries are not initialized") + } + + if uc.tp == nil { + return errors.New("time provider is not initialized") + } + + if gameId < 1 { + return fmt.Errorf("invalid game ID: %d", gameId) + } + + if playerId < 1 { + return fmt.Errorf("invalid player ID: %d", playerId) + } + + t, err := uc.tp.Time() + if err != nil { + return fmt.Errorf("failed to get current time: %w", err) + } + + _, err = uc.q.PauseGame(ctx, database.PauseGameParams{ + GameID: gameId, + PlayerID: sql.NullInt64{Int64: playerId, Valid: true}, + OccurredAt: t.Format(constant.DBDatetimeFormat), + }) + + return err } diff --git a/internal/constant/constant.go b/internal/constant/constant.go index af51628..1e46aa6 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -1,3 +1,5 @@ package constant -const ISO8601Layout string = "2006-01-02 15:04:05" +import "time" + +const DBDatetimeFormat string = time.RFC3339 diff --git a/internal/core/interface/driven/driven.go b/internal/core/interface/driven/driven.go new file mode 100644 index 0000000..926d13f --- /dev/null +++ b/internal/core/interface/driven/driven.go @@ -0,0 +1,7 @@ +package driven + +import "time" + +type TimeProvider interface { + Time() (time.Time, error) +} diff --git a/internal/core/service/shared_data.go b/internal/core/service/shared_data.go index c851718..789d18e 100644 --- a/internal/core/service/shared_data.go +++ b/internal/core/service/shared_data.go @@ -31,7 +31,7 @@ func BuildSharedData( ) } - occurredAt, err := time.Parse(constant.ISO8601Layout, ia.OccurredAt) + occurredAt, err := time.Parse(constant.DBDatetimeFormat, ia.OccurredAt) if err != nil { return nil, fmt.Errorf( "build shared data: interaction %d: parse occurred_at: %w", diff --git a/internal/infrastructure/database/commands.sql.go b/internal/infrastructure/database/commands.sql.go new file mode 100644 index 0000000..f336d92 --- /dev/null +++ b/internal/infrastructure/database/commands.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: commands.sql + +package database + +import ( + "context" + "database/sql" +) + +const pauseGame = `-- name: PauseGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at +) +VALUES (?, ?, 'paused', ?) +RETURNING id, game_id, player_id, action, occurred_at +` + +type PauseGameParams struct { + GameID int64 + PlayerID sql.NullInt64 + OccurredAt string +} + +type PauseGameRow struct { + ID int64 + GameID int64 + PlayerID sql.NullInt64 + Action string + OccurredAt string +} + +func (q *Queries) PauseGame(ctx context.Context, arg PauseGameParams) (PauseGameRow, error) { + row := q.db.QueryRowContext(ctx, pauseGame, arg.GameID, arg.PlayerID, arg.OccurredAt) + var i PauseGameRow + err := row.Scan( + &i.ID, + &i.GameID, + &i.PlayerID, + &i.Action, + &i.OccurredAt, + ) + return i, err +} + +const resumeGame = `-- name: ResumeGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at +) +VALUES (?, ?, 'resumed', ?) +RETURNING id, game_id, player_id, action, occurred_at +` + +type ResumeGameParams struct { + GameID int64 + PlayerID sql.NullInt64 + OccurredAt string +} + +type ResumeGameRow struct { + ID int64 + GameID int64 + PlayerID sql.NullInt64 + Action string + OccurredAt string +} + +func (q *Queries) ResumeGame(ctx context.Context, arg ResumeGameParams) (ResumeGameRow, error) { + row := q.db.QueryRowContext(ctx, resumeGame, arg.GameID, arg.PlayerID, arg.OccurredAt) + var i ResumeGameRow + err := row.Scan( + &i.ID, + &i.GameID, + &i.PlayerID, + &i.Action, + &i.OccurredAt, + ) + return i, err +} + +const saveGame = `-- name: SaveGame :one +INSERT INTO interactions +( + game_id, + player_id, + action, + occurred_at, + saved_by +) +VALUES (?, ?, 'saved', ?, ?) +RETURNING id, game_id, player_id, action, occurred_at, saved_by +` + +type SaveGameParams struct { + GameID int64 + PlayerID sql.NullInt64 + OccurredAt string + SavedBy sql.NullInt64 +} + +func (q *Queries) SaveGame(ctx context.Context, arg SaveGameParams) (Interaction, error) { + row := q.db.QueryRowContext(ctx, saveGame, + arg.GameID, + arg.PlayerID, + arg.OccurredAt, + arg.SavedBy, + ) + var i Interaction + err := row.Scan( + &i.ID, + &i.GameID, + &i.PlayerID, + &i.Action, + &i.OccurredAt, + &i.SavedBy, + ) + return i, err +} diff --git a/internal/infrastructure/driven/time_provider.go b/internal/infrastructure/driven/time_provider.go new file mode 100644 index 0000000..fe74a5f --- /dev/null +++ b/internal/infrastructure/driven/time_provider.go @@ -0,0 +1,9 @@ +package driven + +import "time" + +type DefaultTimeProvider struct{} + +func (tp DefaultTimeProvider) Time() (time.Time, error) { + return time.Now(), nil +}