package export import ( "archive/zip" "errors" "os" "path/filepath" "strings" "testing" "time" "gopkg.in/yaml.v3" ) func TestExportImportProjectZip(t *testing.T) { db := openEventDB(t) seedEvent(t, db) uploads := t.TempDir() if err := os.WriteFile(filepath.Join(uploads, "logo.txt"), []byte("logo"), 0o644); err != nil { t.Fatalf("写 uploads: %v", err) } rules := filepath.Join(t.TempDir(), "rules.yaml") if err := os.WriteFile(rules, []byte("format: scoreboard-rules\nformatVersion: 1\n"), 0o644); err != nil { t.Fatalf("写 rules.yaml: %v", err) } zipPath := filepath.Join(t.TempDir(), "赛事包.zip") meta, err := ExportProjectZip(db, zipPath, ProjectExportOptions{ Name: "测试赛事", UploadsDir: uploads, RulesPath: rules, CreatedBy: "tester", Now: time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local), }) if err != nil { t.Fatalf("导出赛事包: %v", err) } if meta.Format != projectFormat || meta.FormatVersion != projectFormatVersion { t.Fatalf("meta 格式 = %s/%d", meta.Format, meta.FormatVersion) } if meta.EventName != "测试运动会" { t.Errorf("meta.eventName = %q,期望 测试运动会", meta.EventName) } for _, want := range []string{"event.db", "uploads/logo.txt", "rules.yaml", "meta.yaml"} { if !zipHas(zipPath, t, want) { t.Errorf("包内缺少 %s", want) } } got, err := InspectProjectZip(zipPath) if err != nil { t.Fatalf("预览赛事包: %v", err) } if got.Name != "测试赛事" || got.Database.Path != "event.db" || got.Database.SHA256 == "" { t.Fatalf("预览结果异常: %+v", got) } root := t.TempDir() res, err := ImportProjectZip(zipPath, root, ImportOptions{}) if err != nil { t.Fatalf("导入赛事包: %v", err) } if res.Dir != filepath.Join(root, "测试赛事") { t.Errorf("导入目录 = %q", res.Dir) } if _, err := os.Stat(res.EventDB); err != nil { t.Fatalf("导入后 event.db 不存在: %v", err) } if _, err := os.Stat(filepath.Join(res.Dir, "uploads", "logo.txt")); err != nil { t.Errorf("导入后 uploads 资源缺失: %v", err) } assertTeams(t, res.EventDB, 2) if _, err := ImportProjectZip(zipPath, root, ImportOptions{}); !errors.Is(err, ErrProjectExists) { t.Fatalf("同名导入应报 ErrProjectExists,实际 %v", err) } if _, err := ImportProjectZip(zipPath, root, ImportOptions{Overwrite: true}); err != nil { t.Fatalf("覆盖导入: %v", err) } assertTeams(t, filepath.Join(root, "测试赛事", "event.db"), 2) renamed, err := ImportProjectZip(zipPath, root, ImportOptions{Rename: "另一场赛事"}) if err != nil { t.Fatalf("改名导入: %v", err) } if renamed.Name != "另一场赛事" { t.Errorf("改名导入结果 = %q", renamed.Name) } } // 字体必须落在包内 uploads/fonts/:导入后它就在运行时的字体目录下,大屏的 @font-face 才找得到。 // 这里只给 FontsDir、不给 UploadsDir,顺带验证兜底路径本身是通的。 func TestExportProjectZipIncludesFontsFallback(t *testing.T) { db := openEventDB(t) seedEvent(t, db) fonts := t.TempDir() writeFile(t, filepath.Join(fonts, "思源宋体.ttf"), "font-bytes") zipPath := filepath.Join(t.TempDir(), "赛事包.zip") if _, err := ExportProjectZip(db, zipPath, ProjectExportOptions{ Name: "字体赛事", FontsDir: fonts, IncludeFonts: true, }); err != nil { t.Fatalf("导出赛事包: %v", err) } if !zipHas(zipPath, t, "uploads/fonts/思源宋体.ttf") { t.Fatal("包内缺少 uploads/fonts/思源宋体.ttf") } res, err := ImportProjectZip(zipPath, t.TempDir(), ImportOptions{}) if err != nil { t.Fatalf("导入赛事包: %v", err) } if got := readFile(t, filepath.Join(res.Dir, "uploads", "fonts", "思源宋体.ttf")); got != "font-bytes" { t.Errorf("导入后字体内容 = %q", got) } } func TestImportRejectsBadPackages(t *testing.T) { base := ProjectMeta{ Format: projectFormat, FormatVersion: projectFormatVersion, Name: "坏包", Database: ProjectFile{Path: "event.db", SHA256: strings.Repeat("0", 64)}, Files: []ProjectFile{{Path: "event.db", SHA256: strings.Repeat("0", 64)}}, } t.Run("校验和不匹配", func(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "bad.zip") writeZip(t, p, map[string]string{ "meta.yaml": marshalMeta(t, base), "event.db": "not a database", }) _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) if !errors.Is(err, ErrChecksumMismatch) { t.Fatalf("期望 ErrChecksumMismatch,实际 %v", err) } }) t.Run("版本过新", func(t *testing.T) { meta := base meta.FormatVersion = 99 dir := t.TempDir() p := filepath.Join(dir, "bad.zip") writeZip(t, p, map[string]string{"meta.yaml": marshalMeta(t, meta), "event.db": "x"}) _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) if !errors.Is(err, ErrUnsupportedVersion) { t.Fatalf("期望 ErrUnsupportedVersion,实际 %v", err) } }) t.Run("路径穿越", func(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "bad.zip") writeZip(t, p, map[string]string{ "meta.yaml": marshalMeta(t, base), "../evil.txt": "oops", "event.db": "x", }) _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) if !errors.Is(err, ErrInvalidPackage) { t.Fatalf("期望 ErrInvalidPackage,实际 %v", err) } }) t.Run("缺 meta.yaml", func(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "bad.zip") writeZip(t, p, map[string]string{"event.db": "x"}) if _, err := InspectProjectZip(p); !errors.Is(err, ErrInvalidPackage) { t.Fatalf("期望 ErrInvalidPackage,实际 %v", err) } }) } // 覆盖导入时第二次改名失败,旧项目必须回滚回来,不能出现「旧项目已删、新项目没成」。 func TestSwapProjectDirRollback(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "赛事") src := filepath.Join(root, ".import-1") writeFile(t, filepath.Join(target, "old.txt"), "旧") writeFile(t, filepath.Join(src, "new.txt"), "新") calls := 0 rename := func(old, new string) error { calls++ if calls == 2 { return errors.New("模拟第二次改名失败") } return os.Rename(old, new) } if err := swapProjectDir(src, target, true, rename); err == nil { t.Fatal("第二次改名失败应返回错误") } if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" { t.Errorf("回滚后旧项目内容 = %q,期望 旧", got) } if got := readFile(t, filepath.Join(src, "new.txt")); got != "新" { t.Errorf("新项目内容 = %q,期望 新", got) } entries, err := os.ReadDir(root) if err != nil { t.Fatalf("读取项目根目录: %v", err) } for _, e := range entries { if strings.Contains(e.Name(), ".replaced-") { t.Errorf("回滚后残留暂存目录 %s", e.Name()) } } } // 不允许覆盖时必须原地报错,不能动旧项目。 func TestSwapProjectDirNoOverwrite(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "赛事") src := filepath.Join(root, ".import-1") writeFile(t, filepath.Join(target, "old.txt"), "旧") writeFile(t, filepath.Join(src, "new.txt"), "新") if err := swapProjectDir(src, target, false, os.Rename); !errors.Is(err, ErrProjectExists) { t.Fatalf("期望 ErrProjectExists,实际 %v", err) } if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" { t.Errorf("拒绝覆盖后旧项目内容 = %q,期望 旧", got) } } // 覆盖成功后旧目录必须清理干净,不留 .replaced- 残留。 func TestSwapProjectDirOverwriteCleansStale(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "赛事") src := filepath.Join(root, ".import-1") writeFile(t, filepath.Join(target, "old.txt"), "旧") writeFile(t, filepath.Join(src, "new.txt"), "新") if err := swapProjectDir(src, target, true, os.Rename); err != nil { t.Fatalf("覆盖导入: %v", err) } if got := readFile(t, filepath.Join(target, "new.txt")); got != "新" { t.Errorf("覆盖后新项目内容 = %q,期望 新", got) } entries, err := os.ReadDir(root) if err != nil { t.Fatalf("读取项目根目录: %v", err) } for _, e := range entries { if strings.Contains(e.Name(), ".replaced-") { t.Errorf("覆盖成功后残留暂存目录 %s", e.Name()) } } } // 解压总体积上限:超过上限的包必须拒绝,不能把磁盘写满。 func TestExtractZipRejectsOversize(t *testing.T) { old := maxExtractBytes maxExtractBytes = 64 t.Cleanup(func() { maxExtractBytes = old }) p := filepath.Join(t.TempDir(), "big.zip") writeZip(t, p, map[string]string{"event.db": strings.Repeat("x", 200)}) if err := extractZip(p, t.TempDir()); !errors.Is(err, ErrInvalidPackage) { t.Fatalf("期望 ErrInvalidPackage,实际 %v", err) } } func TestSafeProjectName(t *testing.T) { bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y", "赛事.", "a.."} for _, name := range bad { if _, err := SafeProjectName(name); err == nil { t.Errorf("SafeProjectName(%q) 应报错", name) } } for _, name := range []string{"2026 运动会", "test-event_1", "测试赛事"} { if _, err := SafeProjectName(name); err != nil { t.Errorf("SafeProjectName(%q) 不应报错: %v", name, err) } } } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("创建目录 %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("写入 %s: %v", path, err) } } func readFile(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("读取 %s: %v", path, err) } return string(raw) } func assertTeams(t *testing.T, path string, want int) { t.Helper() db := openSQLite(t, path) defer func() { _ = db.Close() }() var n int if err := db.QueryRow(`SELECT count(*) FROM teams`).Scan(&n); err != nil { t.Fatalf("查询 %s 的 teams: %v", path, err) } if n != want { t.Errorf("%s 的 teams 数 = %d,期望 %d", path, n, want) } } func zipHas(path string, t *testing.T, name string) bool { t.Helper() r, err := zip.OpenReader(path) if err != nil { t.Fatalf("打开 zip: %v", err) } defer func() { _ = r.Close() }() for _, f := range r.File { if f.Name == name { return true } } return false } func writeZip(t *testing.T, path string, files map[string]string) { t.Helper() f, err := os.Create(path) if err != nil { t.Fatalf("创建 zip: %v", err) } zw := zip.NewWriter(f) for name, body := range files { w, err := zw.Create(name) if err != nil { t.Fatalf("创建条目 %s: %v", name, err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatalf("写入条目 %s: %v", name, err) } } if err := zw.Close(); err != nil { t.Fatalf("关闭 zip: %v", err) } if err := f.Close(); err != nil { t.Fatalf("关闭文件: %v", err) } } func marshalMeta(t *testing.T, meta ProjectMeta) string { t.Helper() raw, err := yaml.Marshal(&meta) if err != nil { t.Fatalf("序列化 meta: %v", err) } return string(raw) }