mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-19 05:47:02 +00:00
Add graphify, tests and supabase migrations & config
This commit is contained in:
@@ -10,14 +10,14 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
// Service composes validation on top of Store.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
pg Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
func NewService(pg Store) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package clans
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
clans map[string]Clan
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{clans: make(map[string]Clan)}
|
||||
}
|
||||
|
||||
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Create(ctx context.Context, c Clan) error {
|
||||
for _, existing := range m.clans {
|
||||
if existing.Tag == c.Tag {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.clans[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(ctx context.Context, id string) (Clan, error) {
|
||||
c, ok := m.clans[id]
|
||||
if !ok {
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetByTag(ctx context.Context, tag string) (Clan, error) {
|
||||
for _, c := range m.clans {
|
||||
if c.Tag == tag {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return Clan{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memoryStore) List(ctx context.Context, limit, offset int) ([]Clan, error) {
|
||||
out := make([]Clan, 0)
|
||||
for _, c := range m.clans {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(ctx context.Context, id string) error {
|
||||
if _, ok := m.clans[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.clans, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Update(ctx context.Context, c Clan) error {
|
||||
if _, ok := m.clans[c.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.clans[c.ID] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Count(ctx context.Context) (int, error) {
|
||||
return len(m.clans), nil
|
||||
}
|
||||
|
||||
func TestClansService(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
svc := NewService(store)
|
||||
svc.now = func() time.Time {
|
||||
return time.Unix(1700000000, 0)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Create a clan with valid inputs
|
||||
c1, err := svc.Create(ctx, CreateInput{
|
||||
ID: "clan-1",
|
||||
Tag: "SPD",
|
||||
Name: "Speed Demons",
|
||||
AvatarURL: "https://example.com/spd.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if c1.ID != "clan-1" || c1.Tag != "SPD" || c1.Name != "Speed Demons" {
|
||||
t.Errorf("Unexpected created clan: %+v", c1)
|
||||
}
|
||||
|
||||
// 2. Validate Tag must be 3 uppercase ASCII letters
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "SPEED", Name: "toolongtag"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'SPEED', got: %v", err)
|
||||
}
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "S1D", Name: "withnumbertag"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'S1D', got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Validate empty Name is rejected
|
||||
_, err = svc.Create(ctx, CreateInput{Tag: "AAA", Name: ""})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
|
||||
}
|
||||
|
||||
// 4. Duplicate tag check
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "clan-2",
|
||||
Tag: "SPD",
|
||||
Name: "Duplicate SPD Tag",
|
||||
})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate tag, got: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get clan
|
||||
got, err := svc.Get(ctx, "clan-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if got.Tag != "SPD" {
|
||||
t.Errorf("Expected SPD, got %s", got.Tag)
|
||||
}
|
||||
|
||||
// 6. Get by tag
|
||||
gotByTag, err := svc.GetByTag(ctx, "spd") // should normalize to SPD
|
||||
if err != nil {
|
||||
t.Fatalf("GetByTag failed: %v", err)
|
||||
}
|
||||
if gotByTag.ID != "clan-1" {
|
||||
t.Errorf("Expected clan-1, got %s", gotByTag.ID)
|
||||
}
|
||||
|
||||
// 7. Update clan
|
||||
updated, err := svc.Update(ctx, "clan-1", UpdateInput{
|
||||
Name: "Super Speed Demons",
|
||||
AvatarURL: "https://example.com/super-spd.png",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Super Speed Demons" || updated.AvatarURL != "https://example.com/super-spd.png" {
|
||||
t.Errorf("Unexpected updated clan fields: %+v", updated)
|
||||
}
|
||||
|
||||
// 8. Delete clan
|
||||
err = svc.Delete(ctx, "clan-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
_, err = svc.Get(ctx, "clan-1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ type Clan struct {
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// Store defines the persistence operations for clans.
|
||||
type Store interface {
|
||||
Exec(ctx context.Context, sql string) (int64, error)
|
||||
Create(ctx context.Context, c Clan) error
|
||||
Get(ctx context.Context, id string) (Clan, error)
|
||||
GetByTag(ctx context.Context, tag string) (Clan, error)
|
||||
List(ctx context.Context, limit, offset int) ([]Clan, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, c Clan) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Service composes validation on top of PgStore.
|
||||
// Service composes validation on top of Store.
|
||||
type Service struct {
|
||||
pg *PgStore
|
||||
pg Store
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires a service.
|
||||
func NewService(pg *PgStore) *Service {
|
||||
func NewService(pg Store) *Service {
|
||||
return &Service{pg: pg, now: time.Now}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
drivers map[string]Driver
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{drivers: make(map[string]Driver)}
|
||||
}
|
||||
|
||||
func (m *memoryStore) Exec(ctx context.Context, sql string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Create(ctx context.Context, d Driver) error {
|
||||
for _, existing := range m.drivers {
|
||||
if existing.Nickname == d.Nickname {
|
||||
return &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
|
||||
}
|
||||
}
|
||||
m.drivers[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(ctx context.Context, id string) (Driver, error) {
|
||||
d, ok := m.drivers[id]
|
||||
if !ok {
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) GetByNickname(ctx context.Context, nick string) (Driver, error) {
|
||||
for _, d := range m.drivers {
|
||||
if d.Nickname == nick {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return Driver{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memoryStore) List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error) {
|
||||
out := make([]Driver, 0)
|
||||
for _, d := range m.drivers {
|
||||
if clanID == "" || d.ClanID == clanID {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Delete(ctx context.Context, id string) error {
|
||||
if _, ok := m.drivers[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.drivers, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Update(ctx context.Context, d Driver) error {
|
||||
if _, ok := m.drivers[d.ID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.drivers[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Count(ctx context.Context) (int, error) {
|
||||
return len(m.drivers), nil
|
||||
}
|
||||
|
||||
func TestDriversService(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
svc := NewService(store)
|
||||
svc.now = func() time.Time {
|
||||
return time.Unix(1700000000, 0)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Create a driver
|
||||
d1, err := svc.Create(ctx, CreateInput{
|
||||
ID: "driver-1",
|
||||
Nickname: "HAM",
|
||||
Name: "Lewis Hamilton",
|
||||
AvatarURL: "https://example.com/ham.png",
|
||||
ClanID: "clan-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
if d1.ID != "driver-1" || d1.Nickname != "HAM" || d1.Name != "Lewis Hamilton" || d1.ClanID != "clan-1" {
|
||||
t.Errorf("Unexpected created driver: %+v", d1)
|
||||
}
|
||||
|
||||
// 2. Validate Nickname must be 3 uppercase ASCII letters
|
||||
_, err = svc.Create(ctx, CreateInput{Nickname: "HAMILTON", Name: "toolong"})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for tag 'HAMILTON', got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Validate empty Name is rejected
|
||||
_, err = svc.Create(ctx, CreateInput{Nickname: "BOT", Name: ""})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got: %v", err)
|
||||
}
|
||||
|
||||
// 4. Duplicate nickname check
|
||||
_, err = svc.Create(ctx, CreateInput{
|
||||
ID: "driver-2",
|
||||
Nickname: "HAM",
|
||||
Name: "Another Lewis",
|
||||
})
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Errorf("Expected ErrAlreadyExists for duplicate nickname, got: %v", err)
|
||||
}
|
||||
|
||||
// 5. Get driver
|
||||
got, err := svc.Get(ctx, "driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if got.Nickname != "HAM" {
|
||||
t.Errorf("Expected HAM, got %s", got.Nickname)
|
||||
}
|
||||
|
||||
// 6. Get by nickname
|
||||
gotByNick, err := svc.GetByNickname(ctx, "ham") // should normalize to HAM
|
||||
if err != nil {
|
||||
t.Fatalf("GetByNickname failed: %v", err)
|
||||
}
|
||||
if gotByNick.ID != "driver-1" {
|
||||
t.Errorf("Expected driver-1, got %s", gotByNick.ID)
|
||||
}
|
||||
|
||||
// 7. Update driver
|
||||
clanID := "clan-2"
|
||||
updated, err := svc.Update(ctx, "driver-1", UpdateInput{
|
||||
Name: "Lewis Hamilton MBE",
|
||||
AvatarURL: "https://example.com/ham-mbe.png",
|
||||
ClanID: &clanID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
if updated.Name != "Lewis Hamilton MBE" || updated.AvatarURL != "https://example.com/ham-mbe.png" || updated.ClanID != "clan-2" {
|
||||
t.Errorf("Unexpected updated driver fields: %+v", updated)
|
||||
}
|
||||
|
||||
// 8. Delete driver
|
||||
err = svc.Delete(ctx, "driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
_, err = svc.Get(ctx, "driver-1")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Expected ErrNotFound after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ type Driver struct {
|
||||
UpdatedMs int64
|
||||
}
|
||||
|
||||
// Store defines the persistence operations for drivers.
|
||||
type Store interface {
|
||||
Exec(ctx context.Context, sql string) (int64, error)
|
||||
Create(ctx context.Context, d Driver) error
|
||||
Get(ctx context.Context, id string) (Driver, error)
|
||||
GetByNickname(ctx context.Context, nick string) (Driver, error)
|
||||
List(ctx context.Context, limit, offset int, clanID string) ([]Driver, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, d Driver) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// PgStore is the Postgres-backed half of the package.
|
||||
type PgStore struct {
|
||||
pool *pgxpool.Pool
|
||||
|
||||
Reference in New Issue
Block a user