package companion import ( "context" "encoding/json" "io" "net/http" "os" "strings" "testing" "time" ) func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) { stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC) config := loadTestConfig(t) var snapshot Snapshot client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) { if request.URL.Path != snapshotPath { t.Fatalf("unexpected projection request path: %s", request.URL.Path) } var body snapshotRequest if err := json.NewDecoder(request.Body).Decode(&body); err != nil { t.Fatalf("decode snapshot request: %v", err) } snapshot = Snapshot{Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, ObservedAt: body.ObservedAt, Payload: body.Payload, KeepForSeconds: body.KeepForSeconds, MaxRecords: body.MaxRecords} return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot-1","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current","sequence":1}`))}, nil }), stamp) client.mu.Lock() client.sessionToken = "component-session" client.sessionExpiresAt = stamp.Add(time.Hour) client.mu.Unlock() projection := NewSCUMPlayerLogProjection(client, "server-example") projection.Now = func() time.Time { return stamp } if err := projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 7, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: "Ada", OccurredAt: stamp}}}); err != nil { t.Fatalf("project login event: %v", err) } players, ok := snapshot.Payload["players"].([]any) if !ok || len(players) != 1 { t.Fatalf("expected one projected player, payload=%#v", snapshot.Payload) } player, ok := players[0].(map[string]any) if !ok || player["playerId"] != "76561198000000001" || player["playerName"] != "Ada" || player["status"] != "online" { t.Fatalf("unexpected projected player: %#v", players[0]) } } func TestSCUMPlayerLogProjectionKeepsSequenceAcrossRestart(t *testing.T) { stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC) config := loadTestConfig(t) sequences := make([]uint64, 0, 2) client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) { if request.URL.Path != snapshotPath { t.Fatalf("unexpected projection request path: %s", request.URL.Path) } var body snapshotRequest if err := json.NewDecoder(request.Body).Decode(&body); err != nil { t.Fatalf("decode snapshot request: %v", err) } sequences = append(sequences, body.Sequence) return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current"}`))}, nil }), stamp) client.mu.Lock() client.sessionToken = "component-session" client.sessionExpiresAt = stamp.Add(time.Hour) client.mu.Unlock() originalDirectory, err := os.Getwd() if err != nil { t.Fatalf("get working directory: %v", err) } t.Chdir(t.TempDir()) t.Cleanup(func() { _ = os.Chdir(originalDirectory) }) store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) if err != nil { t.Fatalf("create sequence store: %v", err) } project := func(name string) error { projection := NewSCUMPlayerLogProjection(client, "server-example") projection.Now = func() time.Time { return stamp } projection.SequenceStore = store return projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 1, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: name, OccurredAt: stamp}}}) } if err := project("Ada"); err != nil { t.Fatalf("project before restart: %v", err) } restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename) if err != nil { t.Fatalf("restart sequence store: %v", err) } store = restarted if err := project("Ada Lovelace"); err != nil { t.Fatalf("project after restart: %v", err) } if len(sequences) != 2 || sequences[1] <= sequences[0] { t.Fatalf("expected restart-safe monotonic snapshots, got %v", sequences) } }