package main import ( "errors" "fmt" "net" "net/http" "path/filepath" "sync/atomic" "testing" "time" "nex/backend/internal/config" "nex/backend/internal/conversion" "nex/backend/internal/database" pkgLogger "nex/backend/pkg/logger" "github.com/gin-gonic/gin" "go.uber.org/zap" "gorm.io/gorm" ) type fakeDesktopLock struct { lockErr error unlockCount atomic.Int32 } func (l *fakeDesktopLock) Lock() error { return l.lockErr } func (l *fakeDesktopLock) Unlock() error { l.unlockCount.Add(1) return nil } func (l *fakeDesktopLock) unlocked() bool { return l.unlockCount.Load() > 0 } type recordingListener struct { net.Listener closeCount atomic.Int32 } func newRecordingListener(t *testing.T) *recordingListener { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("创建测试 listener 失败: %v", err) } return &recordingListener{Listener: listener} } func (l *recordingListener) Close() error { l.closeCount.Add(1) return l.Listener.Close() } func (l *recordingListener) closed() bool { return l.closeCount.Load() > 0 } func testDesktopConfig(t *testing.T) *config.Config { t.Helper() tmpDir := t.TempDir() cfg := config.DefaultConfig() cfg.Server.Port = 0 cfg.Database.Driver = "sqlite" cfg.Database.Path = filepath.Join(tmpDir, "config.db") cfg.Log.Path = filepath.Join(tmpDir, "log") return cfg } func installDesktopTestHooks(t *testing.T, cfg *config.Config, mutate func(*desktopRuntimeHooks)) { t.Helper() oldHooks := desktopHooks oldServer := server oldLogger := zapLogger oldShutdownCtx := shutdownCtx oldShutdownCancel := shutdownCancel server = nil zapLogger = nil shutdownCtx = nil shutdownCancel = nil hooks := defaultDesktopRuntimeHooks() if cfg != nil { hooks.loadConfig = func() (*config.Config, config.ConfigMetadata, error) { return cfg, config.ConfigMetadata{ConfigPath: filepath.Join(t.TempDir(), "config.yaml")}, nil } } hooks.upgradeLogger = func(_ *zap.Logger, _ pkgLogger.Config) (*zap.Logger, error) { return zap.NewNop(), nil } hooks.setupStaticFiles = func(*gin.Engine) error { return nil } hooks.startServer = func(*http.Server, net.Listener, chan<- error, *zap.Logger) {} hooks.setupSystray = func(int, <-chan error) error { return nil } if mutate != nil { mutate(&hooks) } desktopHooks = hooks t.Cleanup(func() { if server != nil { _ = server.Close() } desktopHooks = oldHooks server = oldServer zapLogger = oldLogger shutdownCtx = oldShutdownCtx shutdownCancel = oldShutdownCancel }) } func requireStartupPhase(t *testing.T, err error, want startupPhase) { t.Helper() if err == nil { t.Fatalf("期望 %s 阶段启动错误,实际 nil", want) } var startupErr *startupError if !errors.As(err, &startupErr) { t.Fatalf("期望 startupError,实际: %T %v", err, err) } if startupErr.phase != want { t.Fatalf("phase = %s, want %s", startupErr.phase, want) } } func TestRunDesktopConfigFailureReturnsConfigPhase(t *testing.T) { installDesktopTestHooks(t, nil, func(h *desktopRuntimeHooks) { h.loadConfig = func() (*config.Config, config.ConfigMetadata, error) { return nil, config.ConfigMetadata{}, errors.New("yaml 解析失败") } }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, phaseConfig) } func TestRunDesktopSingletonFailurePrecedesPortListen(t *testing.T) { cfg := testDesktopConfig(t) lock := &fakeDesktopLock{lockErr: errors.New("已有实例运行")} listenCalled := false installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { h.newLock = func(string) singletonLocker { return lock } h.listen = func(int) (net.Listener, error) { listenCalled = true return nil, errors.New("不应监听端口") } }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, phaseSingleton) if listenCalled { t.Fatal("单实例锁失败时不应继续监听端口") } } func TestRunDesktopPortFailureUnlocksSingleton(t *testing.T) { cfg := testDesktopConfig(t) lock := &fakeDesktopLock{} installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { h.newLock = func(string) singletonLocker { return lock } h.listen = func(int) (net.Listener, error) { return nil, errors.New("bind failed") } }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, phasePort) if !lock.unlocked() { t.Fatal("端口监听失败时应释放单实例锁") } } func TestRunDesktopLoggerFailureClosesListenerAndUnlocks(t *testing.T) { cfg := testDesktopConfig(t) lock := &fakeDesktopLock{} listener := newRecordingListener(t) installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { h.newLock = func(string) singletonLocker { return lock } h.listen = func(int) (net.Listener, error) { return listener, nil } h.upgradeLogger = func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error) { return nil, errors.New("log permission denied") } }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, phaseLogger) if !listener.closed() { t.Fatal("日志初始化失败时应关闭 listener") } if !lock.unlocked() { t.Fatal("日志初始化失败时应释放单实例锁") } } func TestRunDesktopDatabaseFailureClassification(t *testing.T) { tests := []struct { name string err error want startupPhase }{ {name: "database", err: errors.New("open failed"), want: phaseDatabase}, {name: "migration", err: fmt.Errorf("%w: %w", database.ErrMigration, errors.New("goose failed")), want: phaseMigration}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testDesktopConfig(t) lock := &fakeDesktopLock{} listener := newRecordingListener(t) installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { h.newLock = func(string) singletonLocker { return lock } h.listen = func(int) (net.Listener, error) { return listener, nil } h.initDB = func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error) { return nil, tt.err } }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, tt.want) if !listener.closed() { t.Fatal("数据库失败时应关闭 listener") } if !lock.unlocked() { t.Fatal("数据库失败时应释放单实例锁") } }) } } func TestRunDesktopInternalStartupFailurePhasesAndDatabaseCleanup(t *testing.T) { tests := []struct { name string mutate func(*desktopRuntimeHooks) want startupPhase }{ { name: "adapter", mutate: func(h *desktopRuntimeHooks) { h.registerAdapters = func(conversion.AdapterRegistry) error { return errors.New("adapter failed") } }, want: phaseAdapter, }, { name: "static", mutate: func(h *desktopRuntimeHooks) { h.setupStaticFiles = func(*gin.Engine) error { return errors.New("missing frontend") } }, want: phaseStaticResource, }, { name: "server", mutate: func(h *desktopRuntimeHooks) { h.startServer = func(_ *http.Server, _ net.Listener, errCh chan<- error, _ *zap.Logger) { errCh <- errors.New("serve failed") } }, want: phaseServer, }, { name: "tray", mutate: func(h *desktopRuntimeHooks) { h.setupSystray = func(int, <-chan error) error { return newStartupError(phaseTray, "托盘初始化失败", errors.New("tray failed")) } }, want: phaseTray, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testDesktopConfig(t) lock := &fakeDesktopLock{} listener := newRecordingListener(t) closeDBCalled := false installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { h.newLock = func(string) singletonLocker { return lock } h.listen = func(int) (net.Listener, error) { return listener, nil } h.closeDB = func(db *gorm.DB) { closeDBCalled = true database.Close(db) } tt.mutate(h) }) err := runDesktop(zap.NewNop()) requireStartupPhase(t, err, tt.want) if !closeDBCalled { t.Fatal("数据库初始化后的启动失败应关闭数据库") } if !listener.closed() { t.Fatal("数据库初始化后的启动失败应关闭 listener") } if !lock.unlocked() { t.Fatal("数据库初始化后的启动失败应释放单实例锁") } }) } } func TestRunDesktopBrowserFailureRemainsNonFatal(t *testing.T) { controller := newFakeTrayController() notified := make(chan string, 1) controller.run = func(onReady func(), _ func()) { onReady() <-controller.quitCh } err := runSystray(19826, trayOptions{ controller: controller, readyTimeout: time.Second, iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, openBrowser: func(string) error { return errors.New("no browser") }, notify: func(_, message string) { notified <- message controller.Quit() }, logger: zap.NewNop(), }) if err != nil { t.Fatalf("浏览器打开失败不应导致 runSystray 返回 fatal: %v", err) } if got := <-notified; got == "" { t.Fatal("浏览器打开失败应提示用户手动访问") } }