Files
3x-ui-agent/internal/singbox/convert_golden_test.go
T
Muse 39ad8b8a2e ITEM6 B1: sing-box data model + one-shot migration
New stored model (legacy columns untouched, read paths unchanged
until B2):
- inbounds.singbox_config (converted sing-box inbound/endpoint
  fragment JSON; empty = blocked or external-runtime) and
  inbounds.singbox_preserved (unmapped items verbatim + panel-side
  material + notes)
- agent_nodes.singbox_outbounds / singbox_routing_rules

Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC):
all panel protocols mapped; unmapped constructs are preserved and
registered, never dropped. B0 golden fixtures: 26/26 semantic match
(fragment + unmapped concept/pending sets + disposition).

One-shot migrator (internal/database, InitDB hook after initModels):
marker-gated and idempotent; SQLite file backup before any write
(failure refuses migration/startup); stats switches migrate out of
the template BEFORE the xrayTemplateConfig row is discarded
(SCOPE 4.3.1 order); identity layer is the only client truth with
spec 7-1 mirror reconciliation (adopt/fill/conflict all reported);
fallbacks merged from table + embedded settings, deduped; agent
overrides converted with per-entry issues; everything in one
transaction, marker written last; JSON report written next to the
DB. Malformed rows/templates are hard failures (rollback, old DB
untouched).

settings.trafficStatsSwitches added with factory defaults
(inbound on, outbound off) in defaultValueMap.

Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp,
agent geo rule, non-default stats) converts as specified; old
production backup (25 inbounds) migrates with credentials, Reality
pbk derivation, traffic counters and reset cycles byte-identical;
idempotent re-run is a no-op; touched packages show zero new test
failures vs the 9-red baseline.
2026-10-22 19:32:15 +00:00

195 lines
5.1 KiB
Go

package singbox
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"testing"
)
// TestConvertGolden drives the B1 converter over the B0 golden fixtures
// (ITEM6-CONVERSION-SPEC). Fragments are compared by semantic JSON deep
// equality; unmapped items are compared as (concept, pending) sets —
// the fixture inputs are redacted (kcp seed="*", xmux trimmed), so the
// verbatim input payloads are not byte-comparable by design. Disposition
// must also match the B0 classification.
func TestConvertGolden(t *testing.T) {
root := filepath.Join("testdata", "golden")
dirs, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read golden dir: %v", err)
}
ran := 0
for _, d := range dirs {
if !d.IsDir() {
continue
}
name := d.Name()
t.Run(name, func(t *testing.T) {
in := convertFixture(t, filepath.Join(root, name))
res, err := ConvertInbound(in)
if err != nil {
t.Fatalf("ConvertInbound: %v", err)
}
exp := loadExpected(t, filepath.Join(root, name, "expected-singbox.json"))
// Fragment: expected carries inbounds[] (+ endpoints[] for
// wireguard). Exactly one fragment object at most.
var gotFrag any
if res.Fragment != nil {
gotFrag = res.Fragment
}
var wantFrag any
key := "inbounds"
if res.Endpoint {
key = "endpoints"
}
if arr, ok := exp[key].([]any); ok && len(arr) > 0 {
wantFrag = arr[0]
}
if !reflect.DeepEqual(normalizeJSON(gotFrag), normalizeJSON(wantFrag)) {
t.Errorf("fragment mismatch:\n got: %s\nwant: %s",
marshalIndent(gotFrag), marshalIndent(wantFrag))
}
// Unmapped: (concept, pending) set equality.
gotSet := map[[2]string]int{}
for _, u := range res.Unmapped {
gotSet[[2]string{u.Concept, u.Pending}]++
}
wantSet := map[[2]string]int{}
if b0, ok := exp["_b0"].(map[string]any); ok {
if ums, ok := b0["unmapped"].([]any); ok {
for _, e := range ums {
em, _ := e.(map[string]any)
wantSet[[2]string{strOf(em["concept"]), strOf(em["pending"])}]++
}
}
if disp := strOf(b0["disposition"]); disp != "" && disp != res.Disposition {
t.Errorf("disposition = %q, want %q", res.Disposition, disp)
}
}
if !reflect.DeepEqual(gotSet, wantSet) {
t.Errorf("unmapped set mismatch:\n got: %v\nwant: %v",
sortedPairs(gotSet), sortedPairs(wantSet))
}
})
ran++
}
if ran != 26 {
t.Fatalf("golden cases ran = %d, want 26", ran)
}
}
func strOf(v any) string {
s, _ := v.(string)
return s
}
func sortedPairs(m map[[2]string]int) [][2]string {
out := make([][2]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if out[i][0] != out[j][0] {
return out[i][0] < out[j][0]
}
return out[i][1] < out[j][1]
})
return out
}
func normalizeJSON(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return v
}
var out any
if err := json.Unmarshal(b, &out); err != nil {
return v
}
return out
}
func marshalIndent(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func loadExpected(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read expected: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse expected: %v", err)
}
return m
}
// convertFixture builds the converter input from a B0 fixture: the
// fixture's settings.clients mirror stands in for the identity layer
// (B0 built the fixtures from a state where mirror == identity), and
// settings.fallbacks supplies the fallback entries.
func convertFixture(t *testing.T, dir string) Inbound {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "input.json"))
if err != nil {
t.Fatalf("read input: %v", err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatalf("parse input: %v", err)
}
ib, _ := root["inbound"].(map[string]any)
if ib == nil {
t.Fatalf("input has no inbound object")
}
settings := gObj(ib, "settings")
in := Inbound{
Listen: gStr(ib, "listen"),
Port: gInt(ib, "port"),
Protocol: gStr(ib, "protocol"),
Settings: rawOf(ib["settings"]),
StreamSettings: rawOf(ib["streamSettings"]),
Sniffing: rawOf(ib["sniffing"]),
}
for _, e := range gArr(settings, "fallbacks") {
in.Fallbacks = append(in.Fallbacks, rawOf(e))
}
for _, e := range gArr(settings, "clients") {
cm, _ := e.(map[string]any)
if cm == nil {
continue
}
cl := Client{
Email: gStr(cm, "email"),
UUID: gStr(cm, "id"),
Password: gStr(cm, "password"),
Auth: gStr(cm, "auth"),
Flow: gStr(cm, "flow"),
Security: gStr(cm, "security"),
AlterID: gInt(cm, "alterId"),
PublicKey: gStr(cm, "publicKey"),
PreSharedKey: gStr(cm, "preSharedKey"),
AllowedIPs: strArr(cm["allowedIPs"]),
KeepAlive: gInt(cm, "keepAlive"),
Secret: gStr(cm, "secret"),
AdTag: gStr(cm, "adTag"),
}
if rev, ok := cm["reverse"]; ok && rev != nil {
cl.Reverse = rawOf(rev)
}
in.Clients = append(in.Clients, cl)
}
return in
}