package lobbyclient import ( "context" "errors" "net/http" "net/http/httptest" "strconv" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "galaxy/gamemaster/internal/ports" ) func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client { t.Helper() client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout}) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) return client } func TestNewClientValidatesConfig(t *testing.T) { cases := map[string]Config{ "empty base url": {BaseURL: "", RequestTimeout: time.Second}, "non-absolute base url": {BaseURL: "lobby:8095", RequestTimeout: time.Second}, "non-positive timeout": {BaseURL: "http://lobby:8095", RequestTimeout: 0}, } for name, cfg := range cases { t.Run(name, func(t *testing.T) { _, err := NewClient(cfg) require.Error(t, err) }) } } func TestGetMembershipsHappyPathSinglePage(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, "/api/v1/internal/games/game-1/memberships", r.URL.Path) assert.Equal(t, strconv.Itoa(pageSize), r.URL.Query().Get("page_size")) assert.Empty(t, r.URL.Query().Get("page_token")) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "items": [ {"membership_id":"m1","game_id":"game-1","user_id":"u1","race_name":"Human","status":"active","joined_at":1700000000000}, {"membership_id":"m2","game_id":"game-1","user_id":"u2","race_name":"Klingon","status":"removed","joined_at":1700000010000,"removed_at":1700000020000} ] }`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) memberships, err := client.GetMemberships(context.Background(), "game-1") require.NoError(t, err) require.Len(t, memberships, 2) assert.Equal(t, "u1", memberships[0].UserID) assert.Equal(t, "Human", memberships[0].RaceName) assert.Equal(t, "active", memberships[0].Status) assert.Equal(t, time.UnixMilli(1700000000000).UTC(), memberships[0].JoinedAt) assert.Nil(t, memberships[0].RemovedAt) assert.Equal(t, "removed", memberships[1].Status) require.NotNil(t, memberships[1].RemovedAt) assert.Equal(t, time.UnixMilli(1700000020000).UTC(), *memberships[1].RemovedAt) } func TestGetMembershipsFollowsPagination(t *testing.T) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := calls.Add(1) w.Header().Set("Content-Type", "application/json") switch call { case 1: assert.Empty(t, r.URL.Query().Get("page_token")) _, _ = w.Write([]byte(`{ "items":[{"membership_id":"m1","game_id":"g","user_id":"u1","race_name":"Human","status":"active","joined_at":1}], "next_page_token":"tok-2" }`)) case 2: assert.Equal(t, "tok-2", r.URL.Query().Get("page_token")) _, _ = w.Write([]byte(`{ "items":[{"membership_id":"m2","game_id":"g","user_id":"u2","race_name":"Klingon","status":"active","joined_at":2}], "next_page_token":"tok-3" }`)) case 3: assert.Equal(t, "tok-3", r.URL.Query().Get("page_token")) _, _ = w.Write([]byte(`{ "items":[{"membership_id":"m3","game_id":"g","user_id":"u3","race_name":"Vulcan","status":"blocked","joined_at":3}] }`)) default: t.Fatalf("unexpected extra call %d", call) } })) defer server.Close() client := newTestClient(t, server.URL, time.Second) memberships, err := client.GetMemberships(context.Background(), "g") require.NoError(t, err) require.Len(t, memberships, 3) assert.Equal(t, "u1", memberships[0].UserID) assert.Equal(t, "u2", memberships[1].UserID) assert.Equal(t, "u3", memberships[2].UserID) assert.Equal(t, int32(3), calls.Load()) } func TestGetMembershipsPaginationOverflow(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"items":[],"next_page_token":"never-ends"}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetMemberships(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) assert.Contains(t, err.Error(), "pagination overflow") } func TestGetMembershipsInternalErrorMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetMemberships(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) assert.Contains(t, err.Error(), "internal_error") } func TestGetMembershipsTimeoutMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(120 * time.Millisecond) _, _ = w.Write([]byte(`{}`)) })) defer server.Close() client := newTestClient(t, server.URL, 30*time.Millisecond) _, err := client.GetMemberships(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) } func TestGetMembershipsRejectsBadInput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("must not contact lobby on bad input") })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetMemberships(context.Background(), " ") require.Error(t, err) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = client.GetMemberships(ctx, "g") require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled)) } func TestGetMembershipsMalformedPayload(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"items":[{"membership_id":"m","game_id":"g","user_id":"","race_name":"","status":"active","joined_at":1}]}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetMemberships(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) } func TestGetMembershipsEmptyList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"items":[]}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) memberships, err := client.GetMemberships(context.Background(), "g") require.NoError(t, err) assert.Empty(t, memberships) } func TestGetMembershipsTrailingJSONIsRejected(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"items":[]}{"items":[]}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetMemberships(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) } func TestGetGameSummaryHappyPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, "/api/v1/internal/games/game-1", r.URL.Path) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "game_id":"game-1", "game_name":"Andromeda Conquest", "game_type":"public", "owner_user_id":"", "status":"running", "min_players":2, "max_players":8, "start_gap_hours":2, "start_gap_players":4, "enrollment_ends_at":1700000000, "turn_schedule":"0 18 * * *", "target_engine_version":"v1.2.3", "created_at":1700000000000, "updated_at":1700000000000, "current_turn":0, "runtime_status":"", "engine_health_summary":"" }`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) summary, err := client.GetGameSummary(context.Background(), "game-1") require.NoError(t, err) assert.Equal(t, ports.GameSummary{ GameID: "game-1", GameName: "Andromeda Conquest", Status: "running", }, summary) } func TestGetGameSummaryNotFoundMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"game not found"}}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetGameSummary(context.Background(), "missing") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) assert.Contains(t, err.Error(), "not_found") } func TestGetGameSummaryInternalErrorMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetGameSummary(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) assert.Contains(t, err.Error(), "internal_error") } func TestGetGameSummaryTimeoutMapsToUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(120 * time.Millisecond) _, _ = w.Write([]byte(`{}`)) })) defer server.Close() client := newTestClient(t, server.URL, 30*time.Millisecond) _, err := client.GetGameSummary(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) } func TestGetGameSummaryMalformedJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{not-json}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetGameSummary(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) } func TestGetGameSummaryMissingRequiredFields(t *testing.T) { cases := map[string]string{ "missing game_id": `{"game_name":"Andromeda","status":"running"}`, "missing game_name": `{"game_id":"g","status":"running"}`, "missing status": `{"game_id":"g","game_name":"Andromeda"}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetGameSummary(context.Background(), "g") require.Error(t, err) assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable)) }) } } func TestGetGameSummaryRejectsBadInput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("must not contact lobby on bad input") })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, err := client.GetGameSummary(context.Background(), " ") require.Error(t, err) ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = client.GetGameSummary(ctx, "g") require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled)) } func TestCloseIsIdempotent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"items":[]}`)) })) defer server.Close() client := newTestClient(t, server.URL, time.Second) _, _ = client.GetMemberships(context.Background(), "g") require.NoError(t, client.Close()) require.NoError(t, client.Close()) }