mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
feat: show current owner names and persist user and limit order (#576)
Resolve current forwarding-rule owner names by user ID. Add administrator-managed persistent user and speed-limit ordering, backup compatibility, and conflict recovery. Repair PostgreSQL explicit-ID import sequences transactionally. Closes #574 Closes #575
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/model"
|
||||
"go-backend/internal/store/repo"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDisplayOrderBackupAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "order.db")
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := r.CreateSpeedLimit("a", 100, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := r.CreateSpeedLimit("b", 200, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.UpdateSpeedLimitDisplayOrder([]int64{a, b}, []int64{b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(r *repo.Repository) {
|
||||
t.Helper()
|
||||
rows, err := r.ListSpeedLimits()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []int64{a, b}) {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
check(r)
|
||||
backup, err := r.ExportPartial([]string{"speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest, err := repo.Open(filepath.Join(t.TempDir(), "restore.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dest.Close()
|
||||
if _, err := dest.Import(backup, []string{"speedLimits"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(dest)
|
||||
}
|
||||
|
||||
// Lifecycle and migration fixtures use DDL only to reproduce a pre-upgrade
|
||||
// database and inject a write failure. All observations use public interfaces.
|
||||
func TestDisplayOrderLifecycleDialects(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r := open()
|
||||
for _, resource := range []string{"user", "speed_limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
create := func(name string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
var err error
|
||||
if resource == "user" {
|
||||
id, err = r.CreateUser(name, security.MD5("test-password"), 1, 0, 123, 1, 4, 1, 5, 0)
|
||||
} else {
|
||||
id, err = r.CreateSpeedLimit(name, 123, 0, 1)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
list := func(db *repo.Repository) []int64 {
|
||||
t.Helper()
|
||||
var rows []map[string]interface{}
|
||||
var err error
|
||||
if resource == "user" {
|
||||
rows, err = db.ListUsers()
|
||||
} else {
|
||||
rows, err = db.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row["id"].(int64))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
save := func(db *repo.Repository, ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return db.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return db.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, b, c := create("a"), create("b"), create("c")
|
||||
// Drop the new column to reproduce a database last opened by the old release.
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP COLUMN display_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("legacy order %v", got)
|
||||
}
|
||||
if err := save(r, []int64{a, c, b}, []int64{c, b, a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore := orderTestDatabase(t, dialect)()
|
||||
defer restore.Close()
|
||||
types := []string{"speedLimits"}
|
||||
if resource == "user" {
|
||||
types = []string{"users"}
|
||||
}
|
||||
if _, err := restore.Import(backup, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(restore); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("new backup order: %v", got)
|
||||
}
|
||||
before, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = open()
|
||||
second := open()
|
||||
defer second.Close()
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("cross-client/restart order %v", got)
|
||||
}
|
||||
// The next save fails on its second row; the first row must roll back.
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec(fmt.Sprintf("CREATE TRIGGER reject_order BEFORE UPDATE OF display_order ON \"%s\" WHEN NEW.id = %d BEGIN SELECT RAISE(ABORT, 'injected failure'); END", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec(fmt.Sprintf("ALTER TABLE \"%s\" ADD CONSTRAINT reject_order CHECK (id != %d OR display_order = 0)", resource, a)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := save(r, []int64{b, a, c}, []int64{a, c, b}); err == nil {
|
||||
t.Fatal("expected injected failure")
|
||||
}
|
||||
if got := list(second); !reflect.DeepEqual(got, []int64{a, c, b}) {
|
||||
t.Fatalf("partial write: %v", got)
|
||||
}
|
||||
if dialect == "sqlite" {
|
||||
if err := r.DB().Exec("DROP TRIGGER reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := r.DB().Exec("ALTER TABLE \"" + resource + "\" DROP CONSTRAINT reject_order").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Exactly one cross-client save against the same observed order may win.
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
for _, db := range []*repo.Repository{r, second} {
|
||||
go func(db *repo.Repository) { <-start; results <- save(db, []int64{b, a, c}, []int64{a, c, b}) }(db)
|
||||
}
|
||||
close(start)
|
||||
e1, e2 := <-results, <-results
|
||||
if !((e1 == nil && errors.Is(e2, repo.ErrDisplayOrderConflict)) || (e2 == nil && errors.Is(e1, repo.ErrDisplayOrderConflict))) {
|
||||
t.Fatalf("concurrent saves: %v / %v", e1, e2)
|
||||
}
|
||||
// Reordering must not mutate any exported fields besides rank.
|
||||
after, err := r.ExportPartial([]string{"users", "speedLimits"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
strip := func(b *model.BackupData) {
|
||||
b.ExportedAt = 0
|
||||
for i := range b.Users {
|
||||
b.Users[i].DisplayOrder = nil
|
||||
}
|
||||
for i := range b.SpeedLimits {
|
||||
b.SpeedLimits[i].DisplayOrder = nil
|
||||
}
|
||||
}
|
||||
strip(before)
|
||||
strip(after)
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("reorder modified non-order backup fields")
|
||||
}
|
||||
// Old backups omit ranks and must not overwrite an existing saved order.
|
||||
legacy := orderTestDatabase(t, dialect)()
|
||||
defer legacy.Close()
|
||||
if _, err := legacy.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(legacy); !reflect.DeepEqual(got, []int64{c, b, a}) {
|
||||
t.Fatalf("new legacy records: %v", got)
|
||||
}
|
||||
if _, err := r.Import(after, types); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{b, a, c}) {
|
||||
t.Fatalf("old backup reset existing order: %v", got)
|
||||
}
|
||||
d := create("d")
|
||||
if got := list(r); !reflect.DeepEqual(got, []int64{d, b, a, c}) {
|
||||
t.Fatalf("create after reorder: %v", got)
|
||||
}
|
||||
if err := save(second, []int64{a, b, c}, []int64{b, a, c}); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("stale membership: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
r.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func orderTestDatabase(t *testing.T, dialect string) func() *repo.Repository {
|
||||
t.Helper()
|
||||
if dialect == "sqlite" {
|
||||
path := filepath.Join(t.TempDir(), "lifecycle.db")
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
dsn := os.Getenv("FLVX_POSTGRES_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set FLVX_POSTGRES_TEST_DSN for isolated PostgreSQL contracts")
|
||||
}
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := "contract_display_order_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
if _, err := admin.Exec("CREATE SCHEMA \"" + schema + "\""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { admin.Exec("DROP SCHEMA \"" + schema + "\" CASCADE"); admin.Close() })
|
||||
scoped, err := withSearchPath(dsn, schema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() *repo.Repository {
|
||||
r, err := repo.OpenPostgres(scoped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { r.Close() })
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayOrderConcurrentCreate(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
for _, resource := range []string{"user", "speed-limit"} {
|
||||
t.Run(resource, func(t *testing.T) {
|
||||
open := orderTestDatabase(t, dialect)
|
||||
r, other := open(), open()
|
||||
create := func(db *repo.Repository, name string) (int64, error) {
|
||||
if resource == "user" {
|
||||
return db.CreateUser(name, "hash", 1, 0, 10, 1, 2, 1, 3, 0)
|
||||
}
|
||||
return db.CreateSpeedLimit(name, 100, 0, 1)
|
||||
}
|
||||
save := func(ids, expected []int64) error {
|
||||
if resource == "user" {
|
||||
return r.UpdateUserDisplayOrder(ids, expected)
|
||||
}
|
||||
return r.UpdateSpeedLimitDisplayOrder(ids, expected)
|
||||
}
|
||||
a, err := create(r, "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := create(r, "b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
saved := make(chan error, 1)
|
||||
type creation struct {
|
||||
id int64
|
||||
err error
|
||||
}
|
||||
created := make(chan creation, 1)
|
||||
go func() { <-start; saved <- save([]int64{a, b}, []int64{b, a}) }()
|
||||
go func() { <-start; id, err := create(other, "c"); created <- creation{id, err} }()
|
||||
close(start)
|
||||
saveErr, c := <-saved, <-created
|
||||
if c.err != nil {
|
||||
t.Fatal(c.err)
|
||||
}
|
||||
if saveErr != nil && !errors.Is(saveErr, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("concurrent save: %v", saveErr)
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if resource == "user" {
|
||||
rows, err = r.ListUsers()
|
||||
} else {
|
||||
rows, err = r.ListSpeedLimits()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []int64{}
|
||||
for _, row := range rows {
|
||||
got = append(got, row["id"].(int64))
|
||||
}
|
||||
want := []int64{c.id, a, b}
|
||||
if saveErr != nil {
|
||||
want = []int64{c.id, b, a}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("concurrent create order %v want %v", got, want)
|
||||
}
|
||||
if resource == "user" {
|
||||
err = r.DeleteUserCascade(c.id)
|
||||
} else {
|
||||
err = r.DeleteSpeedLimit(c.id)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := save(got, got); !errors.Is(err, repo.ErrDisplayOrderConflict) {
|
||||
t.Fatalf("deleted membership not rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user