add more tests
This commit is contained in:
parent
74add408e6
commit
4a528fe85a
9 changed files with 266 additions and 55 deletions
|
@ -17,17 +17,22 @@ func main() {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playerStore := store.NewPlayerStore(db)
|
||||||
|
vehicleStore := store.NewVehicleStore(db)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
run(ctx, playerStore, vehicleStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, ps store.IPlayerStore, vs store.IVehicleStore) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
i, err := ps.Prune(ctx)
|
||||||
playerStore := store.NewPlayerStore(db)
|
|
||||||
i, err := playerStore.Prune(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("pruner: playerStore.Prune failed")
|
log.Println("pruner: playerStore.Prune failed")
|
||||||
}
|
}
|
||||||
|
@ -38,9 +43,7 @@ func main() {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
i, err := vs.Prune(ctx)
|
||||||
vehicleStore := store.NewVehicleStore(db)
|
|
||||||
i, err := vehicleStore.Prune(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("pruner: vehicleStore.Prune failed")
|
log.Println("pruner: vehicleStore.Prune failed")
|
||||||
}
|
}
|
||||||
|
|
22
cmd/pruner/pruner_test.go
Normal file
22
cmd/pruner/pruner_test.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/genudine/saerro-go/store/storemock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
ps := new(storemock.MockPlayerStore)
|
||||||
|
vs := new(storemock.MockVehicleStore)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ps.On("Prune", ctx).Return(30, nil)
|
||||||
|
vs.On("Prune", ctx).Return(30, nil)
|
||||||
|
|
||||||
|
run(ctx, ps, vs)
|
||||||
|
}
|
128
cmd/ws/eventhandler/ess_bench_test.go
Normal file
128
cmd/ws/eventhandler/ess_bench_test.go
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
package eventhandler_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/genudine/saerro-go/cmd/ws/eventhandler"
|
||||||
|
"github.com/genudine/saerro-go/translators"
|
||||||
|
"github.com/genudine/saerro-go/types"
|
||||||
|
"github.com/genudine/saerro-go/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PreloadedCharacterCount = 45
|
||||||
|
|
||||||
|
var (
|
||||||
|
characterStore = make([]string, PreloadedCharacterCount)
|
||||||
|
db *sql.DB
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkESS(b *testing.B) {
|
||||||
|
events, eh := prebench(b)
|
||||||
|
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
event := events[i]
|
||||||
|
|
||||||
|
eh.HandleEvent(context.Background(), event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prebench(b *testing.B) ([]types.ESSEvent, eventhandler.EventHandler) {
|
||||||
|
b.Helper()
|
||||||
|
b.StopTimer()
|
||||||
|
|
||||||
|
// Create our character pool
|
||||||
|
for i := 0; i < PreloadedCharacterCount; i++ {
|
||||||
|
characterStore[i] = mkRandomCharacterID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create events
|
||||||
|
events := []types.ESSEvent{}
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
events = append(events, mkRandomEvent())
|
||||||
|
}
|
||||||
|
|
||||||
|
if db == nil {
|
||||||
|
var err error
|
||||||
|
db, err = util.GetDBConnection(os.Getenv("DB_ADDR"))
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eh := eventhandler.NewEventHandler(db)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.StartTimer()
|
||||||
|
|
||||||
|
return events, eh
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomEvent() types.ESSEvent {
|
||||||
|
w := rand.Intn(4)
|
||||||
|
z := rand.Intn(7)
|
||||||
|
|
||||||
|
switch rand.Intn(2) {
|
||||||
|
case 0:
|
||||||
|
return mkRandomDeathEvent(w, z)
|
||||||
|
default:
|
||||||
|
return mkRandomExpEvent(w, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomDeathEvent(world, zone int) types.ESSEvent {
|
||||||
|
return types.ESSEvent{
|
||||||
|
EventName: "Death",
|
||||||
|
WorldID: uint16(world),
|
||||||
|
ZoneID: uint32(zone),
|
||||||
|
|
||||||
|
CharacterID: getRandomCharacterID(),
|
||||||
|
CharacterLoadoutID: mkRandomLoadout(),
|
||||||
|
TeamID: mkRandomFaction(),
|
||||||
|
|
||||||
|
AttackerCharacterID: getRandomCharacterID(),
|
||||||
|
AttackerLoadoutID: mkRandomLoadout(),
|
||||||
|
AttackerTeamID: mkRandomFaction(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomExpEvent(world, zone int) types.ESSEvent {
|
||||||
|
return types.ESSEvent{
|
||||||
|
EventName: "GainExperience",
|
||||||
|
WorldID: uint16(world),
|
||||||
|
ZoneID: uint32(zone),
|
||||||
|
|
||||||
|
CharacterID: getRandomCharacterID(),
|
||||||
|
LoadoutID: mkRandomLoadout(),
|
||||||
|
TeamID: mkRandomFaction(),
|
||||||
|
|
||||||
|
ExperienceID: rand.Uint32() % 256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRandomCharacterID() string {
|
||||||
|
i := rand.Intn(PreloadedCharacterCount)
|
||||||
|
return characterStore[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomCharacterID() string {
|
||||||
|
return strconv.Itoa(rand.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomFaction() types.Faction {
|
||||||
|
return types.Faction(rand.Intn(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRandomLoadout() uint16 {
|
||||||
|
for {
|
||||||
|
i := rand.Intn(46)
|
||||||
|
_, ok := translators.LoadoutMap[uint16(i)]
|
||||||
|
if ok {
|
||||||
|
return uint16(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,8 +9,15 @@ import (
|
||||||
"github.com/genudine/saerro-go/types"
|
"github.com/genudine/saerro-go/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type IEventHandler interface {
|
||||||
|
HandleEvent(ctx context.Context, event types.ESSEvent)
|
||||||
|
HandleDeath(ctx context.Context, event types.ESSEvent)
|
||||||
|
HandleExperience(ctx context.Context, event types.ESSEvent)
|
||||||
|
HandleAnalytics(ctx context.Context, event types.ESSEvent)
|
||||||
|
}
|
||||||
|
|
||||||
type EventHandler struct {
|
type EventHandler struct {
|
||||||
Ingest *ingest.Ingest
|
Ingest ingest.IIngest
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEventHandler(db *sql.DB) EventHandler {
|
func NewEventHandler(db *sql.DB) EventHandler {
|
||||||
|
|
|
@ -5,32 +5,28 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/avast/retry-go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/genudine/saerro-go/cmd/ws/ingest"
|
"github.com/genudine/saerro-go/cmd/ws/ingest"
|
||||||
"github.com/genudine/saerro-go/store"
|
"github.com/genudine/saerro-go/store/storemock"
|
||||||
"github.com/genudine/saerro-go/translators"
|
|
||||||
"github.com/genudine/saerro-go/types"
|
"github.com/genudine/saerro-go/types"
|
||||||
"github.com/genudine/saerro-go/util/testutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getEventHandlerTestShim(t *testing.T) (EventHandler, context.Context) {
|
func getEventHandlerTestShim(t *testing.T) (EventHandler, context.Context, *storemock.MockPlayerStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db := testutil.GetTestDB(t)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
ps := new(storemock.MockPlayerStore)
|
||||||
|
|
||||||
return EventHandler{
|
return EventHandler{
|
||||||
Ingest: &ingest.Ingest{
|
Ingest: &ingest.Ingest{
|
||||||
PlayerStore: store.NewPlayerStore(db),
|
PlayerStore: ps,
|
||||||
},
|
},
|
||||||
}, ctx
|
}, ctx, ps
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleDeath(t *testing.T) {
|
func TestHandleDeath(t *testing.T) {
|
||||||
eh, ctx := getEventHandlerTestShim(t)
|
eh, ctx, ps := getEventHandlerTestShim(t)
|
||||||
|
|
||||||
event := types.ESSEvent{
|
event := types.ESSEvent{
|
||||||
EventName: "Death",
|
EventName: "Death",
|
||||||
|
@ -46,21 +42,17 @@ func TestHandleDeath(t *testing.T) {
|
||||||
AttackerTeamID: types.TR,
|
AttackerTeamID: types.TR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p1 := types.PopEventFromESSEvent(event, false).ToPlayer()
|
||||||
|
p2 := types.PopEventFromESSEvent(event, true).ToPlayer()
|
||||||
|
|
||||||
|
ps.On("Insert", ctx, p1).Return(nil)
|
||||||
|
ps.On("Insert", ctx, p2).Return(nil)
|
||||||
|
|
||||||
eh.HandleDeath(ctx, event)
|
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) {
|
func TestHandleExperience(t *testing.T) {
|
||||||
eh, ctx := getEventHandlerTestShim(t)
|
eh, ctx, ps := getEventHandlerTestShim(t)
|
||||||
|
|
||||||
event := types.ESSEvent{
|
event := types.ESSEvent{
|
||||||
EventName: "GainExperience",
|
EventName: "GainExperience",
|
||||||
|
@ -74,15 +66,14 @@ func TestHandleExperience(t *testing.T) {
|
||||||
ExperienceID: 674,
|
ExperienceID: 674,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p := types.PopEventFromESSEvent(event, false).ToPlayer()
|
||||||
|
ps.On("Insert", ctx, p).Return(nil)
|
||||||
|
|
||||||
eh.HandleExperience(ctx, event)
|
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) {
|
func TestHandleAnalytics(t *testing.T) {
|
||||||
eh, ctx := getEventHandlerTestShim(t)
|
eh, ctx, _ := getEventHandlerTestShim(t)
|
||||||
event := types.ESSEvent{
|
event := types.ESSEvent{
|
||||||
EventName: "GainExperience",
|
EventName: "GainExperience",
|
||||||
WorldID: 17,
|
WorldID: 17,
|
||||||
|
@ -99,7 +90,7 @@ func TestHandleAnalytics(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleEvent(t *testing.T) {
|
func TestHandleEvent(t *testing.T) {
|
||||||
eh, ctx := getEventHandlerTestShim(t)
|
eh, ctx, ps := getEventHandlerTestShim(t)
|
||||||
|
|
||||||
events := []types.ESSEvent{
|
events := []types.ESSEvent{
|
||||||
{
|
{
|
||||||
|
@ -108,7 +99,7 @@ func TestHandleEvent(t *testing.T) {
|
||||||
ZoneID: 2,
|
ZoneID: 2,
|
||||||
|
|
||||||
CharacterID: "LyytisDoll",
|
CharacterID: "LyytisDoll",
|
||||||
LoadoutID: 3,
|
CharacterLoadoutID: 3,
|
||||||
TeamID: types.NC,
|
TeamID: types.NC,
|
||||||
|
|
||||||
AttackerCharacterID: "Lyyti",
|
AttackerCharacterID: "Lyyti",
|
||||||
|
@ -126,19 +117,19 @@ func TestHandleEvent(t *testing.T) {
|
||||||
|
|
||||||
ExperienceID: 201,
|
ExperienceID: 201,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
EventName: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p1 := types.PopEventFromESSEvent(events[0], false).ToPlayer()
|
||||||
|
ps.On("Insert", ctx, p1).Return(nil).Once()
|
||||||
|
p2 := types.PopEventFromESSEvent(events[0], true).ToPlayer()
|
||||||
|
ps.On("Insert", ctx, p2).Return(nil).Once()
|
||||||
|
p3 := types.PopEventFromESSEvent(events[1], false).ToPlayer()
|
||||||
|
ps.On("Insert", ctx, p3).Return(nil).Once()
|
||||||
|
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
eh.HandleEvent(ctx, event)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,13 @@ import (
|
||||||
"github.com/genudine/saerro-go/types"
|
"github.com/genudine/saerro-go/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type IIngest interface {
|
||||||
|
TrackPop(context.Context, types.PopEvent)
|
||||||
|
}
|
||||||
|
|
||||||
type Ingest struct {
|
type Ingest struct {
|
||||||
PlayerStore store.IPlayerStore
|
PlayerStore store.IPlayerStore
|
||||||
|
VehicleStore store.IVehicleStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Ingest) TrackPop(ctx context.Context, event types.PopEvent) {
|
func (i *Ingest) TrackPop(ctx context.Context, event types.PopEvent) {
|
||||||
|
|
|
@ -12,23 +12,25 @@ import (
|
||||||
"github.com/genudine/saerro-go/types"
|
"github.com/genudine/saerro-go/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mkIngest(t *testing.T) (context.Context, *ingest.Ingest, *storemock.MockPlayerStore) {
|
func mkIngest(t *testing.T) (context.Context, *ingest.Ingest, *storemock.MockPlayerStore, *storemock.MockVehicleStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ps := new(storemock.MockPlayerStore)
|
ps := new(storemock.MockPlayerStore)
|
||||||
|
vs := new(storemock.MockVehicleStore)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
i := &ingest.Ingest{
|
i := &ingest.Ingest{
|
||||||
PlayerStore: ps,
|
PlayerStore: ps,
|
||||||
|
VehicleStore: vs,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx, i, ps
|
return ctx, i, ps, vs
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackPopHappyPath(t *testing.T) {
|
func TestTrackPopHappyPath(t *testing.T) {
|
||||||
ctx, i, ps := mkIngest(t)
|
ctx, i, ps, _ := mkIngest(t)
|
||||||
|
|
||||||
// Combat Medic on Emerald
|
// Combat Medic on Emerald
|
||||||
event := types.PopEvent{
|
event := types.PopEvent{
|
||||||
|
@ -48,7 +50,7 @@ func TestTrackPopHappyPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackPopFixup(t *testing.T) {
|
func TestTrackPopFixup(t *testing.T) {
|
||||||
ctx, i, ps := mkIngest(t)
|
ctx, i, ps, _ := mkIngest(t)
|
||||||
|
|
||||||
event := types.PopEvent{
|
event := types.PopEvent{
|
||||||
WorldID: 17,
|
WorldID: 17,
|
||||||
|
@ -68,7 +70,7 @@ func TestTrackPopFixup(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackPopFixupFailed(t *testing.T) {
|
func TestTrackPopFixupFailed(t *testing.T) {
|
||||||
ctx, i, ps := mkIngest(t)
|
ctx, i, ps, _ := mkIngest(t)
|
||||||
|
|
||||||
event := types.PopEvent{
|
event := types.PopEvent{
|
||||||
WorldID: 17,
|
WorldID: 17,
|
||||||
|
|
45
store/storemock/vehiclestore.go
Normal file
45
store/storemock/vehiclestore.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package storemock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/genudine/saerro-go/types"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockVehicleStore struct {
|
||||||
|
mock.Mock
|
||||||
|
|
||||||
|
DB *sql.DB
|
||||||
|
RanMigration bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVehicleStore) IsMigrated(ctx context.Context) bool {
|
||||||
|
args := m.Called(ctx)
|
||||||
|
return args.Bool(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVehicleStore) RunMigration(ctx context.Context, force bool) {
|
||||||
|
m.Called(ctx, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVehicleStore) Insert(ctx context.Context, vehicle *types.Vehicle) error {
|
||||||
|
args := m.Called(ctx, vehicle)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVehicleStore) GetOne(ctx context.Context, id string) (*types.Vehicle, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.Get(0).(*types.Vehicle), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVehicleStore) Prune(ctx context.Context) (int64, error) {
|
||||||
|
args := m.Called(ctx)
|
||||||
|
return int64(args.Int(0)), args.Error(1)
|
||||||
|
}
|
|
@ -11,6 +11,14 @@ import (
|
||||||
"github.com/avast/retry-go"
|
"github.com/avast/retry-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type IVehicleStore interface {
|
||||||
|
IsMigrated(context.Context) bool
|
||||||
|
RunMigration(context.Context, bool)
|
||||||
|
Insert(context.Context, *types.Vehicle) error
|
||||||
|
GetOne(context.Context, string) (*types.Vehicle, error)
|
||||||
|
Prune(context.Context) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
type VehicleStore struct {
|
type VehicleStore struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue