accounts/internal/xrayconfig/syncer_test.go
Haitao Pan 07e31ff6bd feat: move account service to repo root
# Conflicts:
#	account/Makefile
#	account/go.mod
#	docs/account-admin-settings.md
#	docs/account-svc-plus.md
2026-01-16 16:15:23 +08:00

206 lines
5.4 KiB
Go

package xrayconfig
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type staticSource struct {
clients []Client
err error
}
const minimalTemplate = `{"inbounds":[{"settings":{"clients":[]}}]}`
func (s staticSource) ListClients(context.Context) ([]Client, error) {
if s.err != nil {
return nil, s.err
}
return append([]Client(nil), s.clients...), nil
}
func TestNewPeriodicSyncerValidation(t *testing.T) {
output := filepath.Join(t.TempDir(), "config.json")
tests := []struct {
name string
opts PeriodicOptions
}{
{
name: "missing source",
opts: PeriodicOptions{
Interval: time.Minute,
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
},
},
{
name: "missing output",
opts: PeriodicOptions{
Interval: time.Minute,
Source: staticSource{},
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}},
},
},
{
name: "non-positive interval",
opts: PeriodicOptions{
Interval: 0,
Source: staticSource{},
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := NewPeriodicSyncer(tt.opts); err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestPeriodicSyncerSyncSuccess(t *testing.T) {
output := filepath.Join(t.TempDir(), "config.json")
opts := PeriodicOptions{
Interval: time.Minute,
Source: staticSource{clients: []Client{{ID: "uuid-a", Email: "a@example"}, {ID: "uuid-b"}}},
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
ValidateCommand: []string{"echo", "validate"},
RestartCommand: []string{"echo", "restart"},
}
var commands [][]string
opts.Runner = func(_ context.Context, cmd []string) ([]byte, error) {
commands = append(commands, append([]string(nil), cmd...))
return []byte(strings.Join(cmd, " ")), nil
}
syncer, err := NewPeriodicSyncer(opts)
if err != nil {
t.Fatalf("new syncer: %v", err)
}
n, err := syncer.sync(context.Background())
if err != nil {
t.Fatalf("sync: %v", err)
}
if n != 2 {
t.Fatalf("expected 2 clients, got %d", n)
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !strings.Contains(string(data), "uuid-a") || !strings.Contains(string(data), "uuid-b") {
t.Fatalf("output missing client ids: %s", string(data))
}
if got, want := len(commands), 2; got != want {
t.Fatalf("expected %d commands, got %d", want, got)
}
if commands[0][0] != "echo" || commands[1][0] != "echo" {
t.Fatalf("unexpected commands: %+v", commands)
}
}
func TestPeriodicSyncerSyncError(t *testing.T) {
output := filepath.Join(t.TempDir(), "config.json")
opts := PeriodicOptions{
Interval: time.Minute,
Source: staticSource{err: errors.New("boom")},
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
}
syncer, err := NewPeriodicSyncer(opts)
if err != nil {
t.Fatalf("new syncer: %v", err)
}
if _, err := syncer.sync(context.Background()); err == nil {
t.Fatalf("expected sync error")
}
}
func TestPeriodicSyncerStartStop(t *testing.T) {
output := filepath.Join(t.TempDir(), "config.json")
var calls atomic.Int32
src := staticSource{clients: []Client{{ID: "uuid-a"}}}
opts := PeriodicOptions{
Interval: 10 * time.Millisecond,
Source: clientSourceFunc(func(ctx context.Context) ([]Client, error) {
calls.Add(1)
return src.ListClients(ctx)
}),
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
}
syncer, err := NewPeriodicSyncer(opts)
if err != nil {
t.Fatalf("new syncer: %v", err)
}
stop, err := syncer.Start(context.Background())
if err != nil {
t.Fatalf("start: %v", err)
}
deadline := time.Now().Add(200 * time.Millisecond)
for calls.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
if calls.Load() == 0 {
t.Fatalf("sync never executed")
}
if err := stop(context.Background()); err != nil {
t.Fatalf("stop: %v", err)
}
}
func TestPeriodicSyncerOnSyncCallback(t *testing.T) {
output := filepath.Join(t.TempDir(), "config.json")
var results []SyncResult
var mu sync.Mutex
opts := PeriodicOptions{
Interval: 5 * time.Millisecond,
Source: staticSource{clients: []Client{{ID: "uuid-a"}}},
Generator: Generator{Definition: JSONDefinition{Raw: []byte(minimalTemplate)}, OutputPath: output},
OnSync: func(res SyncResult) {
mu.Lock()
defer mu.Unlock()
results = append(results, res)
},
}
syncer, err := NewPeriodicSyncer(opts)
if err != nil {
t.Fatalf("new syncer: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
stop, err := syncer.Start(ctx)
if err != nil {
t.Fatalf("start: %v", err)
}
time.Sleep(20 * time.Millisecond)
cancel()
if err := stop(context.Background()); err != nil {
t.Fatalf("stop: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(results) == 0 {
t.Fatalf("expected at least one sync result")
}
if results[0].Clients != 1 {
t.Fatalf("expected 1 client, got %d", results[0].Clients)
}
if results[0].CompletedAt.IsZero() {
t.Fatalf("expected completion timestamp to be set")
}
}
type clientSourceFunc func(ctx context.Context) ([]Client, error)
func (f clientSourceFunc) ListClients(ctx context.Context) ([]Client, error) {
return f(ctx)
}