Files
galaxy-game/rtmanager/integration/harness/store.go
T
2026-04-28 20:39:18 +02:00

129 lines
4.3 KiB
Go

package harness
import (
"context"
"errors"
"testing"
"time"
"galaxy/rtmanager/internal/adapters/postgres/healthsnapshotstore"
"galaxy/rtmanager/internal/adapters/postgres/operationlogstore"
"galaxy/rtmanager/internal/adapters/postgres/runtimerecordstore"
"galaxy/rtmanager/internal/domain/health"
"galaxy/rtmanager/internal/domain/operation"
"galaxy/rtmanager/internal/domain/runtime"
"github.com/stretchr/testify/require"
)
// RuntimeRecord returns the persisted runtime record for gameID. The
// helper opens the store on every call (cheap; the harness `*sql.DB`
// is shared) so individual scenarios stay isolated even if a previous
// test mutated store state.
func RuntimeRecord(t testing.TB, env *Env, gameID string) (runtime.RuntimeRecord, error) {
t.Helper()
store, err := runtimerecordstore.New(runtimerecordstore.Config{
DB: env.Postgres.Pool(),
OperationTimeout: pgOperationTimeout,
})
require.NoError(t, err)
return store.Get(context.Background(), gameID)
}
// MustRuntimeRecord asserts that the record exists and returns it.
func MustRuntimeRecord(t testing.TB, env *Env, gameID string) runtime.RuntimeRecord {
t.Helper()
record, err := RuntimeRecord(t, env, gameID)
require.NoErrorf(t, err, "load runtime record for %s", gameID)
return record
}
// EventuallyRuntimeRecord polls until predicate matches the runtime
// record for gameID, or the deadline fires. Returns the matching
// record. Used by lifecycle assertions that depend on async state
// transitions (start consumer → record).
func EventuallyRuntimeRecord(t testing.TB, env *Env, gameID string, predicate func(runtime.RuntimeRecord) bool, timeout time.Duration) runtime.RuntimeRecord {
t.Helper()
if timeout <= 0 {
timeout = defaultStreamTimeout
}
deadline := time.Now().Add(timeout)
for {
record, err := RuntimeRecord(t, env, gameID)
if err == nil && predicate(record) {
return record
}
if err != nil && !errors.Is(err, runtime.ErrNotFound) {
t.Fatalf("rtmanager integration: load runtime record: %v", err)
}
if time.Now().After(deadline) {
if err != nil {
t.Fatalf("rtmanager integration: runtime record predicate not met within %s; last err=%v",
timeout, err)
}
t.Fatalf("rtmanager integration: runtime record predicate not met within %s; last record=%+v",
timeout, record)
}
time.Sleep(defaultStreamPoll)
}
}
// OperationEntries returns up to `limit` most-recent operation_log
// entries for gameID, ordered descending by started_at.
func OperationEntries(t testing.TB, env *Env, gameID string, limit int) []operation.OperationEntry {
t.Helper()
store, err := operationlogstore.New(operationlogstore.Config{
DB: env.Postgres.Pool(),
OperationTimeout: pgOperationTimeout,
})
require.NoError(t, err)
entries, err := store.ListByGame(context.Background(), gameID, limit)
require.NoErrorf(t, err, "list operation log entries for %s", gameID)
return entries
}
// EventuallyOperationKind polls operation_log until at least one entry
// for gameID has the requested kind, or the deadline fires. Returns
// the matching entry.
func EventuallyOperationKind(t testing.TB, env *Env, gameID string, kind operation.OpKind, timeout time.Duration) operation.OperationEntry {
t.Helper()
if timeout <= 0 {
timeout = defaultStreamTimeout
}
deadline := time.Now().Add(timeout)
for {
entries := OperationEntries(t, env, gameID, 50)
for _, entry := range entries {
if entry.OpKind == kind {
return entry
}
}
if time.Now().After(deadline) {
t.Fatalf("rtmanager integration: operation_log entry with op_kind=%s not seen within %s; observed=%v",
kind, timeout, opKindSummary(entries))
}
time.Sleep(defaultStreamPoll)
}
}
// HealthSnapshot returns the latest persisted health snapshot for
// gameID, or the underlying not-found sentinel when nothing has been
// recorded yet.
func HealthSnapshot(t testing.TB, env *Env, gameID string) (health.HealthSnapshot, error) {
t.Helper()
store, err := healthsnapshotstore.New(healthsnapshotstore.Config{
DB: env.Postgres.Pool(),
OperationTimeout: pgOperationTimeout,
})
require.NoError(t, err)
return store.Get(context.Background(), gameID)
}
func opKindSummary(entries []operation.OperationEntry) []string {
out := make([]string, 0, len(entries))
for _, entry := range entries {
out = append(out, string(entry.OpKind)+"/"+string(entry.Outcome))
}
return out
}