Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
Expand All @@ -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,
}
Expand Down
13 changes: 5 additions & 8 deletions database/migrations/20260815203118_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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;
DROP TABLE IF EXISTS interactions;
DROP TABLE IF EXISTS access;
DROP TABLE IF EXISTS players;
DROP TABLE IF EXISTS games;
33 changes: 33 additions & 0 deletions database/queries/commands.sql
Original file line number Diff line number Diff line change
@@ -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;
106 changes: 106 additions & 0 deletions internal/application/usecase/access_management_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
118 changes: 118 additions & 0 deletions internal/application/usecase/data_fetching_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
Loading
Loading