仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/migrate/migrate_test.go 17.1 KB · 424 行 原始文件
1package migrate
2
3import (
4 "database/sql"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 _ "modernc.org/sqlite"
11
12 "clearlove/internal/config"
13 "clearlove/internal/database"
14 "clearlove/internal/models"
15)
16
17// buildOldDB 构造一个旧版(PHP 1.x)结构与数据的 SQLite 数据库
18func buildOldDB(t *testing.T, path string) {
19 t.Helper()
20 db, err := sql.Open("sqlite", path)
21 if err != nil {
22 t.Fatalf("创建旧库失败: %v", err)
23 }
24 defer db.Close()
25 stmts := []string{
26 `CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, content TEXT NOT NULL,
27 topic TEXT, media TEXT, likes INTEGER DEFAULT 0, is_pinned INTEGER DEFAULT 0, ip_address TEXT,
28 fingerprint TEXT, status INTEGER DEFAULT 0, is_admin INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
29 `CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, nickname TEXT NOT NULL,
30 content TEXT NOT NULL, ip_address TEXT, fingerprint TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
31 `CREATE TABLE topics (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
32 `CREATE TABLE announcements (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, image TEXT,
33 active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
34 `CREATE TABLE admins (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
35 role TEXT NOT NULL DEFAULT 'admin', permissions TEXT NOT NULL DEFAULT '')`,
36 `CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
37 email TEXT, email_verified INTEGER DEFAULT 0, verification_token TEXT, email_notify INTEGER DEFAULT 1,
38 created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
39 `CREATE TABLE banned_devices (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, value TEXT NOT NULL,
40 reason TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE (type, value))`,
41 `CREATE TABLE reports (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, reason TEXT NOT NULL,
42 detail TEXT, reporter_ip TEXT, reporter_fingerprint TEXT, status INTEGER DEFAULT 0, handled_by INTEGER DEFAULT 0,
43 handled_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
44 }
45 for _, s := range stmts {
46 if _, err := db.Exec(s); err != nil {
47 t.Fatalf("旧库建表失败: %v", err)
48 }
49 }
50 seed := []string{
51 `INSERT INTO topics(name) VALUES('校园'),('日常')`,
52 // 正常帖:1 张图片、5 个赞、status=0(旧版 0 = 正常展示)
53 `INSERT INTO posts(nickname,content,topic,media,likes,ip_address,fingerprint,status,is_admin,created_at)
54 VALUES('小明','第一条帖子','校园','[{"file":"a.webp","type":"image"}]',5,'1.1.1.1','fp1',0,0,'2026-01-02 03:04:05')`,
55 // 被隐藏的帖子:status=1
56 `INSERT INTO posts(nickname,content,topic,media,likes,status,created_at)
57 VALUES('被屏蔽者','这条被隐藏了','日常','[]',0,1,'2026-01-03 03:04:05')`,
58 // 管理员帖:is_admin=1
59 `INSERT INTO posts(nickname,content,topic,media,likes,status,is_admin,created_at)
60 VALUES('官方','管理员帖','','[]',2,0,1,'2026-01-04 03:04:05')`,
61 `INSERT INTO comments(post_id,nickname,content,ip_address,fingerprint,created_at) VALUES(1,'小红','评论一','2.2.2.2','fp2','2026-01-05 03:04:05')`,
62 `INSERT INTO comments(post_id,nickname,content,created_at) VALUES(1,'小刚','评论二','2026-01-06 03:04:05')`,
63 `INSERT INTO comments(post_id,nickname,content,created_at) VALUES(999,'幽灵','孤儿评论','2026-01-07 03:04:05')`,
64 `INSERT INTO announcements(content,active,created_at) VALUES('欢迎来到表白墙','1','2026-01-08 03:04:05')`,
65 `INSERT INTO announcements(content,active,created_at) VALUES('已停用公告','0','2026-01-09 03:04:05')`,
66 `INSERT INTO users(username,password,email,created_at) VALUES('alice','$2y$10$abcdefghijklmnopqrstuv','a@b.com','2026-01-10 03:04:05')`,
67 `INSERT INTO users(username,password,email,created_at) VALUES('bob','$2y$10$abcdefghijklmnopqrstuv','','2026-01-11 03:04:05')`,
68 `INSERT INTO admins(username,password,role) VALUES('boss','$2y$10$abcdefghijklmnopqrstuv','super')`,
69 `INSERT INTO admins(username,password,role) VALUES('mod','$2y$10$abcdefghijklmnopqrstuv','admin')`,
70 `INSERT INTO banned_devices(type,value) VALUES('ip','9.9.9.9'),('fingerprint','badfp')`,
71 `INSERT INTO reports(post_id,reason,detail,reporter_ip,status) VALUES(1,'广告','发小广告','3.3.3.3',0)`,
72 `INSERT INTO reports(post_id,reason,detail,reporter_ip,status) VALUES(999,'违规','帖子已删','4.4.4.4',0)`,
73 }
74 for _, s := range seed {
75 if _, err := db.Exec(s); err != nil {
76 t.Fatalf("旧库写入测试数据失败: %v\nSQL: %s", err, s)
77 }
78 }
79}
80
81// setupNew 准备新版数据库环境
82func setupNew(t *testing.T, root string) {
83 t.Helper()
84 newDir := filepath.Join(root, "new")
85 if err := os.MkdirAll(newDir, 0o755); err != nil {
86 t.Fatal(err)
87 }
88 config.Cfg = config.Config{
89 DataDir: newDir,
90 UploadDir: filepath.Join(root, "newuploads"),
91 DBType: "sqlite",
92 SQLitePath: filepath.Join(newDir, "clearlove.db"),
93 Installed: true,
94 Secret: "test-secret",
95 }
96 if err := database.Connect(); err != nil {
97 t.Fatalf("新版数据库连接失败: %v", err)
98 }
99 // Windows 下临时目录清理前必须关闭连接,否则文件被占用
100 t.Cleanup(func() {
101 if database.DB != nil {
102 _ = database.DB.Close()
103 database.DB = nil
104 }
105 })
106 if err := database.Migrate(); err != nil {
107 t.Fatalf("新版数据库迁移失败: %v", err)
108 }
109}
110
111func TestRunMigration(t *testing.T) {
112 root := t.TempDir()
113 oldDir := filepath.Join(root, "old")
114 oldUploads := filepath.Join(oldDir, "uploads")
115 if err := os.MkdirAll(oldUploads, 0o755); err != nil {
116 t.Fatal(err)
117 }
118 if err := os.WriteFile(filepath.Join(oldUploads, "a.webp"), []byte("fake-image-bytes"), 0o644); err != nil {
119 t.Fatal(err)
120 }
121 oldDB := filepath.Join(oldDir, "data.db")
122 buildOldDB(t, oldDB)
123 setupNew(t, root)
124
125 st, err := Run(oldDB, oldUploads, false)
126 if err != nil {
127 t.Fatalf("迁移失败: %v", err)
128 }
129
130 // 统计断言
131 if st.Posts != 3 {
132 t.Errorf("帖子数 = %d, 期望 3", st.Posts)
133 }
134 if st.Comments != 2 {
135 t.Errorf("评论数 = %d, 期望 2(孤儿评论应被跳过)", st.Comments)
136 }
137 if st.Media != 1 {
138 t.Errorf("媒体数 = %d, 期望 1", st.Media)
139 }
140 if st.Likes != 7 {
141 t.Errorf("点赞补齐 = %d, 期望 7", st.Likes)
142 }
143 if st.Users != 2 {
144 t.Errorf("用户数 = %d, 期望 2", st.Users)
145 }
146 if st.Admins != 2 {
147 t.Errorf("管理员数 = %d, 期望 2", st.Admins)
148 }
149 if st.Bans != 2 {
150 t.Errorf("封禁数 = %d, 期望 2", st.Bans)
151 }
152 if st.Reports != 1 {
153 t.Errorf("举报数 = %d, 期望 1(关联帖子不存在的应跳过)", st.Reports)
154 }
155 if st.Notices != 1 {
156 t.Errorf("公告数 = %d, 期望 1(停用公告不导入)", st.Notices)
157 }
158 if st.NoPostRef != 2 {
159 t.Errorf("无效关联数 = %d, 期望 2(1 条评论 + 1 条举报)", st.NoPostRef)
160 }
161
162 db := database.DB
163 // ID 保留
164 if n := models.QueryInt("SELECT COUNT(1) FROM posts WHERE id IN (1,2,3)"); n != 3 {
165 t.Errorf("帖子 ID 未保留,命中 %d 条", n)
166 }
167 // status 取反:旧 0 -> 新 1,旧 1 -> 新 0
168 if n := models.QueryInt("SELECT status FROM posts WHERE id=1"); n != 1 {
169 t.Errorf("帖子1 status = %d, 期望 1(旧版 0 为正常)", n)
170 }
171 if n := models.QueryInt("SELECT status FROM posts WHERE id=2"); n != 0 {
172 t.Errorf("帖子2 status = %d, 期望 0(旧版 1 为隐藏)", n)
173 }
174 // 管理员帖
175 if n := models.QueryInt("SELECT is_admin FROM posts WHERE id=3"); n != 1 {
176 t.Errorf("帖子3 is_admin = %d, 期望 1", n)
177 }
178 // 点赞数与 likes 记录一致
179 if n := models.QueryInt("SELECT like_count FROM posts WHERE id=1"); n != 5 {
180 t.Errorf("帖子1 点赞数 = %d, 期望 5", n)
181 }
182 if n := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=1"); n != 5 {
183 t.Errorf("帖子1 likes 记录 = %d, 期望 5", n)
184 }
185 // 评论数重算
186 if n := models.QueryInt("SELECT comment_count FROM posts WHERE id=1"); n != 2 {
187 t.Errorf("帖子1 评论数 = %d, 期望 2", n)
188 }
189 // 话题关联
190 if n := models.QueryInt("SELECT topic_id FROM posts WHERE id=1"); n == 0 {
191 t.Error("帖子1 未关联话题")
192 }
193 if n := models.QueryInt("SELECT COUNT(1) FROM topics WHERE name='校园'"); n != 1 {
194 t.Error("话题「校园」未迁移")
195 }
196 // 媒体:文件复制 + 记录写入
197 if _, err := os.Stat(filepath.Join(config.Cfg.UploadDir, "legacy", "a.webp")); err != nil {
198 t.Errorf("媒体文件未复制: %v", err)
199 }
200 if got := models.QueryStr("SELECT path FROM medias WHERE post_id=1"); got != "/uploads/legacy/a.webp" {
201 t.Errorf("媒体路径 = %q", got)
202 }
203 // 时间格式统一为 RFC3339
204 created := models.QueryStr("SELECT created_at FROM posts WHERE id=1")
205 if _, err := sql.Open("sqlite", oldDB); err == nil {
206 if created != "2026-01-02T03:04:05Z" {
207 t.Errorf("created_at = %q, 期望 2026-01-02T03:04:05Z", created)
208 }
209 }
210 // 用户:空邮箱补齐占位
211 if got := models.QueryStr("SELECT email FROM users WHERE username='bob'"); got == "" {
212 t.Error("用户 bob 的邮箱未补齐")
213 }
214 // 密码哈希原样保留(PHP bcrypt 与 Go bcrypt 兼容)
215 if got := models.QueryStr("SELECT password FROM admins WHERE username='boss'"); got != "$2y$10$abcdefghijklmnopqrstuv" {
216 t.Errorf("管理员密码未保留: %q", got)
217 }
218 if got := models.QueryStr("SELECT role FROM admins WHERE username='boss'"); got != "super" {
219 t.Errorf("超管角色 = %q", got)
220 }
221 if got := models.QueryStr("SELECT role FROM admins WHERE username='mod'"); got != "custom" {
222 t.Errorf("普通管理员角色 = %q, 期望 custom", got)
223 }
224 // 举报 reason 合并 detail
225 if got := models.QueryStr("SELECT reason FROM reports WHERE id=1"); got != "广告:发小广告" {
226 t.Errorf("举报理由 = %q", got)
227 }
228
229 // 幂等:再次执行不应重复写入
230 st2, err := Run(oldDB, oldUploads, false)
231 if err != nil {
232 t.Fatalf("二次迁移失败: %v", err)
233 }
234 if st2.Posts != 0 || st2.Comments != 0 || st2.Users != 0 || st2.Admins != 0 {
235 t.Errorf("二次迁移不应新增数据: %+v", st2)
236 }
237 if n := models.QueryInt("SELECT COUNT(1) FROM posts"); n != 3 {
238 t.Errorf("二次迁移后帖子数 = %d, 期望 3", n)
239 }
240 if n := models.QueryInt("SELECT COUNT(1) FROM likes"); n != 7 {
241 t.Errorf("二次迁移后点赞记录 = %d, 期望 7", n)
242 }
243 _ = db
244}
245
246// TestMySQLDumpMigration 验证从 MySQL 导出文件(宝塔 / mysqldump)迁移。
247// 反引号在 Go 原始字符串里无法书写,用 « 占位后替换。
248func TestMySQLDumpMigration(t *testing.T) {
249 root := t.TempDir()
250 setupNew(t, root)
251
252 dumpRaw := `-- MySQL dump 10.13 Distrib 5.7.44, for Linux (x86_64)
253/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
254/*!40000 ALTER TABLE «posts» DISABLE KEYS */;
255
256DROP TABLE IF EXISTS «posts»;
257CREATE TABLE «posts» (
258 «id» int(11) NOT NULL AUTO_INCREMENT,
259 «nickname» varchar(50) NOT NULL,
260 «content» text NOT NULL,
261 «topic» varchar(100) DEFAULT NULL,
262 «media» text,
263 «likes» int(11) DEFAULT '0',
264 «is_pinned» tinyint(1) DEFAULT '0',
265 «ip_address» varchar(45) DEFAULT NULL,
266 «fingerprint» varchar(64) DEFAULT NULL,
267 «status» tinyint(4) DEFAULT '0',
268 «is_admin» tinyint(4) DEFAULT '0',
269 «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
270 PRIMARY KEY («id»),
271 KEY «idx_created» («created_at»)
272) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
273
274INSERT INTO «posts» VALUES (1,'小明','第一条帖\n带换行和单引号''测试','校园','[{"file":"a.webp","type":"image"}]',5,0,'1.1.1.1','fp1',0,0,'2026-01-02 03:04:05'),(2,'被屏蔽者','隐藏帖','日常',NULL,0,0,NULL,NULL,1,0,'2026-01-03 03:04:05');
275
276CREATE TABLE «comments» (
277 «id» int(11) NOT NULL AUTO_INCREMENT,
278 «post_id» int(11) NOT NULL,
279 «nickname» varchar(50) NOT NULL,
280 «content» text NOT NULL,
281 «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
282 PRIMARY KEY («id»)
283) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
284
285INSERT INTO «comments» («id», «post_id», «nickname», «content», «created_at») VALUES (1,1,'小红','评论一','2026-01-05 03:04:05'),(2,999,'幽灵','孤儿评论','2026-01-07 03:04:05');
286
287CREATE TABLE «announcements» (
288 «id» int(11) NOT NULL AUTO_INCREMENT,
289 «content» text NOT NULL,
290 «active» tinyint(1) DEFAULT '1',
291 «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
292 PRIMARY KEY («id»)
293) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
294
295INSERT INTO «announcements» VALUES (1,'欢迎来到表白墙',1,'2026-01-08 03:04:05'),(2,'已停用公告',0,'2026-01-09 03:04:05');
296
297CREATE TABLE «users» (
298 «id» int(11) NOT NULL AUTO_INCREMENT,
299 «username» varchar(50) NOT NULL,
300 «password» varchar(255) NOT NULL,
301 «email» varchar(100) DEFAULT NULL,
302 «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
303 PRIMARY KEY («id»)
304) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
305
306INSERT INTO «users» VALUES (1,'alice','$2y$10$abcdefghijklmnopqrstuv','a@b.com','2026-01-10 03:04:05'),(2,'bob','$2y$10$abcdefghijklmnopqrstuv',NULL,'2026-01-11 03:04:05');
307
308CREATE TABLE «admins» (
309 «id» int(11) NOT NULL AUTO_INCREMENT,
310 «username» varchar(50) NOT NULL,
311 «password» varchar(255) NOT NULL,
312 «role» varchar(10) NOT NULL DEFAULT 'admin',
313 PRIMARY KEY («id»)
314) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
315
316INSERT INTO «admins» VALUES (1,'boss','$2y$10$abcdefghijklmnopqrstuv','super'),(2,'mod','$2y$10$abcdefghijklmnopqrstuv','admin');
317
318CREATE TABLE «banned_devices» (
319 «id» int(11) NOT NULL AUTO_INCREMENT,
320 «type» varchar(15) NOT NULL,
321 «value» varchar(64) NOT NULL,
322 PRIMARY KEY («id»)
323) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
324
325INSERT INTO «banned_devices» VALUES (1,'ip','9.9.9.9'),(2,'fingerprint','badfp');
326
327CREATE TABLE «reports» (
328 «id» int(11) NOT NULL AUTO_INCREMENT,
329 «post_id» int(11) NOT NULL,
330 «reason» varchar(50) NOT NULL,
331 «detail» text,
332 «reporter_ip» varchar(45) DEFAULT NULL,
333 «status» tinyint(4) DEFAULT '0',
334 «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
335 PRIMARY KEY («id»)
336) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
337
338INSERT INTO «reports» VALUES (1,1,'广告','发小广告','3.3.3.3',0,'2026-01-12 03:04:05'),(2,1,'重复','重复贴','5.5.5.5',2,'2026-01-13 03:04:05'),(3,999,'违规','帖子已删','4.4.4.4',0,'2026-01-14 03:04:05');
339
340/*!40000 ALTER TABLE «posts» ENABLE KEYS */;
341-- Dump completed on 2026-09-13 0:00:00
342`
343 dump := strings.NewReplacer("«", "`", "»", "`").Replace(dumpRaw)
344
345 oldDir := filepath.Join(root, "old")
346 oldUploads := filepath.Join(oldDir, "uploads")
347 if err := os.MkdirAll(oldUploads, 0o755); err != nil {
348 t.Fatal(err)
349 }
350 if err := os.WriteFile(filepath.Join(oldUploads, "a.webp"), []byte("img"), 0o644); err != nil {
351 t.Fatal(err)
352 }
353 dumpPath := filepath.Join(oldDir, "clearlove.sql")
354 if err := os.WriteFile(dumpPath, []byte(dump), 0o644); err != nil {
355 t.Fatal(err)
356 }
357
358 st, err := Run(dumpPath, oldUploads, false)
359 if err != nil {
360 t.Fatalf("从 MySQL 导出文件迁移失败: %v", err)
361 }
362 if st.Source != "mysqldump" {
363 t.Errorf("数据源识别为 %q, 期望 mysqldump", st.Source)
364 }
365 if st.Posts != 2 || st.Comments != 1 || st.Media != 1 || st.Likes != 5 {
366 t.Errorf("统计异常: 帖子=%d 评论=%d 媒体=%d 点赞=%d", st.Posts, st.Comments, st.Media, st.Likes)
367 }
368 if st.Users != 2 || st.Admins != 2 || st.Bans != 2 || st.Reports != 2 || st.Notices != 1 {
369 t.Errorf("统计异常: 用户=%d 管理员=%d 封禁=%d 举报=%d 公告=%d",
370 st.Users, st.Admins, st.Bans, st.Reports, st.Notices)
371 }
372 if st.NoPostRef != 2 {
373 t.Errorf("无效关联数 = %d, 期望 2(1 条孤儿评论 + 1 条失效举报)", st.NoPostRef)
374 }
375 // 转义与引号还原
376 content := models.QueryStr("SELECT content FROM posts WHERE id=1")
377 if content != "第一条帖\n带换行和单引号'测试" {
378 t.Errorf("转义解析异常: %q", content)
379 }
380 // 状态取反
381 if n := models.QueryInt("SELECT status FROM posts WHERE id=2"); n != 0 {
382 t.Errorf("隐藏帖 status = %d, 期望 0", n)
383 }
384 // 无列名列表的 INSERT 也能按建表顺序解析(posts 表即为此种写法)
385 if n := models.QueryInt("SELECT like_count FROM posts WHERE id=1"); n != 5 {
386 t.Errorf("点赞数 = %d, 期望 5", n)
387 }
388 // 邮箱为空时补齐占位
389 if got := models.QueryStr("SELECT email FROM users WHERE username='bob'"); got == "" {
390 t.Error("bob 的邮箱未补齐")
391 }
392 // 举报状态归一
393 if n := models.QueryInt("SELECT status FROM reports WHERE id=2"); n != 1 {
394 t.Errorf("举报2 状态 = %d, 期望 1(旧版 2 表示已处理)", n)
395 }
396}
397
398// TestRunDryRun 试运行不应写入任何数据
399func TestRunDryRun(t *testing.T) {
400 root := t.TempDir()
401 oldDir := filepath.Join(root, "old")
402 oldUploads := filepath.Join(oldDir, "uploads")
403 if err := os.MkdirAll(oldUploads, 0o755); err != nil {
404 t.Fatal(err)
405 }
406 oldDB := filepath.Join(oldDir, "data.db")
407 buildOldDB(t, oldDB)
408 setupNew(t, root)
409
410 st, err := Run(oldDB, oldUploads, true)
411 if err != nil {
412 t.Fatalf("试运行失败: %v", err)
413 }
414 if st.Posts == 0 {
415 t.Error("试运行应统计出帖子数量")
416 }
417 // 试运行不会真正写入帖子,评论/举报的关联判定不能因此失效
418 if st.Comments == 0 || st.Reports == 0 {
419 t.Errorf("试运行应统计出评论与举报: 评论=%d 举报=%d", st.Comments, st.Reports)
420 }
421 if n := models.QueryInt("SELECT COUNT(1) FROM posts"); n != 0 {
422 t.Errorf("试运行写入了 %d 条帖子", n)
423 }
424}