diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a841bc562..b1c312129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,16 @@ jobs: exit 1 fi + - name: PostgreSQL Telegram lookup and legacy migration tests + run: | + set -o pipefail + go test ./internal/web/service -run '^(TestGetClientTrafficTgBot_IndexedPostgres|TestGetClientTrafficTgBot_SettingsSerializationStyles)$' -count=1 -v | tee /tmp/postgres-tgbot.log + passed=$(grep -c -- '^--- PASS' /tmp/postgres-tgbot.log || true) + if [ "$passed" -ne 2 ]; then + echo "expected 2 passing PostgreSQL Telegram lookup tests, got $passed" >&2 + exit 1 + fi + codegen: runs-on: ubuntu-latest steps: diff --git a/internal/web/service/inbound_tgbot_lookup_test.go b/internal/web/service/inbound_tgbot_lookup_test.go index f90546848..e7d83130a 100644 --- a/internal/web/service/inbound_tgbot_lookup_test.go +++ b/internal/web/service/inbound_tgbot_lookup_test.go @@ -1,8 +1,16 @@ package service import ( + "context" + "fmt" "path/filepath" + "reflect" + "strings" "testing" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/logger" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" @@ -10,11 +18,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" ) -// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the -// prefilter regressing into a formatting-sensitive string match (#5805): the -// lookup must find clients whether inbounds.settings stores compact JSON -// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as -// written by the panel's MarshalIndent). +// Legacy JSON of either serialization style must remain discoverable after +// the supported ClientsTable startup migration (#5805, #6886). func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) @@ -31,6 +36,11 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { }{ {"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001}, {"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002}, + {"indented", `{ + "clients": [ + {"id": "u3", "email": "indented-user", "tgId": 123456789} + ] + }`, "indented-user", 41003}, } for _, c := range cases { inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings} @@ -40,6 +50,30 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil { t.Fatalf("create %s client_traffics: %v", c.name, err) } + if c.name == "indented" { + // The existing migration also repairs a missing link for an existing record. + if err := db.Create(&model.ClientRecord{Email: c.email, UUID: "u3", TgID: tgId}).Error; err != nil { + t.Fatal(err) + } + } + } + + // Model an upgrade from JSON-only storage, before the ClientsTable seeder. + if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatal(err) + } + if err := database.CloseDB(); err != nil { + t.Fatal(err) + } + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatal(err) + } + var records []model.ClientRecord + if err := database.GetDB().Where("tg_id = ?", tgId).Find(&records).Error; err != nil { + t.Fatal(err) + } + if len(records) != 3 { + t.Fatalf("ClientsTable migration created %d records, want 3", len(records)) } svc := InboundService{} @@ -51,8 +85,8 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { for _, tr := range traffics { got[tr.Email] = true } - if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] { - t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got) + if len(traffics) != 3 || !got["compact-user"] || !got["spaced-user"] || !got["indented-user"] { + t.Fatalf("expected traffic for compact-user, spaced-user and indented-user, got %v", got) } other, err := svc.GetClientTrafficTgBot(42) @@ -63,3 +97,168 @@ func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) { t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other)) } } + +type tgLookupSQL struct { + logger.Interface + queries []string +} + +func (l *tgLookupSQL) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) { + sql, _ := fc() + l.queries = append(l.queries, sql) +} + +func TestGetClientTrafficTgBot_Indexed(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + testIndexedTgLookup(t, database.GetDB()) +} + +func TestGetClientTrafficTgBot_IndexedPostgres(t *testing.T) { + testIndexedTgLookup(t, durablePostgresDB(t)) +} + +func TestGetClientTrafficTgBot_LargeAccount(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + db := database.GetDB() + inbound := &model.Inbound{UserId: 1, Tag: "large-tg", Port: 41888, Protocol: model.VLESS, Settings: `{"clients":[]}`} + if err := db.Create(inbound).Error; err != nil { + t.Fatal(err) + } + const size = 1200 + records := make([]model.ClientRecord, size) + traffics := make([]xray.ClientTraffic, size) + links := make([]model.ClientInbound, size) + for i := range size { + email := fmt.Sprintf("large-%04d", i) + records[i] = model.ClientRecord{Email: email, UUID: "uuid-" + email, SubID: "sub-" + email, TgID: 6886, Enable: true} + traffics[i] = xray.ClientTraffic{Email: email, InboundId: inbound.Id, Up: int64(i)} + } + if err := db.CreateInBatches(&records, 100).Error; err != nil { + t.Fatal(err) + } + for i := range size { + links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: inbound.Id} + } + if err := db.CreateInBatches(&links, 100).Error; err != nil { + t.Fatal(err) + } + if err := db.CreateInBatches(&traffics, 100).Error; err != nil { + t.Fatal(err) + } + trace := &tgLookupSQL{Interface: logger.Default} + previous := db.Logger + db.Logger = trace + t.Cleanup(func() { db.Logger = previous }) + got, err := (&InboundService{}).GetClientTrafficTgBot(6886) + if err != nil { + t.Fatal(err) + } + if len(got) != size || len(trace.queries) != 1 { + t.Fatalf("got %d rows in %d queries, want %d rows in 1 query", len(got), len(trace.queries), size) + } + for i, traffic := range got { + if traffic.Email != records[i].Email || traffic.UUID != records[i].UUID || traffic.SubId != records[i].SubID || !traffic.Enable || traffic.Up != int64(i) { + t.Fatalf("row %d lost ordering or metadata: %+v", i, traffic) + } + } +} + +func testIndexedTgLookup(t *testing.T, db *gorm.DB) { + t.Helper() + const tgID = int64(6886001) + prefix := fmt.Sprintf("tg6886-%d-", time.Now().UnixNano()) + inbounds := []model.Inbound{ + {UserId: 1, Tag: prefix + "a", Port: 41886, Protocol: model.VLESS, Enable: true, Settings: `{"clients":[]}`}, + {UserId: 1, Tag: prefix + "b", Port: 41887, Protocol: model.VLESS, Settings: `{"clients":[]}`}, + } + for i := range inbounds { + if err := db.Create(&inbounds[i]).Error; err != nil { + t.Fatal(err) + } + } + var ids []int + t.Cleanup(func() { + _ = db.Where("client_id IN ?", ids).Delete(&model.ClientInbound{}).Error + _ = db.Where("email LIKE ?", prefix+"%").Delete(&xray.ClientTraffic{}).Error + _ = db.Where("id IN ?", ids).Delete(&model.ClientRecord{}).Error + for i := range inbounds { + _ = db.Delete(&inbounds[i]).Error + } + }) + for i, name := range []string{"a", "b", "no-traffic", "foreign", "unbound", "deleted-inbound", "zero"} { + record := &model.ClientRecord{Email: prefix + name, UUID: "uuid-" + name, SubID: "sub-" + name, TgID: tgID, Enable: true} + if name == "foreign" { + record.TgID++ + } + if name == "zero" { + record.TgID = 0 + } + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + ids = append(ids, record.Id) + if name == "b" { + if err := db.Model(record).Update("enable", false).Error; err != nil { + t.Fatal(err) + } + } + if name != "unbound" { + links := []model.ClientInbound{{ClientId: record.Id, InboundId: inbounds[0].Id}, {ClientId: record.Id, InboundId: inbounds[1].Id}} + if name == "deleted-inbound" { + links = []model.ClientInbound{{ClientId: record.Id, InboundId: -6886}} + } + if err := db.Create(&links).Error; err != nil { + t.Fatal(err) + } + } + if name != "no-traffic" { + traffic := &xray.ClientTraffic{Email: record.Email, InboundId: -1, Enable: false, Up: int64(10 + i), Down: 20, Total: 500, ExpiryTime: 12345, Reset: 3, LastOnline: 6789} + if err := db.Create(traffic).Error; err != nil { + t.Fatal(err) + } + } + } + trace := &tgLookupSQL{Interface: logger.Default} + previous := db.Logger + db.Logger = trace + t.Cleanup(func() { db.Logger = previous }) + svc := &InboundService{} + for _, id := range []int64{tgID, tgID + 100, 0, -1} { + trace.queries = nil + got, err := svc.GetClientTrafficTgBot(id) + if err != nil { + t.Fatalf("lookup(%d): %v", id, err) + } + if id != tgID { + if len(got) != 0 { + t.Fatalf("lookup(%d) returned %v", id, got) + } + } else { + var emails []string + for _, traffic := range got { + emails = append(emails, traffic.Email) + } + if !reflect.DeepEqual(emails, []string{prefix + "a", prefix + "b"}) { + t.Fatalf("emails = %v", emails) + } + for i, traffic := range got { + name := []string{"a", "b"}[i] + if traffic.Enable != (i == 0) || traffic.UUID != "uuid-"+name || traffic.SubId != "sub-"+name || traffic.Up != int64(10+i) || traffic.Down != 20 || traffic.Total != 500 || traffic.ExpiryTime != 12345 || traffic.Reset != 3 || traffic.LastOnline != 6789 || traffic.InboundId != -1 { + t.Fatalf("incorrect metadata/counters: %+v", traffic) + } + } + } + wantQueries := 1 + if id <= 0 { + wantQueries = 0 + } + if len(trace.queries) != wantQueries { + t.Fatalf("lookup(%d) executed %d queries, want %d: %v", id, len(trace.queries), wantQueries, trace.queries) + } + for _, sql := range trace.queries { + if strings.Contains(strings.ToLower(sql), "settings") || strings.Contains(strings.ToLower(sql), "json") { + t.Fatalf("JSON scan in indexed path: %s", sql) + } + } + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index a92a28432..c0f76f772 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -7,7 +7,6 @@ import ( "fmt" "maps" "slices" - "strconv" "strings" "time" @@ -1057,72 +1056,37 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { return nil } +// ClientsTable migrates legacy JSON at startup; normal writes synchronize these +// records. Never fall back to settings JSON, which can retain stale ownership. func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) { - db := database.GetDB() - - idQuery := fmt.Sprintf( - "SELECT DISTINCT inbounds.id %s WHERE %s = ?", - database.JSONClientsFromInbound(), - database.JSONFieldText("client.value", "tgId"), - ) - var inboundIds []int - if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil { - logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) - return nil, err - } - - var inbounds []*model.Inbound - if len(inboundIds) > 0 { - err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err) - return nil, err - } - } - - var emails []string - for _, inbound := range inbounds { - clients, err := s.GetClients(inbound) - if err != nil { - logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err) - continue - } - for _, client := range clients { - if client.TgID == tgId { - emails = append(emails, client.Email) - } - } - } - - // Chunked to stay under SQLite's bind-variable limit when a single Telegram - // account owns thousands of clients across inbounds. - uniqEmails := uniqueNonEmptyStrings(emails) - traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails)) - for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) { - var page []*xray.ClientTraffic - if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - continue - } - logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err) - return nil, err - } - traffics = append(traffics, page...) - } - if len(traffics) == 0 { - logger.Warning("No ClientTraffic records found for emails:", emails) + if tgId <= 0 { return nil, nil } - - // Populate UUID and other client data for each traffic record - for i := range traffics { - if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { - traffics[i].Enable = client.Enable - traffics[i].UUID = client.ID - traffics[i].SubId = client.SubID - } + type row struct { + xray.ClientTraffic + ClientEnable bool + ClientUUID string + ClientSubID string + } + var rows []row + err := database.GetDB().Table("clients"). + Select("client_traffics.*, clients.enable AS client_enable, clients.uuid AS client_uuid, clients.sub_id AS client_sub_id"). + Joins("JOIN client_traffics ON client_traffics.email = clients.email"). + Where("clients.tg_id = ?", tgId). + Where("EXISTS (SELECT 1 FROM client_inbounds JOIN inbounds ON inbounds.id = client_inbounds.inbound_id WHERE client_inbounds.client_id = clients.id)"). + Order("clients.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + traffics := make([]*xray.ClientTraffic, 0, len(rows)) + for i := range rows { + traffic := &rows[i].ClientTraffic + traffic.Enable = rows[i].ClientEnable + traffic.UUID = rows[i].ClientUUID + traffic.SubId = rows[i].ClientSubID + traffics = append(traffics, traffic) } - return traffics, nil } diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index 7e091f1a2..7fadde8cf 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -586,11 +586,6 @@ func (t *Tgbot) randomLowerAndNum(length int) string { return string(bytes) } -// int64Contains checks if an int64 slice contains a specific item. -func int64Contains(slice []int64, item int64) bool { - return slices.Contains(slice, item) -} - // isSingleWord checks if the text contains only a single word. func (t *Tgbot) isSingleWord(text string) bool { text = strings.TrimSpace(text) diff --git a/internal/web/service/tgbot/tgbot_client_links_authz_test.go b/internal/web/service/tgbot/tgbot_client_links_authz_test.go index 8de9e9100..ce2f5a5e4 100644 --- a/internal/web/service/tgbot/tgbot_client_links_authz_test.go +++ b/internal/web/service/tgbot/tgbot_client_links_authz_test.go @@ -9,6 +9,7 @@ import ( "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/global" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" @@ -19,8 +20,8 @@ const ( ownerMail = "owner@x" ) -// newLinksCallbackTgbot seeds one inbound whose settings bind email to -// ownerTgID, the traffic row the ownership lookup joins on, and a mocked API. +// newLinksCallbackTgbot seeds synchronized client records, inbound settings, +// traffic and a mocked Telegram API for the owner's link callbacks. func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int) { t.Helper() mock, calls := staleButtonServer(t, map[string]any{ @@ -47,6 +48,13 @@ func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int if err := database.GetDB().Create(inbound).Error; err != nil { t.Fatalf("seed inbound: %v", err) } + clients, err := (&service.InboundService{}).GetClients(inbound) + if err != nil { + t.Fatal(err) + } + if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatal(err) + } if err := database.GetDB().Create(&xray.ClientTraffic{ InboundId: inbound.Id, Email: email, @@ -101,6 +109,25 @@ func TestClientLinkCallbackServesOwnClient(t *testing.T) { } } +func TestClientOwnershipUsesNormalizedTelegramID(t *testing.T) { + tb, _ := newLinksCallbackTgbot(t, ownerMail) + db := database.GetDB() + if err := db.Model(&model.ClientRecord{}).Where("email = ?", ownerMail).Update("tg_id", ownerTgID+1).Error; err != nil { + t.Fatal(err) + } + if tb.clientOwnedByTgUser(ownerTgID, ownerMail) { + t.Fatal("stale settings JSON granted ownership to the previous Telegram user") + } + if !tb.clientOwnedByTgUser(ownerTgID+1, ownerMail) { + t.Fatal("the normalized Telegram owner was denied access") + } + for _, id := range []int64{0, -1, ownerTgID + 2} { + if tb.clientOwnedByTgUser(id, ownerMail) { + t.Fatalf("unbound Telegram ID %d received ownership", id) + } + } +} + // Regression test: a payload past 64 chars arrives as its hash, so an email long // enough to be hashed must still be decoded and served to its owner. func TestHashedLinkCallbackServesOwnClient(t *testing.T) { diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go index 7694bb4fd..1207243e3 100644 --- a/internal/web/service/tgbot/tgbot_invite_test.go +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -76,7 +76,10 @@ func newInviteTgbot(t *testing.T, email string) (*Tgbot, func(string) int) { Update("settings", `{"clients":[{"id":"6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f","email":"`+email+`","subId":"sub-invite"}]}`).Error; err != nil { t.Fatalf("unbind seeded client: %v", err) } - seedClientRecord(t, email, "sub-invite", 0) + if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", email). + Updates(map[string]any{"sub_id": "sub-invite", "tg_id": 0}).Error; err != nil { + t.Fatalf("unbind normalized client: %v", err) + } withAdmins(t, 1) return tb, calls } diff --git a/internal/web/service/tgbot/tgbot_level_test.go b/internal/web/service/tgbot/tgbot_level_test.go index dfd650618..70c7590c1 100644 --- a/internal/web/service/tgbot/tgbot_level_test.go +++ b/internal/web/service/tgbot/tgbot_level_test.go @@ -24,7 +24,6 @@ func withAdmins(t *testing.T, ids ...int64) { func newLevelTgbot(t *testing.T) (*Tgbot, func(string) int) { t.Helper() tb, calls := newLinksCallbackTgbot(t, ownerMail) - seedClientRecord(t, ownerMail, "sub-owned", ownerTgID) withAdmins(t, 1) return tb, calls } diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index e9ed58dff..502b45a35 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -312,19 +312,23 @@ func (t *Tgbot) notifyExhausted() { logger.Warning("Unable to load Inbounds", err) } - var chatIDsDone []int64 + chatIDsDone := make(map[int64]struct{}) for _, inbound := range inbounds { if inbound.Enable { if len(inbound.ClientStats) > 0 { - clients, err := t.inboundService.GetClients(inbound) + clients, err := t.inboundService.ListClientsForInbound(inbound.Id) if err == nil { for _, client := range clients { if client.TgID != 0 { chatID := client.TgID - if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) { + if _, done := chatIDsDone[chatID]; !done && !checkAdmin(chatID) { + chatIDsDone[chatID] = struct{}{} var disabledClients []xray.ClientTraffic var exhaustedClients []xray.ClientTraffic traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID) + if err != nil { + logger.Warningf("Unable to load Telegram client traffic for %d: %v", chatID, err) + } if err == nil && len(traffics) > 0 { var output strings.Builder output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))) @@ -357,7 +361,6 @@ func (t *Tgbot) notifyExhausted() { } t.SendMsgToTgbot(chatID, output.String()) } - chatIDsDone = append(chatIDsDone, chatID) } } } diff --git a/internal/web/service/tgbot/tgbot_report_batching_test.go b/internal/web/service/tgbot/tgbot_report_batching_test.go index 324f63415..b232bbaa2 100644 --- a/internal/web/service/tgbot/tgbot_report_batching_test.go +++ b/internal/web/service/tgbot/tgbot_report_batching_test.go @@ -2,6 +2,7 @@ package tgbot import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -10,13 +11,16 @@ import ( "strings" "sync" "testing" + "time" + + "github.com/mymmrac/telego" + "gorm.io/gorm" "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/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "github.com/mymmrac/telego" ) // seedReportClients writes one inbound holding every email plus the traffic row @@ -185,3 +189,92 @@ func TestTrafficUsageReportEscapesHtml(t *testing.T) { t.Errorf("report text = %q, want the remark escaped as <fast>", got[0].Text) } } + +func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) { + for _, outcome := range []string{"empty", "failure", "exhausted", "expiry", "admin", "zero", "disabled-inbound"} { + t.Run(outcome, func(t *testing.T) { + tb := initReportDB(t) + clientInfoLocalizer(t) + mock, sent := captureReportServer(t) + swapTestBot(t, mock.URL) + t.Cleanup(mock.Close) + clients := []model.Client{ + {Email: "notify-a", ID: "uuid-a", TgID: 6886, Enable: true}, + {Email: "notify-b", ID: "uuid-b", TgID: 6886, Enable: true}, + } + if outcome == "zero" { + for i := range clients { + clients[i].TgID = 0 + } + } + if outcome == "admin" { + tgBotMutex.Lock() + previous := adminIds + adminIds = append(append([]int64(nil), adminIds...), 6886) + tgBotMutex.Unlock() + t.Cleanup(func() { + tgBotMutex.Lock() + adminIds = previous + tgBotMutex.Unlock() + }) + } + db := database.GetDB() + for i := range 2 { + inbound := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("notify-%d", i), Port: 41890 + i, Enable: true, Protocol: model.VLESS, Settings: `{"clients":[{"email":"notify-a","id":"uuid-a","enable":true,"tgId":6886},{"email":"notify-b","id":"uuid-b","enable":true,"tgId":6886}]}`} + if outcome == "disabled-inbound" { + inbound.Enable = false + } + if err := db.Create(inbound).Error; err != nil { + t.Fatal(err) + } + if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatal(err) + } + // Keep the inbound eligible even when the Telegram account has no traffic. + traffic := &xray.ClientTraffic{Email: fmt.Sprintf("unrelated-%d", i), InboundId: inbound.Id} + if outcome == "exhausted" || outcome == "expiry" { + traffic.Email = clients[i].Email + traffic.Enable = true + traffic.Total = 1 + traffic.Up = 2 + if outcome == "expiry" { + traffic.Total = 0 + traffic.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli() + } + } + if err := db.Create(traffic).Error; err != nil { + t.Fatal(err) + } + } + lookups := 0 + cb := "test:notify_lookup" + if err := db.Callback().Query().Before("gorm:query").Register(cb, func(tx *gorm.DB) { + if tx.Statement.Table == "clients" && strings.Contains(fmt.Sprint(tx.Statement.Clauses["WHERE"]), "tg_id") { + lookups++ + if outcome == "failure" { + tx.AddError(errors.New("injected Telegram lookup failure")) + } + } + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove(cb) }) + tb.notifyExhausted() + wantLookups := 1 + if outcome == "admin" || outcome == "zero" || outcome == "disabled-inbound" { + wantLookups = 0 + } + if lookups != wantLookups { + t.Fatalf("lookups = %d, want %d after %s", lookups, wantLookups, outcome) + } + got := sent() + if outcome == "exhausted" || outcome == "expiry" { + if len(got) != 1 || !strings.Contains(got[0].Text, "notify-a") || !strings.Contains(got[0].Text, "notify-b") { + t.Fatalf("want one notification containing both clients, got %+v", got) + } + } else if len(got) != 0 { + t.Fatalf("unexpected notifications: %+v", got) + } + }) + } +}