diff --git a/.envrc b/.envrc index 91c714b..fb4b158 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1,2 @@ -source .env; +dotenv; use flake; diff --git a/cmd/pruner/pruner.go b/cmd/pruner/pruner.go new file mode 100644 index 0000000..c0bf291 --- /dev/null +++ b/cmd/pruner/pruner.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "log" + "os" + "sync" + "time" + + "github.com/genudine/saerro-go/store" + "github.com/genudine/saerro-go/util" +) + +func main() { + db, err := util.GetDBConnection(os.Getenv("DB_ADDR")) + if err != nil { + log.Fatalln(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + + playerStore := store.NewPlayerStore(db) + i, err := playerStore.Prune(ctx) + if err != nil { + log.Println("pruner: playerStore.Prune failed") + } + + log.Printf("pruner: deleted %d players", i) + }() + + wg.Add(1) + go func() { + defer wg.Done() + + vehicleStore := store.NewVehicleStore(db) + i, err := vehicleStore.Prune(ctx) + if err != nil { + log.Println("pruner: vehicleStore.Prune failed") + } + + log.Printf("pruner: deleted %d vehicles", i) + }() + + wg.Wait() +} diff --git a/cmd/ws/event_handler_test.go b/cmd/ws/event_handler_test.go deleted file mode 100644 index d9cc866..0000000 --- a/cmd/ws/event_handler_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/genudine/saerro-go/translators" - "github.com/genudine/saerro-go/types" - "github.com/stretchr/testify/assert" - - _ "modernc.org/sqlite" -) - -func getEventHandlerTestShim(t *testing.T) (EventHandler, *sql.DB) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatalf("test shim: sqlite open failed, %v", err) - } - - return EventHandler{ - Ingest: &Ingest{ - DB: db, - }, - }, db -} - -func TestHandleDeath(t *testing.T) { - eh, db := getEventHandlerTestShim(t) - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - - event := types.ESSEvent{ - EventName: "Death", - WorldID: 17, - ZoneID: 2, - - CharacterID: "DollNC", - LoadoutID: "3", - TeamID: types.NC, - - AttackerCharacterID: "Lyyti", - AttackerLoadoutID: "3", - AttackerTeamID: types.TR, - } - - eh.HandleDeath(ctx, event) - - type player struct { - CharacterID string `json:"character_id"` - ClassName string `json:"class_name"` - } - - var player1 player - err := db.QueryRowContext(ctx, "SELECT * FROM players WHERE character_id = ?", event.CharacterID).Scan(&player1) - if err != nil { - t.Error(err) - } - - assert.Equal(t, event.CharacterID, player1.CharacterID) - assert.Equal(t, translators.ClassFromLoadout(event.LoadoutID), player1.ClassName) - - var player2 player - err = db.QueryRowContext(ctx, "SELECT * FROM players WHERE character_id = ?", event.AttackerCharacterID).Scan(&player2) - if err != nil { - t.Error(err) - } - - assert.Equal(t, event.AttackerCharacterID, player2.CharacterID) - assert.Equal(t, translators.ClassFromLoadout(event.AttackerLoadoutID), player2.ClassName) -} diff --git a/cmd/ws/event_handler.go b/cmd/ws/eventhandler/event_handler.go similarity index 66% rename from cmd/ws/event_handler.go rename to cmd/ws/eventhandler/event_handler.go index bb57c27..4218201 100644 --- a/cmd/ws/event_handler.go +++ b/cmd/ws/eventhandler/event_handler.go @@ -1,19 +1,30 @@ -package main +package eventhandler import ( "context" - "log" + "database/sql" + "github.com/genudine/saerro-go/cmd/ws/ingest" + "github.com/genudine/saerro-go/store" "github.com/genudine/saerro-go/types" ) type EventHandler struct { - Ingest *Ingest + Ingest *ingest.Ingest +} + +func NewEventHandler(db *sql.DB) EventHandler { + return EventHandler{ + Ingest: &ingest.Ingest{ + PlayerStore: store.NewPlayerStore(db), + }, + } } func (eh *EventHandler) HandleEvent(ctx context.Context, event types.ESSEvent) { if event.EventName == "" { - log.Println("invalid event; dropping") + // log.Println("invalid event; dropping") + return } if event.EventName == "Death" || event.EventName == "VehicleDestroy" { @@ -27,14 +38,14 @@ func (eh *EventHandler) HandleEvent(ctx context.Context, event types.ESSEvent) { func (eh *EventHandler) HandleDeath(ctx context.Context, event types.ESSEvent) { if event.CharacterID != "" && event.CharacterID != "0" { - log.Println("got pop event") - pe := PopEventFromESSEvent(event, false) + // log.Println("got pop event") + pe := types.PopEventFromESSEvent(event, false) eh.Ingest.TrackPop(ctx, pe) } if event.AttackerCharacterID != "" && event.AttackerCharacterID != "0" && event.AttackerTeamID != 0 { - log.Println("got attacker pop event") - pe := PopEventFromESSEvent(event, true) + pe := types.PopEventFromESSEvent(event, true) + // fmt.Println("got attacker pop event", event) eh.Ingest.TrackPop(ctx, pe) } } @@ -45,18 +56,16 @@ func (eh *EventHandler) HandleExperience(ctx context.Context, event types.ESSEve switch event.ExperienceID { case 201: // Galaxy Spawn Bonus vehicleID = "11" - break case 233: // Sunderer Spawn Bonus vehicleID = "2" - break - case 674: // ANT stuff + case 674: + fallthrough // ANT stuff case 675: vehicleID = "160" - break } event.VehicleID = vehicleID - pe := PopEventFromESSEvent(event, false) + pe := types.PopEventFromESSEvent(event, false) eh.Ingest.TrackPop(ctx, pe) } diff --git a/cmd/ws/eventhandler/event_handler_test.go b/cmd/ws/eventhandler/event_handler_test.go new file mode 100644 index 0000000..54d8c78 --- /dev/null +++ b/cmd/ws/eventhandler/event_handler_test.go @@ -0,0 +1,144 @@ +package eventhandler + +import ( + "context" + "testing" + "time" + + "github.com/avast/retry-go" + "github.com/stretchr/testify/assert" + + "github.com/genudine/saerro-go/cmd/ws/ingest" + "github.com/genudine/saerro-go/store" + "github.com/genudine/saerro-go/translators" + "github.com/genudine/saerro-go/types" + "github.com/genudine/saerro-go/util/testutil" +) + +func getEventHandlerTestShim(t *testing.T) (EventHandler, context.Context) { + t.Helper() + + db := testutil.GetTestDB(t) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + t.Cleanup(cancel) + + return EventHandler{ + Ingest: &ingest.Ingest{ + PlayerStore: store.NewPlayerStore(db), + }, + }, ctx +} + +func TestHandleDeath(t *testing.T) { + eh, ctx := getEventHandlerTestShim(t) + + event := types.ESSEvent{ + EventName: "Death", + WorldID: 17, + ZoneID: 2, + + CharacterID: "LyytisDoll", + LoadoutID: 3, + TeamID: types.NC, + + AttackerCharacterID: "Lyyti", + AttackerLoadoutID: 3, + AttackerTeamID: types.TR, + } + + eh.HandleDeath(ctx, event) + + player1, err := eh.Ingest.PlayerStore.GetOne(ctx, event.CharacterID) + assert.NoError(t, err, "player1 fetch failed") + assert.Equal(t, event.CharacterID, player1.CharacterID) + assert.Equal(t, string(translators.ClassFromLoadout(event.LoadoutID)), player1.ClassName) + + player2, err := eh.Ingest.PlayerStore.GetOne(ctx, event.AttackerCharacterID) + assert.NoError(t, err, "player2 fetch failed") + assert.Equal(t, event.AttackerCharacterID, player2.CharacterID) + assert.Equal(t, string(translators.ClassFromLoadout(event.AttackerLoadoutID)), player2.ClassName) +} + +func TestHandleExperience(t *testing.T) { + eh, ctx := getEventHandlerTestShim(t) + + event := types.ESSEvent{ + EventName: "GainExperience", + WorldID: 17, + ZoneID: 2, + + CharacterID: "LyytisDoll", + LoadoutID: 3, + TeamID: types.NC, + + ExperienceID: 674, + } + + eh.HandleExperience(ctx, event) + player, err := eh.Ingest.PlayerStore.GetOne(ctx, event.CharacterID) + assert.NoError(t, err, "player fetch check failed") + assert.Equal(t, event.CharacterID, player.CharacterID) + assert.Equal(t, string(translators.ClassFromLoadout(event.LoadoutID)), player.ClassName) +} + +func TestHandleAnalytics(t *testing.T) { + eh, ctx := getEventHandlerTestShim(t) + event := types.ESSEvent{ + EventName: "GainExperience", + WorldID: 17, + ZoneID: 2, + + CharacterID: "LyytisDoll", + LoadoutID: 3, + TeamID: types.NC, + + ExperienceID: 674, + } + + eh.HandleAnalytics(ctx, event) +} + +func TestHandleEvent(t *testing.T) { + eh, ctx := getEventHandlerTestShim(t) + + events := []types.ESSEvent{ + { + EventName: "Death", + WorldID: 17, + ZoneID: 2, + + CharacterID: "LyytisDoll", + LoadoutID: 3, + TeamID: types.NC, + + AttackerCharacterID: "Lyyti", + AttackerLoadoutID: 3, + AttackerTeamID: types.TR, + }, + { + EventName: "GainExperience", + WorldID: 17, + ZoneID: 2, + + CharacterID: "DollNC", + LoadoutID: 3, + TeamID: types.NC, + + ExperienceID: 201, + }, + } + + for _, event := range events { + eh.HandleEvent(ctx, event) + } + + checkPlayers := []string{"LyytisDoll", "Lyyti", "DollNC"} + for _, id := range checkPlayers { + // eventual consistency <333 + err := retry.Do(func() error { + _, err := eh.Ingest.PlayerStore.GetOne(ctx, id) + return err + }) + assert.NoError(t, err) + } +} diff --git a/cmd/ws/ingest.go b/cmd/ws/ingest.go deleted file mode 100644 index e698856..0000000 --- a/cmd/ws/ingest.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "context" - "database/sql" -) - -type Ingest struct { - DB *sql.DB -} - -func (i *Ingest) TrackPop(ctx context.Context, event PopEvent) { - -} diff --git a/cmd/ws/ingest/ingest.go b/cmd/ws/ingest/ingest.go new file mode 100644 index 0000000..50b88e8 --- /dev/null +++ b/cmd/ws/ingest/ingest.go @@ -0,0 +1,55 @@ +package ingest + +import ( + "context" + "fmt" + "log" + + "github.com/genudine/saerro-go/store" + "github.com/genudine/saerro-go/types" +) + +type Ingest struct { + PlayerStore store.IPlayerStore +} + +func (i *Ingest) TrackPop(ctx context.Context, event types.PopEvent) { + player := event.ToPlayer() + + err := i.fixupPlayer(ctx, player) + if err != nil { + log.Println("ingest: player fixup failed, dropping event", err) + return + } + + err = i.PlayerStore.Insert(ctx, player) + if err != nil { + log.Println("TrackPop Insert failed", err) + } +} + +func (i *Ingest) fixupPlayer(ctx context.Context, player *types.Player) error { + if player.ClassName != "unknown" && player.FactionID != 0 { + // all fixups are done + return nil + } + + storedPlayer, err := i.PlayerStore.GetOne(ctx, player.CharacterID) + if err != nil { + return fmt.Errorf("ingest: fixupPlayer: fetching player %s failed: %w", player.CharacterID, err) + } + + // probably VehicleDestroy + if player.ClassName == "unknown" { + // TODO: maybe get this from census, profile_id + player.ClassName = storedPlayer.ClassName + } + + // probably PS4 + if player.FactionID == 0 { + // TODO: get this from census + player.FactionID = storedPlayer.FactionID + } + + return nil +} diff --git a/cmd/ws/ingest/ingest_test.go b/cmd/ws/ingest/ingest_test.go new file mode 100644 index 0000000..325b416 --- /dev/null +++ b/cmd/ws/ingest/ingest_test.go @@ -0,0 +1,84 @@ +package ingest_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/genudine/saerro-go/cmd/ws/ingest" + "github.com/genudine/saerro-go/store/storemock" + "github.com/genudine/saerro-go/translators" + "github.com/genudine/saerro-go/types" +) + +func mkIngest(t *testing.T) (context.Context, *ingest.Ingest, *storemock.MockPlayerStore) { + t.Helper() + + ps := new(storemock.MockPlayerStore) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + t.Cleanup(cancel) + + i := &ingest.Ingest{ + PlayerStore: ps, + } + + return ctx, i, ps +} + +func TestTrackPopHappyPath(t *testing.T) { + ctx, i, ps := mkIngest(t) + + // Combat Medic on Emerald + event := types.PopEvent{ + WorldID: 17, + ZoneID: 4, + TeamID: types.TR, + LoadoutID: 4, + ClassName: translators.CombatMedic, + CharacterID: "aaaa", + } + + eventPlayer := event.ToPlayer() + + ps.On("Insert", ctx, eventPlayer).Return(nil).Once() + + i.TrackPop(ctx, event) +} + +func TestTrackPopFixup(t *testing.T) { + ctx, i, ps := mkIngest(t) + + event := types.PopEvent{ + WorldID: 17, + ZoneID: 4, + TeamID: 0, + ClassName: "unknown", + CharacterID: "bbbb", + } + pastEventPlayer := event.ToPlayer() + pastEventPlayer.ClassName = "light_assault" + pastEventPlayer.FactionID = types.VS + + ps.On("GetOne", ctx, event.CharacterID).Return(pastEventPlayer, nil).Once() + ps.On("Insert", ctx, pastEventPlayer).Return(nil).Once() + + i.TrackPop(ctx, event) +} + +func TestTrackPopFixupFailed(t *testing.T) { + ctx, i, ps := mkIngest(t) + + event := types.PopEvent{ + WorldID: 17, + ZoneID: 4, + TeamID: 0, + ClassName: "unknown", + CharacterID: "bbbb", + } + + ps.On("GetOne", ctx, event.CharacterID).Return(nil, errors.New("ingest fixup failed")).Once() + + i.TrackPop(ctx, event) +} diff --git a/cmd/ws/ws.go b/cmd/ws/ws.go index d1f7eb8..23715ff 100644 --- a/cmd/ws/ws.go +++ b/cmd/ws/ws.go @@ -2,14 +2,15 @@ package main import ( "context" - "database/sql" "log" "os" + "os/signal" + "syscall" "time" - "github.com/genudine/saerro-go/types" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" + "github.com/genudine/saerro-go/cmd/ws/eventhandler" + "github.com/genudine/saerro-go/cmd/ws/wsmanager" + "github.com/genudine/saerro-go/util" ) func main() { @@ -18,54 +19,41 @@ func main() { log.Fatalln("WS_ADDR is not set.") } - db, err := sql.Open("sqlite", ":memory:") + db, err := util.GetDBConnection(os.Getenv("DB_ADDR")) if err != nil { - log.Fatalln("database connection failed", err) + log.Fatalln(err) } - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + eventHandler := eventhandler.NewEventHandler(db) + wsm := wsmanager.NewWebsocketManager(eventHandler) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() - wsConn, _, err := websocket.Dial(ctx, wsAddr, nil) + err = wsm.Connect(ctx, wsAddr) if err != nil { - log.Fatalln("Connection to ESS failed.", err) - } - defer wsConn.Close(websocket.StatusInternalError, "internal error. bye") - - err = wsjson.Write(ctx, wsConn, map[string]interface{}{ - "action": "subscribe", - "worlds": "all", - "eventNames": getEventNames(), - "characters": []string{"all"}, - "service": "event", - - "logicalAndCharactersWithWorlds": true, - }) - if err != nil { - log.Fatalln("subscription write failed", err) + log.Fatalln(err) } - log.Println("subscribe done") + go wsm.Start() - eventHandler := EventHandler{ - Ingest: &Ingest{ - DB: db, - }, - } - - for { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - - var event types.ESSData - err := wsjson.Read(ctx, wsConn, &event) + go func() { + time.Sleep(time.Second * 1) + err = wsm.Subscribe(ctx) if err != nil { - log.Println("wsjson read failed", err) - cancel() - continue + wsm.FailClose() + log.Fatalln("subscribe failed", err) } + log.Println("sent subscribe") + }() - go eventHandler.HandleEvent(ctx, event.Payload) + exitSignal := make(chan os.Signal, 1) + signal.Notify(exitSignal, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-exitSignal: + log.Println("got interrupt, exiting...") + case <-wsm.Closed: + log.Println("websocket closed, bailing...") } - - wsConn.Close(websocket.StatusNormalClosure, "") } diff --git a/cmd/ws/event_names.go b/cmd/ws/wsmanager/event_names.go similarity index 96% rename from cmd/ws/event_names.go rename to cmd/ws/wsmanager/event_names.go index bf5e9ca..2ba5fdf 100644 --- a/cmd/ws/event_names.go +++ b/cmd/ws/wsmanager/event_names.go @@ -1,4 +1,4 @@ -package main +package wsmanager import ( "fmt" diff --git a/cmd/ws/event_names_test.go b/cmd/ws/wsmanager/event_names_test.go similarity index 93% rename from cmd/ws/event_names_test.go rename to cmd/ws/wsmanager/event_names_test.go index c8eeb27..980a013 100644 --- a/cmd/ws/event_names_test.go +++ b/cmd/ws/wsmanager/event_names_test.go @@ -1,4 +1,4 @@ -package main +package wsmanager import ( "testing" diff --git a/cmd/ws/wsmanager/wsmanager.go b/cmd/ws/wsmanager/wsmanager.go new file mode 100644 index 0000000..4b064db --- /dev/null +++ b/cmd/ws/wsmanager/wsmanager.go @@ -0,0 +1,124 @@ +package wsmanager + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/coder/websocket" + "github.com/genudine/saerro-go/cmd/ws/eventhandler" + "github.com/genudine/saerro-go/types" +) + +type WebsocketManager struct { + Conn *websocket.Conn + EventHandler eventhandler.EventHandler + Closed chan bool +} + +func NewWebsocketManager(eh eventhandler.EventHandler) WebsocketManager { + return WebsocketManager{ + EventHandler: eh, + Closed: make(chan bool, 1), + } +} + +func (wsm *WebsocketManager) Connect(ctx context.Context, addr string) (err error) { + wsm.Conn, _, err = websocket.Dial(ctx, addr, nil) + if err != nil { + return fmt.Errorf("wsm: connect failed: %w", err) + } + + log.Println("wsm: connected to", addr) + + return +} + +type ESSSubscription struct { + Action string `json:"action,omitempty"` + Worlds []string `json:"worlds,omitempty"` + EventNames []string `json:"eventNames,omitempty"` + Characters []string `json:"characters,omitempty"` + Service string `json:"service,omitempty"` + LogicalAndCharactersWithWorlds bool `json:"logicalAndCharactersWithWorlds,omitempty"` +} + +func (wsm *WebsocketManager) Subscribe(ctx context.Context) error { + sub := ESSSubscription{ + Action: "subscribe", + Service: "event", + Worlds: []string{"all"}, + EventNames: getEventNames(), + Characters: []string{"all"}, + LogicalAndCharactersWithWorlds: true, + } + + var buf bytes.Buffer + err := json.NewEncoder(&buf).Encode(sub) + if err != nil { + return fmt.Errorf("wsm: subscribe: json encode failed: %w", err) + } + + log.Printf("wsm: subscribe message: %s", buf.String()) + + err = wsm.Conn.Write(ctx, websocket.MessageText, buf.Bytes()) + if err != nil { + return fmt.Errorf("wsm: subscribe: ws write failed: %w", err) + } + + return nil +} + +func (wsm *WebsocketManager) Start() { + go wsm.startWatchdog() + + for { + ctx := context.Background() + + var event types.ESSData + + _, data, err := wsm.Conn.Read(ctx) + if err != nil { + log.Fatalln("wsm: read failed:", err) + } + + // log.Printf("raw event: %s", string(data)) + + err = json.Unmarshal(data, &event) + if err != nil { + log.Println("wsm: json unmarshal failed:", err) + log.Println("wsm: json unmarshal failed (payload)", string(data)) + } + + go wsm.EventHandler.HandleEvent(ctx, event.Payload) + } +} + +func (wsm *WebsocketManager) startWatchdog() { + for { + time.Sleep(time.Second * 30) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + + err := wsm.Conn.Ping(ctx) + if err != nil { + log.Println("wsm: watchdog failed") + wsm.Closed <- true + } + + cancel() + } +} + +func (wsm *WebsocketManager) Close() { + wsm.Conn.Close(websocket.StatusNormalClosure, "") + wsm.Closed <- true +} + +func (wsm *WebsocketManager) FailClose() { + wsm.Conn.Close(websocket.StatusAbnormalClosure, "") + wsm.Closed <- true +} diff --git a/docker-compose.yaml b/docker-compose.yaml index bff71b7..83ddf4f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ - services: tsdb: image: docker.io/timescale/timescaledb:latest-pg14 @@ -6,5 +5,6 @@ services: POSTGRES_PASSWORD: saerro321 POSTGRES_USER: saerrouser POSTGRES_DB: data + network_mode: host ports: - 5432:5432 diff --git a/go.mod b/go.mod index 8bdda41..1d6e7ba 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,38 @@ module github.com/genudine/saerro-go go 1.22.3 require ( - github.com/glebarez/go-sqlite v1.22.0 + github.com/avast/retry-go v3.0.0+incompatible + github.com/coder/websocket v1.8.12 + github.com/jackc/pgx/v5 v5.7.1 github.com/stretchr/testify v1.9.0 - nhooyr.io/websocket v1.8.11 + modernc.org/sqlite v1.33.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.19.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.52.1 // indirect + modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect + modernc.org/libc v1.61.0 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.30.1 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 93e7497..e690c76 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,31 @@ +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= -github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -16,29 +34,48 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= -modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo= -modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= +modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M= -modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ= +modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= +modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY= +modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE= +modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= @@ -47,11 +84,9 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk= -modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= -nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/shell.nix b/shell.nix index f02fb90..456a9b5 100644 --- a/shell.nix +++ b/shell.nix @@ -1,4 +1,28 @@ -{ pkgs ? import {} }: pkgs.mkShell { +{ pkgs ? import {} }: let + podmanSetupScript = let + registriesConf = pkgs.writeText "registries.conf" '' + [registries.search] + registries = ['docker.io'] + [registries.block] + registries = [] + ''; + in pkgs.writeScript "podman-setup" '' + #!${pkgs.runtimeShell} + # Dont overwrite customised configuration + if ! test -f ~/.config/containers/policy.json; then + install -Dm555 ${pkgs.skopeo.src}/default-policy.json ~/.config/containers/policy.json + fi + if ! test -f ~/.config/containers/registries.conf; then + install -Dm555 ${registriesConf} ~/.config/containers/registries.conf + fi + ''; + + # Provides a fake "docker" binary mapping to podman + dockerCompat = pkgs.runCommandNoCC "docker-podman-compat" {} '' + mkdir -p $out/bin + ln -s ${pkgs.podman}/bin/podman $out/bin/docker + ''; +in pkgs.mkShell { buildInputs = with pkgs; [ go just diff --git a/store/env_test.go b/store/env_test.go new file mode 100644 index 0000000..37b7b33 --- /dev/null +++ b/store/env_test.go @@ -0,0 +1,23 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/genudine/saerro-go/util/testutil" +) + +func mkEnv(t *testing.T) (context.Context, *sql.DB) { + t.Helper() + + db := testutil.GetTestDB(t) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + t.Cleanup(func() { + cancel() + }) + + return ctx, db +} diff --git a/store/player.go b/store/player.go index 5a6c87d..0045a71 100644 --- a/store/player.go +++ b/store/player.go @@ -5,10 +5,26 @@ import ( "database/sql" "log" "time" + + "github.com/genudine/saerro-go/types" + "github.com/genudine/saerro-go/util" + + "github.com/avast/retry-go" ) +type IPlayerStore interface { + IsMigrated(context.Context) bool + RunMigration(context.Context, bool) + Insert(context.Context, *types.Player) error + GetOne(context.Context, string) (*types.Player, error) + Prune(context.Context) (int64, error) +} + type PlayerStore struct { DB *sql.DB + + // Test introspection for if migrations ran during this PlayerStore init + RanMigration bool } func NewPlayerStore(db *sql.DB) *PlayerStore { @@ -24,10 +40,19 @@ func NewPlayerStore(db *sql.DB) *PlayerStore { return ps } -func (ps *PlayerStore) RunMigration(ctx context.Context, force bool) { - if !force { - // check if migrated first... +func (ps *PlayerStore) IsMigrated(ctx context.Context) bool { + _, err := ps.DB.QueryContext(ctx, `SELECT count(1) FROM players LIMIT 1;`) + if err != nil { + log.Printf("IsMigrated check failed: %v", err) + return false + } + return true +} + +func (ps *PlayerStore) RunMigration(ctx context.Context, force bool) { + if !force && ps.IsMigrated(ctx) { + return } log.Println("(Re)-creating players table") @@ -36,13 +61,101 @@ func (ps *PlayerStore) RunMigration(ctx context.Context, force bool) { CREATE TABLE players ( character_id TEXT NOT NULL PRIMARY KEY, - last_updated TIMESTAMPTZ NOT NULL, + last_updated TIMESTAMP NOT NULL, world_id INT NOT NULL, faction_id INT NOT NULL, zone_id INT NOT NULL, class_name TEXT NOT NULL ); - `) + -- TODO: Add indexes? + `) log.Println("Done, players table is initialized.") + ps.RanMigration = true +} + +// Insert a player into the store. +// For testing, when LastUpdated is not "zero", the provided timestamp will be carried into the store. +func (ps *PlayerStore) Insert(ctx context.Context, player *types.Player) error { + if player.LastUpdated.IsZero() { + player.LastUpdated = time.Now() + } + + err := retry.Do(func() error { + _, err := ps.DB.ExecContext(ctx, + ` + INSERT INTO players ( + last_updated, character_id, world_id, faction_id, zone_id, class_name + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (character_id) DO UPDATE SET + last_updated = EXCLUDED.last_updated, + world_id = EXCLUDED.world_id, + faction_id = EXCLUDED.faction_id, + zone_id = EXCLUDED.zone_id, + class_name = EXCLUDED.class_name; + `, + util.TimeToString(player.LastUpdated), + player.CharacterID, + player.WorldID, + player.FactionID, + player.ZoneID, + player.ClassName, + ) + + return err + }, retry.Attempts(2)) + + return err +} + +// GetOne player from the store. +func (ps *PlayerStore) GetOne(ctx context.Context, id string) (*types.Player, error) { + row := ps.DB.QueryRowContext(ctx, ` + SELECT + last_updated, + character_id, + world_id, + faction_id, + zone_id, + class_name + FROM players WHERE character_id = $1 + `, id) + + player := &types.Player{} + var timestamp string + + err := row.Scan( + ×tamp, + &player.CharacterID, + &player.WorldID, + &player.FactionID, + &player.ZoneID, + &player.ClassName, + ) + if err != nil { + return nil, err + } + + player.LastUpdated, err = time.Parse(time.RFC3339, timestamp) + + return player, err +} + +func (ps *PlayerStore) Prune(ctx context.Context) (int64, error) { + log.Println("pruning PlayerStore") + + // Avoid using sql idioms here for portability + // SQLite and PgSQL do now() differently, we don't need to at all. + res, err := ps.DB.ExecContext(ctx, + ` + DELETE FROM players WHERE last_updated < $1; + `, + util.TimeToString(time.Now().Add(-time.Minute*15)), + ) + if err != nil { + return 0, err + } + + return res.RowsAffected() } diff --git a/store/player_test.go b/store/player_test.go new file mode 100644 index 0000000..d8c16f4 --- /dev/null +++ b/store/player_test.go @@ -0,0 +1,113 @@ +package store_test + +import ( + "testing" + "time" + + "github.com/genudine/saerro-go/store" + "github.com/genudine/saerro-go/types" + + "github.com/stretchr/testify/assert" +) + +func TestPlayerMigrationClean(t *testing.T) { + ctx, db := mkEnv(t) + + ps := store.NewPlayerStore(db) + + isMigrated := ps.IsMigrated(ctx) + assert.True(t, isMigrated) +} + +func TestPlayerMultipleStartups(t *testing.T) { + ctx, db := mkEnv(t) + + ps1 := store.NewPlayerStore(db) + ps2 := store.NewPlayerStore(db) + + isMigrated := ps2.IsMigrated(ctx) + assert.True(t, isMigrated) + + assert.True(t, ps1.RanMigration) + assert.False(t, ps2.RanMigration) +} + +func TestPlayerMigrationRerun(t *testing.T) { + ctx, db := mkEnv(t) + + ps := store.NewPlayerStore(db) + ps.RunMigration(ctx, true) + + isMigrated := ps.IsMigrated(ctx) + assert.True(t, isMigrated) +} + +func TestPlayerInsertGetOne(t *testing.T) { + ctx, db := mkEnv(t) + ps := store.NewPlayerStore(db) + + player := &types.Player{ + CharacterID: "41666", + WorldID: 17, + FactionID: types.TR, + ZoneID: 4, + ClassName: "light_assault", + } + + err := ps.Insert(ctx, player) + assert.NoError(t, err, "Insert failed") + + p1, err := ps.GetOne(ctx, "41666") + assert.NoError(t, err, "GetOne failed") + assert.Equal(t, "light_assault", p1.ClassName) + + time.Sleep(time.Second * 1) + + player.ClassName = "combat_medic" + player.LastUpdated = time.Time{} + err = ps.Insert(ctx, player) + assert.NoError(t, err, "Insert failed") + + p2, err := ps.GetOne(ctx, "41666") + assert.NoError(t, err, "GetOne failed") + assert.Equal(t, "combat_medic", p2.ClassName) + assert.NotEqual(t, p1.LastUpdated, p2.LastUpdated, "time did not update as expected") +} + +func TestPlayerPrune(t *testing.T) { + ctx, db := mkEnv(t) + ps := store.NewPlayerStore(db) + + prunedPlayer := &types.Player{ + CharacterID: "20155", + WorldID: 17, + FactionID: types.NC, + ZoneID: 4, + ClassName: "light_assault", + LastUpdated: time.Now().Add(-time.Minute * 20), + } + survivingPlayer := &types.Player{ + CharacterID: "41666", + WorldID: 17, + FactionID: types.TR, + ZoneID: 4, + ClassName: "light_assault", + LastUpdated: time.Now().Add(-time.Minute * 5), + } + + err := ps.Insert(ctx, prunedPlayer) + assert.NoError(t, err, "Insert prunedPlayer failed") + + err = ps.Insert(ctx, survivingPlayer) + assert.NoError(t, err, "Insert survivingPlayer failed") + + removed, err := ps.Prune(ctx) + assert.NoError(t, err, "Prune failed") + assert.Equal(t, int64(1), removed, "Prune count incorrect") + + _, err = ps.GetOne(ctx, prunedPlayer.CharacterID) + assert.Error(t, err, "GetOne prunedPlayer failed, as expected.") + + _, err = ps.GetOne(ctx, survivingPlayer.CharacterID) + assert.NoError(t, err, "GetOne survivingPlayer failed") +} diff --git a/store/storemock/playerstore.go b/store/storemock/playerstore.go new file mode 100644 index 0000000..4382d2f --- /dev/null +++ b/store/storemock/playerstore.go @@ -0,0 +1,45 @@ +package storemock + +import ( + "context" + "database/sql" + + "github.com/genudine/saerro-go/types" + "github.com/stretchr/testify/mock" +) + +type MockPlayerStore struct { + mock.Mock + + DB *sql.DB + RanMigration bool +} + +func (m *MockPlayerStore) IsMigrated(ctx context.Context) bool { + args := m.Called(ctx) + return args.Bool(0) +} + +func (m *MockPlayerStore) RunMigration(ctx context.Context, force bool) { + m.Called(ctx, force) +} + +func (m *MockPlayerStore) Insert(ctx context.Context, player *types.Player) error { + args := m.Called(ctx, player) + return args.Error(0) +} + +func (m *MockPlayerStore) GetOne(ctx context.Context, id string) (*types.Player, error) { + args := m.Called(ctx, id) + + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*types.Player), args.Error(1) +} + +func (m *MockPlayerStore) Prune(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return int64(args.Int(0)), args.Error(1) +} diff --git a/store/vehicle.go b/store/vehicle.go new file mode 100644 index 0000000..0f26205 --- /dev/null +++ b/store/vehicle.go @@ -0,0 +1,149 @@ +package store + +import ( + "context" + "database/sql" + "log" + "time" + + "github.com/genudine/saerro-go/types" + + "github.com/avast/retry-go" +) + +type VehicleStore struct { + DB *sql.DB + + // Test introspection for if migrations ran during this PlayerStore init + RanMigration bool +} + +func NewVehicleStore(db *sql.DB) *VehicleStore { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + ps := &VehicleStore{ + DB: db, + } + + ps.RunMigration(ctx, false) + + return ps +} + +func (ps *VehicleStore) IsMigrated(ctx context.Context) bool { + _, err := ps.DB.QueryContext(ctx, `SELECT count(1) FROM vehicles LIMIT 1;`) + if err != nil { + log.Printf("IsMigrated check failed: %v", err) + return false + } + + return true +} + +func (ps *VehicleStore) RunMigration(ctx context.Context, force bool) { + if !force && ps.IsMigrated(ctx) { + return + } + + log.Println("(Re)-creating vehicles table") + ps.DB.ExecContext(ctx, ` + DROP TABLE IF EXISTS vehicles; + + CREATE TABLE vehicles ( + character_id TEXT NOT NULL PRIMARY KEY, + last_updated TIMESTAMP NOT NULL, + world_id INT NOT NULL, + faction_id INT NOT NULL, + zone_id INT NOT NULL, + vehicle_name TEXT NOT NULL + ); + + -- TODO: Add indexes? + `) + log.Println("Done, vehicles table is initialized.") + ps.RanMigration = true +} + +// Insert a player into the store. +// For testing, when LastUpdated is not "zero", the provided timestamp will be carried into the store. +func (ps *VehicleStore) Insert(ctx context.Context, vehicle *types.Vehicle) error { + if vehicle.LastUpdated.IsZero() { + vehicle.LastUpdated = time.Now() + } + + err := retry.Do(func() error { + _, err := ps.DB.ExecContext(ctx, + ` + INSERT INTO vehicles ( + last_updated, character_id, world_id, faction_id, zone_id, vehicle_name + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (character_id) DO UPDATE SET + last_updated = EXCLUDED.last_updated, + world_id = EXCLUDED.world_id, + faction_id = EXCLUDED.faction_id, + zone_id = EXCLUDED.zone_id, + vehicle_name = EXCLUDED.vehicle_name + `, + vehicle.LastUpdated, + vehicle.CharacterID, + vehicle.WorldID, + vehicle.FactionID, + vehicle.ZoneID, + vehicle.VehicleName, + ) + + return err + }, retry.Attempts(2)) + + return err +} + +// GetOne player from the store. +func (ps *VehicleStore) GetOne(ctx context.Context, id string) (*types.Vehicle, error) { + row := ps.DB.QueryRowContext(ctx, ` + SELECT + last_updated, + character_id, + world_id, + faction_id, + zone_id, + vehicle_name + FROM vehicles WHERE character_id = $1 + `, id) + + vehicle := &types.Vehicle{} + var timestamp string + + err := row.Scan( + ×tamp, + &vehicle.CharacterID, + &vehicle.WorldID, + &vehicle.FactionID, + &vehicle.ZoneID, + &vehicle.VehicleName, + ) + if err != nil { + return nil, err + } + + vehicle.LastUpdated, err = time.Parse(time.RFC3339, timestamp) + + return vehicle, err +} + +func (ps *VehicleStore) Prune(ctx context.Context) (int64, error) { + log.Println("pruning VehicleStore") + + // Avoid using sql idioms here for portability + // SQLite and PgSQL do now() differently, we don't need to at all. + res, err := ps.DB.ExecContext(ctx, ` + DELETE FROM vehicles WHERE last_updated < $1; + `, time.Now().Add(-time.Minute*15)) + if err != nil { + return 0, err + } + + return res.RowsAffected() +} diff --git a/store/vehicle_test.go b/store/vehicle_test.go new file mode 100644 index 0000000..2543c91 --- /dev/null +++ b/store/vehicle_test.go @@ -0,0 +1,114 @@ +package store_test + +import ( + "testing" + "time" + + "github.com/genudine/saerro-go/store" + "github.com/genudine/saerro-go/types" + + "github.com/stretchr/testify/assert" +) + +func TestVehicleMigrationClean(t *testing.T) { + ctx, db := mkEnv(t) + + ps := store.NewVehicleStore(db) + + isMigrated := ps.IsMigrated(ctx) + assert.True(t, isMigrated) +} + +func TestVehicleMultipleStartups(t *testing.T) { + ctx, db := mkEnv(t) + + ps1 := store.NewVehicleStore(db) + ps2 := store.NewVehicleStore(db) + + isMigrated := ps2.IsMigrated(ctx) + assert.True(t, isMigrated) + + assert.True(t, ps1.RanMigration) + assert.False(t, ps2.RanMigration) +} + +func TestVehicleMigrationRerun(t *testing.T) { + ctx, db := mkEnv(t) + + ps := store.NewVehicleStore(db) + ps.RunMigration(ctx, true) + + isMigrated := ps.IsMigrated(ctx) + assert.True(t, isMigrated) +} + +func TestVehicleInsertGetOne(t *testing.T) { + ctx, db := mkEnv(t) + ps := store.NewVehicleStore(db) + + player := &types.Vehicle{ + CharacterID: "41666", + WorldID: 17, + FactionID: types.TR, + ZoneID: 4, + VehicleName: "harasser", + } + + err := ps.Insert(ctx, player) + assert.NoError(t, err, "Insert failed") + + p1, err := ps.GetOne(ctx, "41666") + assert.NoError(t, err, "GetOne failed") + assert.Equal(t, "harasser", p1.VehicleName) + + time.Sleep(time.Second * 1) + + player.VehicleName = "vanguard" + player.LastUpdated = time.Time{} + err = ps.Insert(ctx, player) + assert.NoError(t, err, "Insert failed") + + p2, err := ps.GetOne(ctx, "41666") + assert.NoError(t, err, "GetOne failed") + assert.Equal(t, "vanguard", p2.VehicleName) + assert.NotEqual(t, p1.LastUpdated, p2.LastUpdated, "time did not update as expected") +} + +func TestVehiclePrune(t *testing.T) { + ctx, db := mkEnv(t) + ps := store.NewVehicleStore(db) + + prunedPlayer := &types.Vehicle{ + CharacterID: "20155", + WorldID: 17, + FactionID: types.NC, + ZoneID: 4, + VehicleName: "harasser", + + LastUpdated: time.Now().Add(-time.Minute * 20), + } + survivingPlayer := &types.Vehicle{ + CharacterID: "41666", + WorldID: 17, + FactionID: types.TR, + ZoneID: 4, + VehicleName: "harasser", + LastUpdated: time.Now().Add(-time.Minute * 5), + } + + err := ps.Insert(ctx, prunedPlayer) + assert.NoError(t, err, "Insert prunedPlayer failed") + + err = ps.Insert(ctx, survivingPlayer) + assert.NoError(t, err, "Insert survivingPlayer failed") + + removed, err := ps.Prune(ctx) + assert.NoError(t, err, "Prune failed") + assert.Equal(t, int64(1), removed, "Prune count incorrect") + + _, err = ps.GetOne(ctx, prunedPlayer.CharacterID) + assert.Error(t, err, "GetOne prunedPlayer failed, as expected.") + + _, err = ps.GetOne(ctx, survivingPlayer.CharacterID) + assert.NoError(t, err, "GetOne survivingPlayer failed") +} diff --git a/translators/loadouts.go b/translators/loadouts.go index c0f5990..5fdf9ad 100644 --- a/translators/loadouts.go +++ b/translators/loadouts.go @@ -12,40 +12,40 @@ const ( ) var ( - LoadoutMap = map[string]Class{ - "1": Infiltrator, - "8": Infiltrator, - "15": Infiltrator, - "28": Infiltrator, + LoadoutMap = map[uint16]Class{ + 1: Infiltrator, + 8: Infiltrator, + 15: Infiltrator, + 28: Infiltrator, - "3": LightAssault, - "10": LightAssault, - "17": LightAssault, - "29": LightAssault, + 3: LightAssault, + 10: LightAssault, + 17: LightAssault, + 29: LightAssault, - "4": CombatMedic, - "11": CombatMedic, - "18": CombatMedic, - "30": CombatMedic, + 4: CombatMedic, + 11: CombatMedic, + 18: CombatMedic, + 30: CombatMedic, - "5": Engineer, - "12": Engineer, - "19": Engineer, - "31": Engineer, + 5: Engineer, + 12: Engineer, + 19: Engineer, + 31: Engineer, - "6": HeavyAssault, - "13": HeavyAssault, - "20": HeavyAssault, - "32": HeavyAssault, + 6: HeavyAssault, + 13: HeavyAssault, + 20: HeavyAssault, + 32: HeavyAssault, - "7": MAX, - "14": MAX, - "21": MAX, - "45": MAX, + 7: MAX, + 14: MAX, + 21: MAX, + 45: MAX, } ) -func ClassFromLoadout(loadoutID string) Class { +func ClassFromLoadout(loadoutID uint16) Class { c, ok := LoadoutMap[loadoutID] if !ok { diff --git a/translators/loadouts_test.go b/translators/loadouts_test.go index 489a8e3..83986fe 100644 --- a/translators/loadouts_test.go +++ b/translators/loadouts_test.go @@ -3,11 +3,11 @@ package translators_test import ( "testing" - "github.com/genudine/saerro-go/pkg/translators" + "github.com/genudine/saerro-go/translators" "github.com/stretchr/testify/assert" ) func TestLoadouts(t *testing.T) { - assert.Equal(t, translators.ClassFromLoadout("1"), translators.Infiltrator) - assert.Equal(t, translators.ClassFromLoadout("0"), translators.Class("unknown")) + assert.Equal(t, translators.ClassFromLoadout(1), translators.Infiltrator) + assert.Equal(t, translators.ClassFromLoadout(0), translators.Class("unknown")) } diff --git a/translators/vehicles_test.go b/translators/vehicles_test.go index 8696921..a016c34 100644 --- a/translators/vehicles_test.go +++ b/translators/vehicles_test.go @@ -3,7 +3,7 @@ package translators_test import ( "testing" - "github.com/genudine/saerro-go/pkg/translators" + "github.com/genudine/saerro-go/translators" "github.com/stretchr/testify/assert" ) diff --git a/types/census.go b/types/census.go index f3a1228..78a23dc 100644 --- a/types/census.go +++ b/types/census.go @@ -8,3 +8,18 @@ const ( TR NSO ) + +// func (f Faction) UnmarshalJSON(b []byte) error { +// switch b[0] { +// case '1': +// f = VS +// case '2': +// f = NC +// case '3': +// f = TR +// case '4': +// f = NSO +// } + +// return nil +// } diff --git a/types/internal.go b/types/internal.go new file mode 100644 index 0000000..258cf08 --- /dev/null +++ b/types/internal.go @@ -0,0 +1,27 @@ +package types + +import "time" + +type Player struct { + CharacterID string `json:"character_id"` + LastUpdated time.Time `json:"last_updated"` + WorldID uint16 `json:"world_id"` + FactionID Faction `json:"faction_id"` + ZoneID uint32 `json:"zone_id"` + ClassName string `json:"class_name"` +} + +type Vehicle struct { + CharacterID string `json:"character_id"` + LastUpdated time.Time `json:"last_updated"` + WorldID uint16 `json:"world_id"` + FactionID Faction `json:"faction_id"` + ZoneID uint32 `json:"zone_id"` + VehicleName string `json:"vehicle_name"` +} + +type AnalyticEvent struct { + Time time.Time `json:"time"` + WorldID uint16 `json:"world_id"` + EventName string `json:"event_name"` +} diff --git a/types/payload.go b/types/payload.go index 5c5cdc5..9bb96a0 100644 --- a/types/payload.go +++ b/types/payload.go @@ -6,18 +6,23 @@ type ESSData struct { type ESSEvent struct { EventName string `json:"event_name"` - WorldID uint16 `json:"world_id"` - ZoneID uint32 `json:"zone_id"` + WorldID uint16 `json:"world_id,string"` + ZoneID uint32 `json:"zone_id,string"` - CharacterID string `json:"character_id"` - LoadoutID string `json:"loadout_id"` - VehicleID string `json:"vehicle_id"` - TeamID Faction `json:"team_id"` + CharacterID string `json:"character_id"` + // On Death + + VehicleID string `json:"vehicle_id"` + TeamID Faction `json:"team_id,string"` + CharacterLoadoutID uint16 `json:"character_loadout_id,string"` AttackerCharacterID string `json:"attacker_character_id"` - AttackerLoadoutID string `json:"attacker_loadout_id"` + AttackerLoadoutID uint16 `json:"attacker_loadout_id,string"` AttackerVehicleID string `json:"attacker_vehicle_id"` - AttackerTeamID Faction `json:"attacker_team_id"` + AttackerTeamID Faction `json:"attacker_team_id,string"` - ExperienceID uint32 + // On GainExperience + + ExperienceID uint32 `json:"experience_id,string"` + LoadoutID uint16 `json:"loadout_id,string"` } diff --git a/cmd/ws/pop_event.go b/types/pop_event.go similarity index 64% rename from cmd/ws/pop_event.go rename to types/pop_event.go index 3af50b5..6e78f7f 100644 --- a/cmd/ws/pop_event.go +++ b/types/pop_event.go @@ -1,23 +1,23 @@ -package main +// TODO: tests +package types import ( "github.com/genudine/saerro-go/translators" - "github.com/genudine/saerro-go/types" ) type PopEvent struct { WorldID uint16 ZoneID uint32 CharacterID string - LoadoutID string - TeamID types.Faction + LoadoutID uint16 + TeamID Faction VehicleID string VehicleName translators.Vehicle ClassName translators.Class } -func PopEventFromESSEvent(event types.ESSEvent, attacker bool) PopEvent { +func PopEventFromESSEvent(event ESSEvent, attacker bool) PopEvent { pe := PopEvent{ WorldID: event.WorldID, ZoneID: event.ZoneID, @@ -25,7 +25,7 @@ func PopEventFromESSEvent(event types.ESSEvent, attacker bool) PopEvent { if !attacker { pe.CharacterID = event.CharacterID - pe.LoadoutID = event.LoadoutID + pe.LoadoutID = event.CharacterLoadoutID pe.TeamID = event.TeamID pe.VehicleID = event.VehicleID } else { @@ -35,8 +35,22 @@ func PopEventFromESSEvent(event types.ESSEvent, attacker bool) PopEvent { pe.VehicleID = event.AttackerVehicleID } + if pe.LoadoutID == 0 { + pe.LoadoutID = event.LoadoutID + } + pe.ClassName = translators.ClassFromLoadout(pe.LoadoutID) pe.VehicleName = translators.VehicleNameFromID(pe.VehicleID) return pe } + +func (pe PopEvent) ToPlayer() *Player { + return &Player{ + CharacterID: pe.CharacterID, + ClassName: string(pe.ClassName), + FactionID: pe.TeamID, + ZoneID: pe.ZoneID, + WorldID: pe.WorldID, + } +} diff --git a/types/pop_event_test.go b/types/pop_event_test.go new file mode 100644 index 0000000..f09d368 --- /dev/null +++ b/types/pop_event_test.go @@ -0,0 +1,72 @@ +package types_test + +import ( + "testing" + + "github.com/genudine/saerro-go/types" + "github.com/stretchr/testify/assert" +) + +var ( + deathEvent = types.ESSEvent{ + EventName: "Death", + WorldID: 17, + ZoneID: 4, + + CharacterID: "LyytisDoll", + TeamID: types.NC, + CharacterLoadoutID: 4, + + AttackerCharacterID: "Lyyti", + AttackerTeamID: types.TR, + AttackerLoadoutID: 3, + } + + expEvent = types.ESSEvent{ + EventName: "GainExperience", + WorldID: 17, + ZoneID: 4, + + CharacterID: "LyytisDoll", + TeamID: types.NC, + LoadoutID: 3, + + ExperienceID: 55, + } +) + +func TestPopEventFromDeath(t *testing.T) { + victim := types.PopEventFromESSEvent(deathEvent, false) + assert.Equal(t, deathEvent.CharacterID, victim.CharacterID) + assert.Equal(t, deathEvent.TeamID, victim.TeamID) + assert.Equal(t, deathEvent.CharacterLoadoutID, victim.LoadoutID) + assert.Equal(t, "combat_medic", string(victim.ClassName)) + + attacker := types.PopEventFromESSEvent(deathEvent, true) + assert.Equal(t, deathEvent.AttackerCharacterID, attacker.CharacterID) + assert.Equal(t, deathEvent.AttackerTeamID, attacker.TeamID) + assert.Equal(t, deathEvent.AttackerLoadoutID, attacker.LoadoutID) + assert.Equal(t, "light_assault", string(attacker.ClassName)) +} + +func TestPopEventFromExperienceGain(t *testing.T) { + pe := types.PopEventFromESSEvent(expEvent, false) + assert.Equal(t, expEvent.CharacterID, pe.CharacterID) + assert.Equal(t, expEvent.TeamID, pe.TeamID) + assert.Equal(t, expEvent.LoadoutID, pe.LoadoutID) + assert.Equal(t, "light_assault", string(pe.ClassName)) +} + +func TestPopEventFromVehicleDestroy(t *testing.T) { + t.SkipNow() +} + +func TestPopEventToPlayer(t *testing.T) { + pe := types.PopEventFromESSEvent(deathEvent, false) + player := pe.ToPlayer() + assert.Equal(t, pe.CharacterID, player.CharacterID) + assert.Equal(t, pe.TeamID, player.FactionID) + assert.Equal(t, pe.ZoneID, player.ZoneID) + assert.Equal(t, string(pe.ClassName), player.ClassName) + assert.Equal(t, pe.WorldID, player.WorldID) +} diff --git a/util/db_connector.go b/util/db_connector.go new file mode 100644 index 0000000..231afb3 --- /dev/null +++ b/util/db_connector.go @@ -0,0 +1,22 @@ +package util + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func GetDBConnection(addr string) (*sql.DB, error) { + db, err := sql.Open("pgx", addr) + if err != nil { + return nil, fmt.Errorf("db failed to open, %w", err) + } + + err = db.Ping() + if err != nil { + return nil, fmt.Errorf("db failed to ping, %w", err) + } + + return db, nil +} diff --git a/util/testutil/db.go b/util/testutil/db.go new file mode 100644 index 0000000..a2542b3 --- /dev/null +++ b/util/testutil/db.go @@ -0,0 +1,20 @@ +package testutil + +import ( + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +// GetTestDB standardizes what type of DB tests use. +func GetTestDB(t *testing.T) *sql.DB { + t.Helper() + + db, err := sql.Open("sqlite", t.TempDir()+"/test.db") + if err != nil { + t.Fatalf("test shim: sqlite open failed, %v", err) + } + + return db +} diff --git a/util/testutil/ws.go b/util/testutil/ws.go new file mode 100644 index 0000000..bba8575 --- /dev/null +++ b/util/testutil/ws.go @@ -0,0 +1,50 @@ +package testutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" +) + +type MockESS struct { + Server *httptest.Server + LastMessage string +} + +func (m MockESS) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + c, err := websocket.Accept(rw, req, nil) + if err != nil { + rw.WriteHeader(400) + rw.Write([]byte("websocket connection failed")) + return + } + defer c.CloseNow() + + ctx, cancel := context.WithTimeout(req.Context(), time.Second*30) + defer cancel() + + _, body, err := c.Read(ctx) + if err != nil { + rw.WriteHeader(500) + rw.Write([]byte("websocket read failed")) + return + } + + m.LastMessage = string(body) +} + +func GetMockESS(t *testing.T) MockESS { + t.Helper() + + m := MockESS{} + + s := httptest.NewServer(m) + m.Server = s + + t.Cleanup(s.Close) + return m +} diff --git a/util/timestrings.go b/util/timestrings.go new file mode 100644 index 0000000..804527d --- /dev/null +++ b/util/timestrings.go @@ -0,0 +1,8 @@ +package util + +import "time" + +// Makes times compatible with old Saerro API +func TimeToString(t time.Time) string { + return t.UTC().Format(time.RFC3339) +}