Files
3x-ui/internal/web/controller/client_partial_apply_test.go
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

188 lines
6.4 KiB
Go

package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"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"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// seedPartlyApplyingClient puts one client on two inbounds and corrupts the second
// one's settings, so a later op succeeds on one inbound and fails on the other.
func seedPartlyApplyingClient(t *testing.T, email string, basePort int) (healthyID, brokenID int) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
ids := make([]int, 0, 2)
for i := range 2 {
ib := &model.Inbound{
UserId: 1, Enable: true, Port: basePort + i,
Tag: "in-" + string(rune('a'+i)) + "-partial",
Protocol: model.VLESS, Settings: `{"clients": []}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %d: %v", i, err)
}
ids = append(ids, ib.Id)
}
if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
Client: model.Client{Email: email, ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-" + email, Enable: true},
InboundIds: ids,
}); err != nil {
t.Fatalf("seed Create across both inbounds: %v", err)
}
if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
Update("settings", `{"clients":`).Error; err != nil {
t.Fatalf("corrupt inbound %d settings: %v", ids[1], err)
}
return ids[0], ids[1]
}
func postCtx(t *testing.T, email string, body any) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "email", Value: email}}
payload := []byte("{}")
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
t.Fatalf("marshal body: %v", err)
}
}
c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
return c, w
}
// assertPartialApply pins that the op really failed on one inbound, so a green
// test cannot be a plain full success that never exercised the error path.
func assertPartialApply(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
t.Fatalf("decode response %q: %v", w.Body.String(), err)
}
if msg.Success {
t.Fatalf("response reports success=true, want the partial apply to report failure: %q", w.Body.String())
}
}
// TestUpdateHandlerFlagsRestartOnPartialApply pins that an edit committed on some
// inbounds and failed on others still flags Xray, as create/attach already did.
func TestUpdateHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-update@example.com"
seedPartlyApplyingClient(t, email, 43310)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, map[string]any{
"email": email, "id": "11111111-2222-3333-4444-555555555555",
"subId": "sub-" + email, "enable": true, "comment": "edited",
})
a.update(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client edit left Xray unflagged for restart")
}
}
// TestDeleteHandlerFlagsRestartOnPartialApply is the delete-side twin: the
// removals that landed still need the restart the error path used to discard.
func TestDeleteHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-delete@example.com"
seedPartlyApplyingClient(t, email, 43320)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, nil)
a.delete(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client delete left Xray unflagged for restart")
}
}
// TestImportHandlerFlagsRestartWhenTrafficRestoreFails: the traffic restore runs
// after the clients are committed, so its failure must not discard their restart.
func TestImportHandlerFlagsRestartWhenTrafficRestoreFails(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
db := database.GetDB()
ib := &model.Inbound{
UserId: 1, Enable: true, Port: 43340, Tag: "in-import-partial",
Protocol: model.VLESS, Settings: `{"clients": []}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
trigger := `CREATE TRIGGER fail_traffic_restore BEFORE UPDATE OF up ON client_traffics
BEGIN SELECT RAISE(ABORT, 'injected traffic restore failure'); END`
if err := db.Exec(trigger).Error; err != nil {
t.Fatalf("create failure trigger: %v", err)
}
const email = "import-partial@example.com"
data, err := json.Marshal([]service.ClientCreatePayload{{
Client: model.Client{Email: email, SubID: "sub-import-partial", Enable: true},
InboundIds: []int{ib.Id},
Traffic: &service.ClientPortableTraffic{Up: 5, Down: 6},
}})
if err != nil {
t.Fatalf("marshal import data: %v", err)
}
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, "", importClientsRequest{Data: string(data)})
a.importClients(c)
assertPartialApply(t, w)
var created int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&created).Error; err != nil {
t.Fatalf("count imported client: %v", err)
}
if created != 1 {
t.Fatalf("imported client count=%d, want 1 committed before the restore failed", created)
}
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a failed traffic restore left the imported clients' Xray restart unflagged")
}
}
// TestDetachHandlerFlagsRestartOnPartialApply covers the third converted path.
func TestDetachHandlerFlagsRestartOnPartialApply(t *testing.T) {
const email = "partial-detach@example.com"
healthyID, brokenID := seedPartlyApplyingClient(t, email, 43330)
a := &ClientController{}
a.xrayService.IsNeedRestartAndSetFalse()
c, w := postCtx(t, email, attachDetachBody{InboundIds: []int{healthyID, brokenID}})
a.detach(c)
assertPartialApply(t, w)
if !a.xrayService.IsNeedRestartAndSetFalse() {
t.Fatal("a partly-applied client detach left Xray unflagged for restart")
}
}