package httpapi import ( "bytes" "context" "database/sql" "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) /** * 恢复流程会在请求末尾关掉目标库、换掉文件,随后进程才优雅退出。 * 广播中间件是在 handler 返回之后读 change_log 的,必然撞上「sql: database is closed」—— * 那不是故障,是一条 ERROR 却在现场最紧张的时刻出现,会被当成「恢复把系统搞坏了」。 * * 这里钉住两条口径:库已关闭 → 只留 Debug;其它读库失败 → 照旧 ERROR,不许被静默吞掉。 */ // stubRealtime 只实现广播中间件用到的那两个方法。 type stubRealtime struct { loadErr error } func (s *stubRealtime) Broadcast([]realtime.Change) {} func (s *stubRealtime) LoadChanges(context.Context, int64, int) ([]realtime.Change, error) { return nil, s.loadErr } func (s *stubRealtime) Instances() []realtime.Instance { return nil } func captureLogs() (*slog.Logger, *bytes.Buffer) { buf := &bytes.Buffer{} return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf } func openSQLiteAt(t *testing.T, path string, migrate bool) *sql.DB { t.Helper() dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("打开 %s: %v", path, err) } db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if migrate { if err := store.MigrateEvent(db); err != nil { t.Fatalf("迁移赛事库: %v", err) } } return db } // closedDBError 拿一个真正的「sql: database is closed」。 // 标准库里这个错误是未导出的(database/sql 的 errDBClosed),只能打开一个库再关掉、 // 然后查询它,让驱动自己把它交出来。 func closedDBError(t *testing.T) error { t.Helper() db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "closed.db")+"?_pragma=busy_timeout(5000)") if err != nil { t.Fatalf("打开库: %v", err) } if err := db.Close(); err != nil { t.Fatalf("关闭库: %v", err) } var one int return db.QueryRow("SELECT 1").Scan(&one) } // 库已关闭不是故障:changeVersion 只留 Debug,不出 ERROR。 func TestChangeVersionClosedDBIsNotLoggedAsError(t *testing.T) { logger, logs := captureLogs() db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false) if err := db.Close(); err != nil { t.Fatalf("关闭赛事库: %v", err) } s := &server{logger: logger, ev: db} if _, ok := s.changeVersion(context.Background()); ok { t.Fatal("已关闭的库不该给出变更版本") } if strings.Contains(logs.String(), "level=ERROR") { t.Errorf("库已关闭不该记 ERROR:%s", logs.String()) } if !strings.Contains(logs.String(), "库已关闭") { t.Errorf("应留下一条 Debug 便于排查:%s", logs.String()) } } // 真的读不通(这里是表都不存在)仍然要记 ERROR,不能被「库已关闭」这条规则顺手吞掉。 func TestChangeVersionOtherErrorsAreStillLogged(t *testing.T) { logger, logs := captureLogs() // 没跑过迁移的库:没有 change_log 表。 db := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), false) s := &server{logger: logger, ev: db} if _, ok := s.changeVersion(context.Background()); ok { t.Fatal("查不到 change_log 时不该给出变更版本") } if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取变更版本失败") { t.Errorf("真失败仍要记 ERROR:%s", logs.String()) } } // 广播中间件:写请求成功后 LoadChanges 撞上「库已关闭」→ 不记 ERROR。 func TestBroadcastChangesClosedDBIsNotLoggedAsError(t *testing.T) { logger, logs := captureLogs() ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true) // 按 realtime 包的包装方式裹一层,确认 errors 链上的「库已关闭」也认得出。 loadErr := fmt.Errorf("读取增量变更: %w", closedDBError(t)) s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: loadErr}} h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil)) if strings.Contains(logs.String(), "level=ERROR") { t.Errorf("库已关闭不该记 ERROR:%s", logs.String()) } if !strings.Contains(logs.String(), "库已关闭") { t.Errorf("应留下一条 Debug 便于排查:%s", logs.String()) } } // 广播中间件:别的读库失败(磁盘 I/O、SQL 写错…)照旧记 ERROR。 func TestBroadcastChangesOtherErrorsAreStillLogged(t *testing.T) { logger, logs := captureLogs() ev := openSQLiteAt(t, filepath.Join(t.TempDir(), "event.db"), true) s := &server{logger: logger, ev: ev, realtime: &stubRealtime{loadErr: errors.New("disk I/O error")}} h := s.broadcastChanges(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/teams", nil)) if !strings.Contains(logs.String(), "level=ERROR") || !strings.Contains(logs.String(), "读取实时变更失败") { t.Errorf("真失败仍要记 ERROR:%s", logs.String()) } } // dbClosed 只认原文完全相等的那一层:子串、相似文案都不算。 func TestDbClosedMatchesOnlyExactMessage(t *testing.T) { if !dbClosed(closedDBError(t)) { t.Error("真正的「sql: database is closed」应被认出") } if !dbClosed(fmt.Errorf("读取增量变更: %w", closedDBError(t))) { t.Error("被 %w 包过一层也要认出") } for _, err := range []error{ nil, errors.New("database is closed"), errors.New("sql: database is closed (something else)"), errors.New("sql: database is locked"), errors.New("disk I/O error"), } { if dbClosed(err) { t.Errorf("%v 不该被当成「库已关闭」", err) } } }