diff --git a/README.md b/README.md index 25d90dc..0e2651c 100644 --- a/README.md +++ b/README.md @@ -20,22 +20,22 @@ Nouns: game add update delete - access grant - revoke + access grant + revoke player add rename - passwd + passwd passwd-force delete auth validate-passwd hash-passwd check-passwd - data games - shared - interactions - session save - resume - pause + data games + shared + interactions + session save + resume + pause ``` ## HTTP API diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 9d968a3..4a6e70c 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -57,7 +57,7 @@ func run() int { Access: usecase.NewAccessManagement(q), Players: usecase.NewPlayerManagement(q, auth), Auth: auth, - Data: usecase.NewDataFetching(), + Data: usecase.NewDataFetching(q), Commands: usecase.NewGameCommands(), Stdout: os.Stdout, Stderr: os.Stderr, diff --git a/database/migrations/20260815203118_initial.sql b/database/migrations/20260815203118_initial.sql index c373cda..bbd05db 100644 --- a/database/migrations/20260815203118_initial.sql +++ b/database/migrations/20260815203118_initial.sql @@ -31,17 +31,67 @@ CREATE TABLE interactions ( action TEXT NOT NULL CHECK ( action IN ('saved', 'paused', 'resumed') ), - occurred_at TEXT NOT NULL, + + occurred_at TEXT NOT NULL, -- Uses ISO 8601 format + 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)), + + CONSTRAINT saved_by_matches_action + CHECK ((action = 'saved') = (saved_by IS NOT NULL)), + + CONSTRAINT saved_by_positive + CHECK (saved_by IS NULL OR saved_by > 0), FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE, FOREIGN KEY (player_id) REFERENCES players(id) -); +) STRICT; CREATE INDEX idx_interactions_game_occurred -ON interactions (game_id, occurred_at DESC); +ON interactions (game_id, occurred_at DESC, id DESC); + +-- +goose StatementBegin +CREATE TRIGGER trg_interactions_state_machine +BEFORE INSERT ON interactions +FOR EACH ROW +BEGIN + SELECT RAISE(ABORT, 'occurred_at precedes an existing interaction for this game') + WHERE EXISTS ( + SELECT 1 FROM interactions + WHERE game_id = NEW.game_id AND occurred_at > NEW.occurred_at + ); + + SELECT RAISE(ABORT, 'cannot save while paused') + WHERE NEW.action = 'saved' + AND (SELECT action FROM interactions + WHERE game_id = NEW.game_id + ORDER BY occurred_at DESC, id DESC LIMIT 1) = 'paused'; + + SELECT RAISE(ABORT, 'cannot pause: game is not currently playing') + WHERE NEW.action = 'paused' + AND COALESCE( + (SELECT action FROM interactions + WHERE game_id = NEW.game_id + ORDER BY occurred_at DESC, id DESC LIMIT 1), + 'none' + ) NOT IN ('saved', 'resumed'); + + SELECT RAISE(ABORT, 'cannot resume: game is not currently paused') + WHERE NEW.action = 'resumed' + AND COALESCE( + (SELECT action FROM interactions + WHERE game_id = NEW.game_id + ORDER BY occurred_at DESC, id DESC LIMIT 1), + 'none' + ) != 'paused'; +END; +-- +goose StatementEnd -- +goose Down +DROP TRIGGER IF EXISTS trg_interactions_state_machine; + DROP INDEX idx_players_username; DROP INDEX idx_interactions_game_occurred; diff --git a/database/queries/fetching.sql b/database/queries/fetching.sql new file mode 100644 index 0000000..f04902f --- /dev/null +++ b/database/queries/fetching.sql @@ -0,0 +1,20 @@ +-- name: ListPlayerGames :many +SELECT + games.id, + games.name +FROM games +JOIN access ON access.game_id = games.id +WHERE access.player_id = ?; + +-- name: ListRecentInteractions :many +SELECT id, game_id, player_id, action, occurred_at, saved_by +FROM interactions +WHERE game_id = ? +ORDER BY occurred_at DESC, id DESC +LIMIT ?; + +-- name: ListInteractionsForReplay :many +SELECT id, game_id, player_id, action, occurred_at, saved_by +FROM interactions +WHERE game_id = ? +ORDER BY occurred_at ASC, id ASC; diff --git a/internal/application/usecase/authentication.go b/internal/application/usecase/authentication.go index a7c731b..b504326 100644 --- a/internal/application/usecase/authentication.go +++ b/internal/application/usecase/authentication.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" "keep-it-up/internal/core/model" + "keep-it-up/internal/core/service" "keep-it-up/internal/infrastructure/database" - "strings" "golang.org/x/crypto/bcrypt" ) @@ -19,31 +19,6 @@ func NewAuthentication(q *database.Queries) *Authentication { return &Authentication{q: q} } -func (uc *Authentication) IsPasswordValid(password string) error { - if strings.ContainsRune(password, ' ') { - return fmt.Errorf("Password cannot have whitespaces") - } - - if len(password) < 6 { - return fmt.Errorf("Password cannot have less than 6 characters") - } - - return nil -} - -func (uc *Authentication) GeneratePasswordHash(password string) (string, error) { - if err := uc.IsPasswordValid(password); err != nil { - return "", err - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", err - } - - return string(hash), nil -} - func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username string, password string) (bool, error) { if uc.q == nil { return false, fmt.Errorf("database queries are not initialized") @@ -53,7 +28,7 @@ func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username stri return false, fmt.Errorf("Username cannot have less than 3 characters: '%s'", username) } - if err := uc.IsPasswordValid(password); err != nil { + if err := service.IsPasswordValid(password); err != nil { return false, err } diff --git a/internal/application/usecase/authentication_test.go b/internal/application/usecase/authentication_test.go index 71e6719..07dd7e8 100644 --- a/internal/application/usecase/authentication_test.go +++ b/internal/application/usecase/authentication_test.go @@ -9,75 +9,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -func TestAuthentication_IsPasswordValid(t *testing.T) { - auth := NewAuthentication(nil) - - for _, password := range []string{"secret123", "abc123"} { - if err := auth.IsPasswordValid(password); err != nil { - t.Fatalf("IsPasswordValid() rejected a valid password %q: %v", password, err) - } - } - - for _, password := range []string{ - "", "short", "abc12", "pa ss", "p ass ", "secret 123", "pass ", - "alice", " alice ", " secret123 ", "secret123 ", " secret123", - "\tsecret 123\n", - } { - if err := auth.IsPasswordValid(password); err == nil { - t.Fatalf("IsPasswordValid() accepted an invalid password %q", password) - } - } -} - -func TestAuthentication_IsPasswordValidBoundary(t *testing.T) { - // Test boundary condition: exactly 6 characters (minimum valid length) - auth := NewAuthentication(nil) - - if err := auth.IsPasswordValid("abc123"); err != nil { - t.Fatalf("IsPasswordValid() rejected valid 6-character password: %v", err) - } - - // Test one below boundary: 5 characters (should fail) - if err := auth.IsPasswordValid("abc12"); err == nil { - t.Fatal("IsPasswordValid() accepted 5-character password (below minimum)") - } - - // Test with whitespace at boundary - if err := auth.IsPasswordValid(" abc123"); err == nil { - t.Fatalf("IsPasswordValid() accepted invalid 6-char password with leading space: %v", err) - } - if err := auth.IsPasswordValid("abc123 "); err == nil { - t.Fatalf("IsPasswordValid() accepted invalid 6-char password with trailing space: %v", err) - } -} - -func TestAuthentication_GeneratePasswordHash(t *testing.T) { - auth := NewAuthentication(nil) - - hash, err := auth.GeneratePasswordHash("secret123") - if err != nil { - t.Fatalf("GeneratePasswordHash() returned error: %v", err) - } - if hash == "" { - t.Fatal("GeneratePasswordHash() returned empty hash") - } - if hash == "secret123" { - t.Fatal("GeneratePasswordHash() returned the raw password instead of a hash") - } - - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("secret123")); err != nil { - t.Fatalf("bcrypt.CompareHashAndPassword() failed for generated hash: %v", err) - } -} - -func TestAuthentication_GeneratePasswordHashRejectsShortPassword(t *testing.T) { - auth := NewAuthentication(nil) - - if _, err := auth.GeneratePasswordHash("short"); err == nil { - t.Fatal("GeneratePasswordHash() accepted a password shorter than 6 characters") - } -} - func TestAuthentication_CheckPlayerPasswordUsesPasswordRuleValidation(t *testing.T) { ctx := context.Background() queries := newTestDB(t) @@ -237,58 +168,6 @@ func TestAuthentication_CheckPlayerPasswordWithCancelledContext(t *testing.T) { } } -func TestAuthentication_IsPasswordValidBoundaryPrecision(t *testing.T) { - auth := NewAuthentication(nil) - - // Test exactly at boundary: 6 characters (minimum valid) - // Note: This test documents desired behavior for password validation boundaries - testCases := []struct { - password string - shouldValidate bool - }{ - {"abc123", true}, // exactly 6 chars - at boundary - {"abc12", false}, // 5 chars - one below minimum - {"abc1234", true}, // 7 chars - one above minimum - {"123456", true}, // 6 digits - {"aaaaaa", true}, // 6 of same letter - } - - for _, tc := range testCases { - err := auth.IsPasswordValid(tc.password) - if tc.shouldValidate && err != nil { - t.Fatalf("IsPasswordValid() should validate %q (length=%d): %v", tc.password, len(tc.password), err) - } - if !tc.shouldValidate && err == nil { - t.Fatalf("IsPasswordValid() should reject %q (length=%d)", tc.password, len(tc.password)) - } - } -} - -func TestAuthentication_GeneratePasswordHashWithValidPasswords(t *testing.T) { - auth := NewAuthentication(nil) - - validPasswords := []string{ - "abc123", // exactly 6 chars - "secret123", // 9 chars - "password", // 8 chars - "aaaaaaaaa", // 9 of same character - "123456789", // 9 digits - } - - for _, password := range validPasswords { - hash, err := auth.GeneratePasswordHash(password) - if err != nil { - t.Fatalf("GeneratePasswordHash() failed for valid password %q: %v", password, err) - } - if hash == "" { - t.Fatalf("GeneratePasswordHash() returned empty hash for %q", password) - } - if hash == password { - t.Fatalf("GeneratePasswordHash() returned unhashed password for %q", password) - } - } -} - func TestAuthentication_CheckPlayerPasswordWithTrimmablePassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) @@ -323,48 +202,3 @@ func TestAuthentication_CheckPlayerPasswordWithTrimmablePassword(t *testing.T) { _ = err } } - -func TestAuthentication_IsPasswordValidWithUnicodeCharacters(t *testing.T) { - auth := NewAuthentication(nil) - - // Test that unicode characters are allowed (if validation allows non-ASCII) - // The exact behavior depends on the validation implementation - testCases := []struct { - password string - desc string - }{ - {"пароль123", "Cyrillic password"}, - {"password123", "ASCII password"}, - {"пароль", "Cyrillic word only"}, - } - - for _, tc := range testCases { - // Just verify the function doesn't panic or error unexpectedly - _ = auth.IsPasswordValid(tc.password) - } -} - -func TestAuthentication_GeneratePasswordHashIsConsistent(t *testing.T) { - auth := NewAuthentication(nil) - - password := "secret123" - hash1, err1 := auth.GeneratePasswordHash(password) - hash2, err2 := auth.GeneratePasswordHash(password) - - if err1 != nil || err2 != nil { - t.Fatalf("GeneratePasswordHash() returned error: err1=%v, err2=%v", err1, err2) - } - - // Bcrypt hashes should be different even for same password (due to salt) - if hash1 == hash2 { - t.Fatal("GeneratePasswordHash() produced identical hashes for same password (should be different due to salt)") - } - - // But both should validate against the same password - if err := bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)); err != nil { - t.Fatalf("First hash does not match password: %v", err) - } - if err := bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)); err != nil { - t.Fatalf("Second hash does not match password: %v", err) - } -} diff --git a/internal/application/usecase/data_fetching.go b/internal/application/usecase/data_fetching.go index e4ee3dc..85c1c6e 100644 --- a/internal/application/usecase/data_fetching.go +++ b/internal/application/usecase/data_fetching.go @@ -2,30 +2,76 @@ package usecase import ( "context" + "errors" + "fmt" "keep-it-up/internal/core/model" + "keep-it-up/internal/core/service" "keep-it-up/internal/infrastructure/database" ) -type DataFetching struct{} +type DataFetching struct { + q *database.Queries +} -func NewDataFetching() *DataFetching { - return &DataFetching{} +func NewDataFetching(q *database.Queries) *DataFetching { + return &DataFetching{q: q} } func (uc *DataFetching) ListPlayerGames( ctx context.Context, playerId int64, ) ([]database.Game, error) { - return nil, nil + if uc.q == nil { + return nil, errors.New("database queries are not initialized") + } + + if playerId < 1 { + return nil, fmt.Errorf("Invalid player ID: %d", playerId) + } + + return uc.q.ListPlayerGames(ctx, playerId) } func (uc *DataFetching) GetSharedData( ctx context.Context, gameId int64, -) (model.SharedData, error) { - return model.SharedData{}, nil +) (*model.SharedData, error) { + if uc.q == nil { + return nil, errors.New("database queries are not initialized") + } + + if gameId < 1 { + return nil, fmt.Errorf("Invalid game ID: %d", gameId) + } + + interactions, err := uc.q.ListInteractionsForReplay(ctx, gameId) + if err != nil { + return nil, fmt.Errorf( + "failed to list interactions for replay: %w", err, + ) + } + + return service.BuildSharedData(gameId, interactions) } func (uc *DataFetching) ListInteractions( - ctx context.Context, gameId int64, count int, + ctx context.Context, gameId int64, limit int64, ) ([]database.Interaction, error) { - return nil, nil + if uc.q == nil { + return nil, errors.New("database queries are not initialized") + } + + if gameId < 1 { + return nil, fmt.Errorf("Invalid game ID: %d", gameId) + } + + if limit < 0 { + return nil, errors.New("query limit cannot be less than 0") + } + + return uc.q.ListRecentInteractions( + ctx, + database.ListRecentInteractionsParams{ + GameID: gameId, + Limit: limit, + }, + ) } diff --git a/internal/application/usecase/game_commands.go b/internal/application/usecase/game_commands.go index a919e5d..feef00b 100644 --- a/internal/application/usecase/game_commands.go +++ b/internal/application/usecase/game_commands.go @@ -2,7 +2,6 @@ package usecase import ( "context" - "time" ) type GameCommands struct{} @@ -12,7 +11,7 @@ func NewGameCommands() *GameCommands { } func (uc *GameCommands) SaveGame( - ctx context.Context, gameId int64, playerId int64, amount time.Time, + ctx context.Context, gameId int64, playerId int64, duration int64, ) error { return nil } diff --git a/internal/application/usecase/player_management.go b/internal/application/usecase/player_management.go index 7cd996f..52720e9 100644 --- a/internal/application/usecase/player_management.go +++ b/internal/application/usecase/player_management.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "keep-it-up/internal/core/interface/driver" + "keep-it-up/internal/core/service" "keep-it-up/internal/infrastructure/database" "keep-it-up/internal/infrastructure/util" "strings" @@ -61,11 +62,11 @@ func (uc *PlayerManagement) AddPlayer(ctx context.Context, name string, username return database.Player{}, errors.New("player password cannot be equal to its username") } - if err := uc.auth.IsPasswordValid(password); err != nil { + if err := service.IsPasswordValid(password); err != nil { return database.Player{}, err } - hashedPassword, err := uc.auth.GeneratePasswordHash(password) + hashedPassword, err := service.GeneratePasswordHash(password) if err != nil { return database.Player{}, err } @@ -112,11 +113,11 @@ func (uc *PlayerManagement) BaseUpdatePlayerPassword(ctx context.Context, id int password = strings.TrimSpace(password) - if err := uc.auth.IsPasswordValid(password); err != nil { + if err := service.IsPasswordValid(password); err != nil { return err } - hashedPassword, err := uc.auth.GeneratePasswordHash(password) + hashedPassword, err := service.GeneratePasswordHash(password) if err != nil { return err } @@ -145,12 +146,12 @@ func (uc *PlayerManagement) UpdatePlayerPassword(ctx context.Context, id int64, } currentPassword = strings.TrimSpace(currentPassword) - if err := uc.auth.IsPasswordValid(currentPassword); err != nil { + if err := service.IsPasswordValid(currentPassword); err != nil { return err } newPassword = strings.TrimSpace(newPassword) - if err := uc.auth.IsPasswordValid(newPassword); err != nil { + if err := service.IsPasswordValid(newPassword); err != nil { return err } @@ -181,7 +182,7 @@ func (uc *PlayerManagement) UpdatePlayerPasswordForce(ctx context.Context, id in return fmt.Errorf("Invalid player ID: %d", id) } - if err := uc.auth.IsPasswordValid(password); err != nil { + if err := service.IsPasswordValid(password); err != nil { return err } diff --git a/internal/constant/constant.go b/internal/constant/constant.go new file mode 100644 index 0000000..af51628 --- /dev/null +++ b/internal/constant/constant.go @@ -0,0 +1,3 @@ +package constant + +const ISO8601Layout string = "2006-01-02 15:04:05" diff --git a/internal/core/interface/driver/driver.go b/internal/core/interface/driver/driver.go index 87e5fc7..d6ef3e3 100644 --- a/internal/core/interface/driver/driver.go +++ b/internal/core/interface/driver/driver.go @@ -4,7 +4,6 @@ import ( "context" "keep-it-up/internal/core/model" "keep-it-up/internal/infrastructure/database" - "time" ) type GameManagement interface { @@ -19,28 +18,48 @@ type AccessManagement interface { } type PlayerManagement interface { - AddPlayer(ctx context.Context, name string, username string, password string) (database.Player, error) - UpdatePlayerName(ctx context.Context, id int64, name string) error - UpdatePlayerPassword(ctx context.Context, id int64, currentPassword string, newPassword string) error - UpdatePlayerPasswordForce(ctx context.Context, id int64, password string) error - DeletePlayer(ctx context.Context, id int64) error + AddPlayer( + ctx context.Context, name string, username string, password string, + ) (database.Player, error) + UpdatePlayerName( + ctx context.Context, id int64, name string, + ) error + UpdatePlayerPassword( + ctx context.Context, id int64, currentPassword string, newPassword string, + ) error + UpdatePlayerPasswordForce( + ctx context.Context, id int64, password string, + ) error + DeletePlayer( + ctx context.Context, id int64, + ) error } type Authentication interface { - IsPasswordValid(password string) error - GeneratePasswordHash(password string) (string, error) - CheckPlayerPassword(ctx context.Context, username string, password string) (bool, error) - LoginPlayer(ctx context.Context, username string, password string) (model.AuthResult, error) + CheckPlayerPassword( + ctx context.Context, username string, password string, + ) (bool, error) + LoginPlayer( + ctx context.Context, username string, password string, + ) (model.AuthResult, error) } type DataFetching interface { - ListPlayerGames(ctx context.Context, playerId int64) ([]database.Game, error) - GetSharedData(ctx context.Context, gameId int64) (model.SharedData, error) - ListInteractions(ctx context.Context, gameId int64, count int) ([]database.Interaction, error) + ListPlayerGames( + ctx context.Context, playerId int64, + ) ([]database.Game, error) + GetSharedData( + ctx context.Context, gameId int64, + ) (*model.SharedData, error) + ListInteractions( + ctx context.Context, gameId int64, limit int64, + ) ([]database.Interaction, error) } type GameCommands interface { - SaveGame(ctx context.Context, gameId int64, playerId int64, amount time.Time) error + SaveGame( + ctx context.Context, gameId int64, playerId int64, duration int64, + ) error ResumeGame(ctx context.Context, gameId int64, playerId int64) error PauseGame(ctx context.Context, gameId int64, playerId int64) error } diff --git a/internal/core/service/authentication.go b/internal/core/service/authentication.go new file mode 100644 index 0000000..1622cc5 --- /dev/null +++ b/internal/core/service/authentication.go @@ -0,0 +1,33 @@ +package service + +import ( + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func IsPasswordValid(password string) error { + if strings.ContainsRune(password, ' ') { + return fmt.Errorf("Password cannot have whitespaces") + } + + if len(password) < 6 { + return fmt.Errorf("Password cannot have less than 6 characters") + } + + return nil +} + +func GeneratePasswordHash(password string) (string, error) { + if err := IsPasswordValid(password); err != nil { + return "", err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return string(hash), nil +} diff --git a/internal/core/service/authentication_test.go b/internal/core/service/authentication_test.go new file mode 100644 index 0000000..84b8390 --- /dev/null +++ b/internal/core/service/authentication_test.go @@ -0,0 +1,156 @@ +package service + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestAuthentication_IsPasswordValid(t *testing.T) { + for _, password := range []string{"secret123", "abc123"} { + if err := IsPasswordValid(password); err != nil { + t.Fatalf("IsPasswordValid() rejected a valid password %q: %v", password, err) + } + } + + for _, password := range []string{ + "", "short", "abc12", "pa ss", "p ass ", "secret 123", "pass ", + "alice", " alice ", " secret123 ", "secret123 ", " secret123", + "\tsecret 123\n", + } { + if err := IsPasswordValid(password); err == nil { + t.Fatalf("IsPasswordValid() accepted an invalid password %q", password) + } + } +} + +func TestAuthentication_IsPasswordValidBoundary(t *testing.T) { + if err := IsPasswordValid("abc123"); err != nil { + t.Fatalf("IsPasswordValid() rejected valid 6-character password: %v", err) + } + + // Test one below boundary: 5 characters (should fail) + if err := IsPasswordValid("abc12"); err == nil { + t.Fatal("IsPasswordValid() accepted 5-character password (below minimum)") + } + + // Test with whitespace at boundary + if err := IsPasswordValid(" abc123"); err == nil { + t.Fatalf("IsPasswordValid() accepted invalid 6-char password with leading space: %v", err) + } + if err := IsPasswordValid("abc123 "); err == nil { + t.Fatalf("IsPasswordValid() accepted invalid 6-char password with trailing space: %v", err) + } +} + +func TestAuthentication_GeneratePasswordHash(t *testing.T) { + hash, err := GeneratePasswordHash("secret123") + if err != nil { + t.Fatalf("GeneratePasswordHash() returned error: %v", err) + } + if hash == "" { + t.Fatal("GeneratePasswordHash() returned empty hash") + } + if hash == "secret123" { + t.Fatal("GeneratePasswordHash() returned the raw password instead of a hash") + } + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("secret123")); err != nil { + t.Fatalf("bcrypt.CompareHashAndPassword() failed for generated hash: %v", err) + } +} + +func TestAuthentication_GeneratePasswordHashRejectsShortPassword(t *testing.T) { + if _, err := GeneratePasswordHash("short"); err == nil { + t.Fatal("GeneratePasswordHash() accepted a password shorter than 6 characters") + } +} + +func TestAuthentication_IsPasswordValidBoundaryPrecision(t *testing.T) { + // Test exactly at boundary: 6 characters (minimum valid) + // Note: This test documents desired behavior for password validation boundaries + testCases := []struct { + password string + shouldValidate bool + }{ + {"abc123", true}, // exactly 6 chars - at boundary + {"abc12", false}, // 5 chars - one below minimum + {"abc1234", true}, // 7 chars - one above minimum + {"123456", true}, // 6 digits + {"aaaaaa", true}, // 6 of same letter + } + + for _, tc := range testCases { + err := IsPasswordValid(tc.password) + if tc.shouldValidate && err != nil { + t.Fatalf("IsPasswordValid() should validate %q (length=%d): %v", tc.password, len(tc.password), err) + } + if !tc.shouldValidate && err == nil { + t.Fatalf("IsPasswordValid() should reject %q (length=%d)", tc.password, len(tc.password)) + } + } +} + +func TestAuthentication_GeneratePasswordHashWithValidPasswords(t *testing.T) { + validPasswords := []string{ + "abc123", // exactly 6 chars + "secret123", // 9 chars + "password", // 8 chars + "aaaaaaaaa", // 9 of same character + "123456789", // 9 digits + } + + for _, password := range validPasswords { + hash, err := GeneratePasswordHash(password) + if err != nil { + t.Fatalf("GeneratePasswordHash() failed for valid password %q: %v", password, err) + } + if hash == "" { + t.Fatalf("GeneratePasswordHash() returned empty hash for %q", password) + } + if hash == password { + t.Fatalf("GeneratePasswordHash() returned unhashed password for %q", password) + } + } +} + +func TestAuthentication_IsPasswordValidWithUnicodeCharacters(t *testing.T) { + // Test that unicode characters are allowed (if validation allows non-ASCII) + // The exact behavior depends on the validation implementation + testCases := []struct { + password string + desc string + }{ + {"пароль123", "Cyrillic password"}, + {"password123", "ASCII password"}, + {"пароль", "Cyrillic word only"}, + } + + for _, tc := range testCases { + // Just verify the function doesn't panic or error unexpectedly + _ = IsPasswordValid(tc.password) + } +} + +func TestAuthentication_GeneratePasswordHashIsConsistent(t *testing.T) { + password := "secret123" + hash1, err1 := GeneratePasswordHash(password) + hash2, err2 := GeneratePasswordHash(password) + + if err1 != nil || err2 != nil { + t.Fatalf("GeneratePasswordHash() returned error: err1=%v, err2=%v", err1, err2) + } + + // Bcrypt hashes should be different even for same password (due to salt) + if hash1 == hash2 { + t.Fatal("GeneratePasswordHash() produced identical hashes for same password (should be different due to salt)") + } + + // But both should validate against the same password + if err := bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)); err != nil { + t.Fatalf("First hash does not match password: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)); err != nil { + t.Fatalf("Second hash does not match password: %v", err) + } +} diff --git a/internal/core/service/shared_data.go b/internal/core/service/shared_data.go new file mode 100644 index 0000000..c851718 --- /dev/null +++ b/internal/core/service/shared_data.go @@ -0,0 +1,106 @@ +package service + +import ( + "fmt" + "time" + + "keep-it-up/internal/constant" + "keep-it-up/internal/core/model" + "keep-it-up/internal/infrastructure/database" +) + +func BuildSharedData( + gameId int64, interactions []database.Interaction, +) (*model.SharedData, error) { + data := &model.SharedData{ + GameID: gameId, + Status: model.NotStarted, + } + + if len(interactions) == 0 { + return data, nil + } + + var prevOccurredAt time.Time + + for _, ia := range interactions { + if ia.GameID != data.GameID { + return nil, fmt.Errorf( + "build shared data: interaction %d belongs to game %d, expected %d", + ia.ID, ia.GameID, data.GameID, + ) + } + + occurredAt, err := time.Parse(constant.ISO8601Layout, ia.OccurredAt) + if err != nil { + return nil, fmt.Errorf( + "build shared data: interaction %d: parse occurred_at: %w", + ia.ID, err, + ) + } + if !prevOccurredAt.IsZero() && occurredAt.Before(prevOccurredAt) { + return nil, fmt.Errorf( + "build shared data: interaction %d: occurred_at out of order", + ia.ID, + ) + } + prevOccurredAt = occurredAt + + switch ia.Action { + case "saved": + if data.Status == model.Paused { + return nil, fmt.Errorf( + "build shared data: interaction %d: cannot save while paused", + ia.ID, + ) + } + if !ia.SavedBy.Valid || ia.SavedBy.Int64 <= 0 { + return nil, fmt.Errorf( + "build shared data: interaction %d: invalid saved_by", + ia.ID, + ) + } + extension := time.Duration(ia.SavedBy.Int64) * time.Second + + if data.Status == model.NotStarted { + deadline := occurredAt.Add(extension) + data.DeadlineAt = &deadline + data.Status = model.Playing + } else { + deadline := data.DeadlineAt.Add(extension) + data.DeadlineAt = &deadline + } + data.LastSavedAt = &occurredAt + + case "paused": + if data.Status != model.Playing { + return nil, fmt.Errorf( + "build shared data: interaction %d: cannot pause from status %q", + ia.ID, data.Status, + ) + } + data.Status = model.Paused + data.LastPausedAt = &occurredAt + + case "resumed": + if data.Status != model.Paused { + return nil, fmt.Errorf( + "build shared data: interaction %d: cannot resume from status %q", + ia.ID, data.Status, + ) + } + deadline := data.DeadlineAt.Add(occurredAt.Sub(*data.LastPausedAt)) + data.DeadlineAt = &deadline + data.Status = model.Playing + data.LastPausedAt = nil + + default: + return nil, fmt.Errorf( + "build shared data: interaction %d: unknown action %q", + ia.ID, ia.Action, + ) + } + } + + return data, nil +} diff --git a/internal/infrastructure/database/fetching.sql.go b/internal/infrastructure/database/fetching.sql.go new file mode 100644 index 0000000..86ec596 --- /dev/null +++ b/internal/infrastructure/database/fetching.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: fetching.sql + +package database + +import ( + "context" +) + +const listInteractionsForReplay = `-- name: ListInteractionsForReplay :many +SELECT id, game_id, player_id, action, occurred_at, saved_by +FROM interactions +WHERE game_id = ? +ORDER BY occurred_at ASC, id ASC +` + +func (q *Queries) ListInteractionsForReplay(ctx context.Context, gameID int64) ([]Interaction, error) { + rows, err := q.db.QueryContext(ctx, listInteractionsForReplay, gameID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Interaction + for rows.Next() { + var i Interaction + if err := rows.Scan( + &i.ID, + &i.GameID, + &i.PlayerID, + &i.Action, + &i.OccurredAt, + &i.SavedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlayerGames = `-- name: ListPlayerGames :many +SELECT + games.id, + games.name +FROM games +JOIN access ON access.game_id = games.id +WHERE access.player_id = ? +` + +func (q *Queries) ListPlayerGames(ctx context.Context, playerID int64) ([]Game, error) { + rows, err := q.db.QueryContext(ctx, listPlayerGames, playerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Game + for rows.Next() { + var i Game + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecentInteractions = `-- name: ListRecentInteractions :many +SELECT id, game_id, player_id, action, occurred_at, saved_by +FROM interactions +WHERE game_id = ? +ORDER BY occurred_at DESC, id DESC +LIMIT ? +` + +type ListRecentInteractionsParams struct { + GameID int64 + Limit int64 +} + +func (q *Queries) ListRecentInteractions(ctx context.Context, arg ListRecentInteractionsParams) ([]Interaction, error) { + rows, err := q.db.QueryContext(ctx, listRecentInteractions, arg.GameID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Interaction + for rows.Next() { + var i Interaction + if err := rows.Scan( + &i.ID, + &i.GameID, + &i.PlayerID, + &i.Action, + &i.OccurredAt, + &i.SavedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/infrastructure/database/interactions.sql.go b/internal/infrastructure/database/interactions.sql.go index e9444f3..5fa4fb3 100644 --- a/internal/infrastructure/database/interactions.sql.go +++ b/internal/infrastructure/database/interactions.sql.go @@ -28,14 +28,22 @@ type AddInteractionParams struct { OccurredAt string } -func (q *Queries) AddInteraction(ctx context.Context, arg AddInteractionParams) (Interaction, error) { +type AddInteractionRow struct { + ID int64 + GameID int64 + PlayerID sql.NullInt64 + Action string + OccurredAt string +} + +func (q *Queries) AddInteraction(ctx context.Context, arg AddInteractionParams) (AddInteractionRow, error) { row := q.db.QueryRowContext(ctx, addInteraction, arg.GameID, arg.PlayerID, arg.Action, arg.OccurredAt, ) - var i Interaction + var i AddInteractionRow err := row.Scan( &i.ID, &i.GameID, diff --git a/internal/infrastructure/database/models.go b/internal/infrastructure/database/models.go index a59c996..e0df975 100644 --- a/internal/infrastructure/database/models.go +++ b/internal/infrastructure/database/models.go @@ -24,6 +24,7 @@ type Interaction struct { PlayerID sql.NullInt64 Action string OccurredAt string + SavedBy sql.NullInt64 } type Player struct { diff --git a/internal/infrastructure/driver/cli.go b/internal/infrastructure/driver/cli.go index da3e616..bfce63b 100644 --- a/internal/infrastructure/driver/cli.go +++ b/internal/infrastructure/driver/cli.go @@ -6,14 +6,11 @@ import ( "fmt" "io" "strconv" - "time" driverport "keep-it-up/internal/core/interface/driver" + "keep-it-up/internal/core/service" ) -// Sentinel errors distinguish CLI-usage failures (bad noun, bad verb, wrong -// arg count) from errors returned by the use cases themselves. Tests and -// callers can discriminate with errors.Is instead of string-matching. var ( ErrNoCommand = errors.New("no command given") ErrNoSubcommand = errors.New("no subcommand given") @@ -31,22 +28,22 @@ Nouns: game add update delete - access grant - revoke + access grant + revoke player add rename - passwd + passwd passwd-force delete auth validate-passwd hash-passwd check-passwd - data games - shared - interactions - session save - resume - pause + data games + shared + interactions + session save + resume + pause ` // Deps groups the driver ports and I/O streams the CLI needs. It is a @@ -75,16 +72,10 @@ type CLI struct { d Deps } -// New constructs a CLI adapter. Every dependency is explicit — there is no -// fallback to os.Stdout/os.Stderr — so tests never need to intercept global -// state to observe output. func New(d Deps) *CLI { return &CLI{d: d} } -// Run parses args (typically os.Args[1:]) and dispatches to the matching -// use case. It returns a non-nil error on any usage problem or use-case -// failure; the caller (main) decides how that maps to an exit code. func (c *CLI) Run(ctx context.Context, args []string) error { if len(args) == 0 { fmt.Fprint(c.d.Stderr, usage) @@ -190,7 +181,7 @@ func (c *CLI) runAccess(ctx context.Context, args []string) error { switch verb { case "grant": if len(rest) != 2 { - return wrongArgs("access grant", "access grant ") + return wrongArgs("access grant", "access grant ") } gameID, err := parseID(rest[0]) if err != nil { @@ -208,7 +199,7 @@ func (c *CLI) runAccess(ctx context.Context, args []string) error { case "revoke": if len(rest) != 2 { - return wrongArgs("access revoke", "access revoke ") + return wrongArgs("access revoke", "access revoke ") } gameID, err := parseID(rest[0]) if err != nil { @@ -264,7 +255,7 @@ func (c *CLI) runPlayer(ctx context.Context, args []string) error { case "passwd": if len(rest) != 3 { - return wrongArgs("player passwd", "player passwd ") + return wrongArgs("player passwd", "player passwd ") } id, err := parseID(rest[0]) if err != nil { @@ -321,7 +312,7 @@ func (c *CLI) runAuth(ctx context.Context, args []string) error { if len(rest) != 1 { return wrongArgs("auth validate-passwd", "auth validate-passwd ") } - if err := c.d.Auth.IsPasswordValid(rest[0]); err != nil { + if err := service.IsPasswordValid(rest[0]); err != nil { return fmt.Errorf("auth validate-passwd: %w", err) } fmt.Fprintln(c.d.Stdout, "valid") @@ -331,7 +322,7 @@ func (c *CLI) runAuth(ctx context.Context, args []string) error { if len(rest) != 1 { return wrongArgs("auth hash-passwd", "auth hash-passwd ") } - hash, err := c.d.Auth.GeneratePasswordHash(rest[0]) + hash, err := service.GeneratePasswordHash(rest[0]) if err != nil { return fmt.Errorf("auth hash-passwd: %w", err) } @@ -364,7 +355,7 @@ func (c *CLI) runData(ctx context.Context, args []string) error { switch verb { case "games": if len(rest) != 1 { - return wrongArgs("data games", "data games ") + return wrongArgs("data games", "data games ") } playerID, err := parseID(rest[0]) if err != nil { @@ -381,7 +372,7 @@ func (c *CLI) runData(ctx context.Context, args []string) error { case "shared": if len(rest) != 1 { - return wrongArgs("data shared", "data shared ") + return wrongArgs("data shared", "data shared ") } gameID, err := parseID(rest[0]) if err != nil { @@ -396,17 +387,17 @@ func (c *CLI) runData(ctx context.Context, args []string) error { case "interactions": if len(rest) != 2 { - return wrongArgs("data interactions", "data interactions ") + return wrongArgs("data interactions", "data interactions ") } gameID, err := parseID(rest[0]) if err != nil { return fmt.Errorf("data interactions: %w", err) } - count, err := strconv.Atoi(rest[1]) + limit, err := strconv.ParseInt(rest[1], 10, 64) if err != nil { - return fmt.Errorf("data interactions: invalid count %q: %w", rest[1], err) + return fmt.Errorf("data interactions: invalid limit %q: %w", rest[1], err) } - interactions, err := c.d.Data.ListInteractions(ctx, gameID, count) + interactions, err := c.d.Data.ListInteractions(ctx, gameID, limit) if err != nil { return fmt.Errorf("data interactions: %w", err) } @@ -434,7 +425,7 @@ func (c *CLI) runSession(ctx context.Context, args []string) error { switch verb { case "save": if len(rest) != 3 { - return wrongArgs("session save", "session save ") + return wrongArgs("session save", "session save ") } gameID, err := parseID(rest[0]) if err != nil { @@ -444,23 +435,19 @@ func (c *CLI) runSession(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("session save: %w", err) } - // GameCommands.SaveGame's third parameter is typed time.Time but - // named "amount"; treated here as the checkpoint timestamp to save - // against, parsed as RFC3339. Adjust if it actually means something - // else (e.g. an elapsed duration serialized as a time.Time). - amount, err := time.Parse(time.RFC3339, rest[2]) + duration, err := strconv.ParseInt(rest[2], 10, 64) if err != nil { return fmt.Errorf("session save: invalid timestamp %q: %w", rest[2], err) } - if err := c.d.Commands.SaveGame(ctx, gameID, playerID, amount); err != nil { + if err := c.d.Commands.SaveGame(ctx, gameID, playerID, duration); err != nil { return fmt.Errorf("session save: %w", err) } - fmt.Fprintf(c.d.Stdout, "game %d saved for player %d at %s\n", gameID, playerID, amount.Format(time.RFC3339)) + fmt.Fprintf(c.d.Stdout, "game %d saved by player %d for %d min\n", gameID, playerID, duration) return nil case "resume": if len(rest) != 2 { - return wrongArgs("session resume", "session resume ") + return wrongArgs("session resume", "session resume ") } gameID, err := parseID(rest[0]) if err != nil { @@ -478,7 +465,7 @@ func (c *CLI) runSession(ctx context.Context, args []string) error { case "pause": if len(rest) != 2 { - return wrongArgs("session pause", "session pause ") + return wrongArgs("session pause", "session pause ") } gameID, err := parseID(rest[0]) if err != nil { diff --git a/internal/infrastructure/driver/cli_test.go b/internal/infrastructure/driver/cli_test.go index 29f9425..8405a4d 100644 --- a/internal/infrastructure/driver/cli_test.go +++ b/internal/infrastructure/driver/cli_test.go @@ -6,7 +6,6 @@ import ( "errors" "strings" "testing" - "time" "keep-it-up/internal/infrastructure/database" "keep-it-up/internal/infrastructure/driver" @@ -31,13 +30,13 @@ func (m *mockGames) DeleteGame(ctx context.Context, id int64) error { } type mockCommands struct { - SaveGameFunc func(ctx context.Context, gameID, playerID int64, amount time.Time) error + SaveGameFunc func(ctx context.Context, gameID, playerID int64, duration int64) error ResumeGameFunc func(ctx context.Context, gameID, playerID int64) error PauseGameFunc func(ctx context.Context, gameID, playerID int64) error } -func (m *mockCommands) SaveGame(ctx context.Context, gameID, playerID int64, amount time.Time) error { - return m.SaveGameFunc(ctx, gameID, playerID, amount) +func (m *mockCommands) SaveGame(ctx context.Context, gameID, playerID int64, duration int64) error { + return m.SaveGameFunc(ctx, gameID, playerID, duration) } func (m *mockCommands) ResumeGame(ctx context.Context, gameID, playerID int64) error { return m.ResumeGameFunc(ctx, gameID, playerID) @@ -117,19 +116,22 @@ func TestCLI_Run(t *testing.T) { // Session subcommand tests { name: "session save success", - args: []string{"session", "save", "10", "20", "2026-08-17T09:46:15Z"}, + args: []string{"session", "save", "10", "20", "300"}, setupMocks: func(d *driver.Deps) { d.Commands = &mockCommands{ - SaveGameFunc: func(ctx context.Context, gameID, playerID int64, amount time.Time) error { + SaveGameFunc: func(ctx context.Context, gameID, playerID int64, duration int64) error { if gameID != 10 || playerID != 20 { t.Errorf("unexpected IDs") } + if duration != 300 { + t.Errorf("expected duration 300, got %d", duration) + } return nil }, } }, expectedErr: nil, - expectedStdout: "game 10 saved for player 20 at 2026-08-17T09:46:15Z\n", + expectedStdout: "game 10 saved by player 20 for 300 min\n", }, { name: "session save with invalid timestamp",