Files
3x-ui/internal/web/service/inbound_disable_flow_test.go
T
MHSanaei 12d51d7195 perf(tests): copy a migrated template DB instead of migrating per test
Most tests opened a throwaway panel DB with database.InitDB, which runs the
full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under
-race because GORM's reflection-heavy migration is what the detector slows
most. internal/web/service does this in ~550 of its 830 tests, so the CI race
job spent ~10 of its ~14.6 minutes re-migrating empty databases.

internal/database/dbtest.InitDB migrates once per test process, then hands
each test its own copy of that file (~130ms under -race) and registers the
CloseDB cleanup. The copy then goes through InitDB like a panel restart, so
every test still starts from the state a fresh install has. Tests that reopen
an existing file, migrate a hand-built legacy DB or target Postgres keep
calling database.InitDB.

Locally under -race: internal/web/service 626s (last CI run) -> 114s,
internal/sub 246s -> 35s.
2026-09-27 03:04:50 +02:00

210 lines
6.1 KiB
Go

package service
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
)
const visionTest = "xtls-rprx-vision"
func clientFlowsInSettings(t *testing.T, settings string) map[string]string {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("parse settings: %v", err)
}
out := map[string]string{}
clients, _ := parsed["clients"].([]any)
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
email, _ := cm["email"].(string)
flow, _ := cm["flow"].(string)
out[email] = flow
}
return out
}
func TestStripClientFlows(t *testing.T) {
cases := []struct {
name string
in string
wantChanged bool
wantFlows map[string]string
}{
{
name: "clears vision on all clients",
in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":"` + visionTest + `"}]}`,
wantChanged: true,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "mixed flows: clears only the non-empty",
in: `{"clients":[{"email":"a","flow":"` + visionTest + `"},{"email":"b","flow":""}]}`,
wantChanged: true,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "no flows: unchanged",
in: `{"clients":[{"email":"a","flow":""},{"email":"b"}]}`,
wantChanged: false,
wantFlows: map[string]string{"a": "", "b": ""},
},
{
name: "no clients: unchanged",
in: `{"decryption":"none"}`,
wantChanged: false,
},
{
name: "malformed json: unchanged",
in: `{not json`,
wantChanged: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, changed := stripClientFlows(tc.in)
if changed != tc.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
}
if !changed {
if out != tc.in {
t.Fatalf("unchanged input must be returned verbatim, got %q", out)
}
return
}
got := clientFlowsInSettings(t, out)
for email, want := range tc.wantFlows {
if got[email] != want {
t.Errorf("flow[%s] = %q, want %q", email, got[email], want)
}
}
})
}
}
func initFlowTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
return database.GetDB()
}
func TestAddInbound_DisableFlowClampsClientFlow(t *testing.T) {
initFlowTestDB(t)
ibSvc := &InboundService{}
in := &model.Inbound{
Tag: "dis-add", Enable: true, Port: 52001, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"u1","email":"a@x","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
DisableFlow: true,
}
if _, _, err := ibSvc.AddInbound(in); err != nil {
t.Fatalf("AddInbound: %v", err)
}
got, err := ibSvc.GetInbound(in.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
if !got.DisableFlow {
t.Error("DisableFlow not persisted on created inbound")
}
if f := clientFlowsInSettings(t, got.Settings)["a@x"]; f != "" {
t.Errorf("settings flow = %q, want empty (clamped at creation)", f)
}
list, err := ibSvc.clientService.ListForInbound(nil, in.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 || list[0].Flow != "" {
t.Errorf("flow_override = %#v, want empty (xray must not expect Vision)", list)
}
}
func TestUpdateInbound_DisableFlowPersistsStripsAndResistsRestore(t *testing.T) {
db := initFlowTestDB(t)
ibSvc := &InboundService{}
cs := &ClientService{}
const email = "shared@x"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
sibling := &model.Inbound{
Tag: "sib", Enable: true, Port: 52101, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
}
if err := db.Create(sibling).Error; err != nil {
t.Fatalf("create sibling: %v", err)
}
sc, _ := ibSvc.GetClients(sibling)
if err := cs.SyncInbound(nil, sibling.Id, sc); err != nil {
t.Fatalf("sync sibling: %v", err)
}
target := &model.Inbound{
Tag: "tgt", Enable: true, Port: 52102, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: `{"clients":[{"id":"` + uid + `","email":"` + email + `","flow":"` + visionTest + `","subId":"s1","enable":true}]}`,
}
if err := db.Create(target).Error; err != nil {
t.Fatalf("create target: %v", err)
}
tc, _ := ibSvc.GetClients(target)
if err := cs.SyncInbound(nil, target.Id, tc); err != nil {
t.Fatalf("sync target: %v", err)
}
upd := *target
upd.DisableFlow = true
if _, _, err := ibSvc.UpdateInbound(&upd); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
reloaded, err := ibSvc.GetInbound(target.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
if !reloaded.DisableFlow {
t.Fatal("DisableFlow did not persist through UpdateInbound (blocking regression)")
}
if f := clientFlowsInSettings(t, reloaded.Settings)["shared@x"]; f != "" {
t.Errorf("target settings flow = %q, want empty after disable", f)
}
list, err := cs.ListForInbound(nil, target.Id)
if err != nil {
t.Fatalf("ListForInbound(target): %v", err)
}
if len(list) != 1 || list[0].Flow != "" {
t.Errorf("target flow_override = %#v, want empty", list)
}
ibSvc.MigrationRestoreVisionFlow()
reloaded2, err := ibSvc.GetInbound(target.Id)
if err != nil {
t.Fatalf("GetInbound after restore: %v", err)
}
if f := clientFlowsInSettings(t, reloaded2.Settings)["shared@x"]; f != "" {
t.Errorf("after MigrationRestoreVisionFlow target flow = %q, want empty (must not self-revert)", f)
}
sList, err := cs.ListForInbound(nil, sibling.Id)
if err != nil {
t.Fatalf("ListForInbound(sibling): %v", err)
}
if len(sList) != 1 || sList[0].Flow != visionTest {
t.Errorf("sibling flow_override = %#v, want Vision preserved", sList)
}
}