feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,343 @@
// Package lobbyclient provides the trusted-internal Lobby REST client
// Game Master uses to fetch membership lists for the in-process
// authorization cache and to resolve the human-readable `game_name`
// consumed by notification intents.
//
// Two endpoints are mounted today:
//
// - `GET /api/v1/internal/games/{game_id}/memberships` — pagination is
// handled internally so callers always receive every membership of
// the game;
// - `GET /api/v1/internal/games/{game_id}` — single read used by the
// turn-generation orchestrator to resolve `game_name` per
// notification.
package lobbyclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
membershipsPathTemplate = "/api/v1/internal/games/%s/memberships"
gameRecordPathTemplate = "/api/v1/internal/games/%s"
// pageSize is the per-call page size; matches the Lobby spec
// maximum (200) so we walk fewer pages on large rosters.
pageSize = 200
// maxPages caps the page walk to defend against an upstream that
// keeps returning a `next_page_token` indefinitely. 64 pages of
// 200 items each cover 12_800 memberships per game — orders of
// magnitude beyond any realistic Galaxy roster.
maxPages = 64
)
// Config configures one HTTP-backed Lobby internal client.
type Config struct {
// BaseURL stores the absolute base URL of the Lobby internal HTTP
// listener (e.g. `http://lobby:8095`).
BaseURL string
// RequestTimeout bounds one outbound page request. The total
// wall-clock for `GetMemberships` is at most
// `RequestTimeout * <pages>`, capped indirectly by the per-page
// limit and `maxPages`.
RequestTimeout time.Duration
}
// Client resolves Lobby memberships through the trusted internal HTTP
// API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type membershipListEnvelope struct {
Items []membershipRecordEnvelope `json:"items"`
NextPageToken string `json:"next_page_token"`
}
type membershipRecordEnvelope struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
JoinedAt int64 `json:"joined_at"`
RemovedAt *int64 `json:"removed_at,omitempty"`
}
// gameRecordEnvelope captures the fields GM consumes from Lobby's
// `GameRecord` schema. Lobby may carry additional fields; the JSON
// decoder ignores them.
type gameRecordEnvelope struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
Status string `json:"status"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs a Lobby internal client with otelhttp-wrapped
// transport cloned from `http.DefaultTransport`. Call `Close` to
// release idle connections at shutdown.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new lobby client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new lobby client: base url must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new lobby client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new lobby client: http client must not be nil")
}
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new lobby client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("new lobby client: base url must be absolute")
}
return &Client{
baseURL: parsed.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// GetMemberships returns every membership of gameID, walking the
// pagination chain transparently. Transport faults, non-2xx responses,
// malformed payloads, and pagination overflow all surface as
// `ports.ErrLobbyUnavailable` so callers can branch with `errors.Is`.
func (client *Client) GetMemberships(ctx context.Context, gameID string) ([]ports.Membership, error) {
if client == nil || client.httpClient == nil {
return nil, errors.New("lobby get memberships: nil client")
}
if ctx == nil {
return nil, errors.New("lobby get memberships: nil context")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(gameID) == "" {
return nil, errors.New("lobby get memberships: game id must not be empty")
}
var memberships []ports.Membership
pathPrefix := fmt.Sprintf(membershipsPathTemplate, url.PathEscape(gameID))
pageToken := ""
for range maxPages {
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, buildPagedQuery(pathPrefix, pageToken))
if err != nil {
return nil, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err)
}
if statusCode != http.StatusOK {
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return nil, fmt.Errorf("%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode)
}
return nil, fmt.Errorf("%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode)
}
var envelope membershipListEnvelope
if err := decodeJSONPayload(payload, &envelope); err != nil {
return nil, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err)
}
for index, item := range envelope.Items {
converted, err := toMembership(item)
if err != nil {
return nil, fmt.Errorf("%w: items[%d]: %w", ports.ErrLobbyUnavailable, index, err)
}
memberships = append(memberships, converted)
}
if strings.TrimSpace(envelope.NextPageToken) == "" {
return memberships, nil
}
pageToken = envelope.NextPageToken
}
return nil, fmt.Errorf("%w: pagination overflow after %d pages", ports.ErrLobbyUnavailable, maxPages)
}
// GetGameSummary returns the narrow projection of Lobby's GameRecord
// (game id, game name, lifecycle status) for gameID. Transport faults,
// non-2xx responses, malformed payloads, and missing required fields
// surface as `ports.ErrLobbyUnavailable` so callers can branch with
// `errors.Is`.
func (client *Client) GetGameSummary(ctx context.Context, gameID string) (ports.GameSummary, error) {
if client == nil || client.httpClient == nil {
return ports.GameSummary{}, errors.New("lobby get game summary: nil client")
}
if ctx == nil {
return ports.GameSummary{}, errors.New("lobby get game summary: nil context")
}
if err := ctx.Err(); err != nil {
return ports.GameSummary{}, err
}
if strings.TrimSpace(gameID) == "" {
return ports.GameSummary{}, errors.New("lobby get game summary: game id must not be empty")
}
requestPath := fmt.Sprintf(gameRecordPathTemplate, url.PathEscape(gameID))
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, requestPath)
if err != nil {
return ports.GameSummary{}, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err)
}
if statusCode != http.StatusOK {
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return ports.GameSummary{}, fmt.Errorf(
"%w: unexpected status %d (error_code=%s)",
ports.ErrLobbyUnavailable, statusCode, errorCode,
)
}
return ports.GameSummary{}, fmt.Errorf(
"%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode,
)
}
var envelope gameRecordEnvelope
if err := decodeJSONPayload(payload, &envelope); err != nil {
return ports.GameSummary{}, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err)
}
if strings.TrimSpace(envelope.GameID) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing game_id", ports.ErrLobbyUnavailable)
}
if strings.TrimSpace(envelope.GameName) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing game_name", ports.ErrLobbyUnavailable)
}
if strings.TrimSpace(envelope.Status) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing status", ports.ErrLobbyUnavailable)
}
return ports.GameSummary{
GameID: envelope.GameID,
GameName: envelope.GameName,
Status: envelope.Status,
}, nil
}
func buildPagedQuery(path, pageToken string) string {
params := url.Values{}
params.Set("page_size", strconv.Itoa(pageSize))
if pageToken != "" {
params.Set("page_token", pageToken)
}
return path + "?" + params.Encode()
}
func toMembership(record membershipRecordEnvelope) (ports.Membership, error) {
if strings.TrimSpace(record.UserID) == "" {
return ports.Membership{}, errors.New("missing user_id")
}
if strings.TrimSpace(record.RaceName) == "" {
return ports.Membership{}, errors.New("missing race_name")
}
if strings.TrimSpace(record.Status) == "" {
return ports.Membership{}, errors.New("missing status")
}
membership := ports.Membership{
UserID: record.UserID,
RaceName: record.RaceName,
Status: record.Status,
JoinedAt: time.UnixMilli(record.JoinedAt).UTC(),
}
if record.RemovedAt != nil {
removedAt := time.UnixMilli(*record.RemovedAt).UTC()
membership.RemovedAt = &removedAt
}
return membership, nil
}
func (client *Client) doRequest(ctx context.Context, method, requestPath string) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return body, resp.StatusCode, nil
}
func decodeJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func decodeErrorCode(payload []byte) string {
if len(payload) == 0 {
return ""
}
var envelope errorEnvelope
if err := json.Unmarshal(payload, &envelope); err != nil {
return ""
}
if envelope.Error == nil {
return ""
}
return envelope.Error.Code
}
// Compile-time assertion: Client implements ports.LobbyClient.
var _ ports.LobbyClient = (*Client)(nil)
@@ -0,0 +1,344 @@
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())
}