package event import ( "database/sql" "fmt" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // 0025:团体项目的「队伍」(entries.squad)。 // // 三条底线: // - 老数据零变化:升级后既有报名的 squad 一律是空串,一个单位在一个组别仍然只能有一条 // 纯团体报名(唯一索引换成带 squad 的版本后,空串这一档的约束与升级前**等价**); // - 只给纯团体报名放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,同一个人在同一组别 // 不会因为这次改动多报一次(表级 UNIQUE + 新的部分索引都不覆盖 athlete_id 非空的行); // - 幂等:重复执行迁移不报错、不改动任何一行。 func TestMigration0025EntrySquad(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0025") // 回到「升级前」:库结构到 0024 为止 stmts := []string{ `INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`, `INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, `INSERT INTO athletes(name, gender, team_id) VALUES ('张伟', '男', 1)`, // 团体报名(athlete_id 为空)与个人报名各一条,升级后都要一字不动。 `INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status) VALUES (1, 1, 1, NULL, '["张伟","李强"]', 'registered')`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, 1, 'registered')`, } // 升级前:老索引拦住第二个队 —— 同一个单位在同一个组别只能有一条团体报名。 for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, NULL, 'registered')`); err == nil { t.Fatal("升级前同一个单位的第二条团体报名应被唯一索引拦住") } if _, err := db.Exec(`SELECT squad FROM entries`); err == nil { t.Fatal("升级前不该有 squad 列") } else if !strings.Contains(err.Error(), "squad") { t.Fatalf("查询报错应点明缺列 squad,实际 %v", err) } if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级赛事库: %v", err) } // 老数据零变化:既有报名一条不少、squad 一律空串。 if got := countRows(t, db, "entries"); got != 2 { t.Fatalf("升级后报名数 = %d,期望 2", got) } if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad <> ''`); got != 0 { t.Errorf("老数据的 squad 必须是空串(不区分队伍),得到 %d 条非空", got) } if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id = 1 AND squad <> ''`); got != 0 { t.Errorf("老数据的 squad 必须全部是空串,得到 %d 条非空", got) } // 老索引没了,换成带 squad 的那条(部分索引:只管 athlete_id 为空的行)。 if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_unique'`); got != 0 { t.Errorf("老索引 idx_entries_team_unique 应已被删除,还剩 %d 条", got) } if got := queryInt(t, db, `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_team_squad_unique'`); got != 1 { t.Errorf("应存在新索引 idx_entries_team_squad_unique,得到 %d 条", got) } // 空串这一档的约束与升级前**等价**:同一单位再加一条不分队的团体报名仍然被拒绝。 if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, NULL, 'registered')`); err == nil { t.Error("同一个单位同一个组别的第二条「不分队」团体报名应被拒绝(老口径不变)") } // 另一个单位的不分队报名照旧合法。 if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, NULL, 'registered')`); err != nil { t.Fatalf("另一个单位的团体报名应合法: %v", err) } // 这就是这次改动要放开的事:同一个单位派两个队。 if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) VALUES (1, 1, 1, NULL, 'registered', '一队')`); err != nil { t.Fatalf("同一个单位的第二个队应能报名: %v", err) } if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) VALUES (1, 1, 1, NULL, 'registered', '二队')`); err != nil { t.Fatalf("同一个单位的第三个队应能报名: %v", err) } // 同一个单位的同一个队仍然只能一条。 if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) VALUES (1, 1, 1, NULL, 'registered', '一队')`); err == nil { t.Error("同一个单位同一个组别的同一个队重复应被唯一索引拒绝") } // 带运动员的报名不受影响:同一个人换个单位也是同一个人,仍按 (组别, 单位, 运动员) 唯一。 if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) VALUES (1, 1, 2, 1, 'registered', '一队')`); err == nil { t.Error("同一个人在同一组别同一单位再报一次应被表级 UNIQUE 拒绝(squad 不参与个人报名)") } if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status, squad) VALUES (1, 1, 1, 1, 'registered', '一队')`); err != nil { t.Fatalf("同一个人在另一个单位报名应合法: %v", err) } // squad 是 NOT NULL DEFAULT '':老代码(或漏写这一列的语句)插入后仍是空串,不会变成 NULL。 if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('C', '第三分公司')`); err != nil { t.Fatalf("补一个单位: %v", err) } if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 3, NULL, 'registered')`); err != nil { t.Fatalf("漏写 squad 的插入应照常成功(默认空串): %v", err) } if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE squad IS NULL`); got != 0 { t.Errorf("squad 不允许为 NULL,得到 %d 条", got) } // 幂等:再跑一次迁移,行数与内容都不变(这条 SQL 里 ADD COLUMN 不可重放,靠记账跳过)。 before := dumpEntriesSquad(t, db) if err := store.MigrateEvent(db); err != nil { t.Fatalf("重复升级应无副作用: %v", err) } if got := dumpEntriesSquad(t, db); got != before { t.Fatalf("重复迁移改动了报名:\n之前 %s\n之后 %s", before, got) } // 外键悬挂检查。 fkRows, err := db.Query(`PRAGMA foreign_key_check`) if err != nil { t.Fatalf("外键检查: %v", err) } defer func() { _ = fkRows.Close() }() if fkRows.Next() { t.Error("迁移后存在外键悬挂") } // 迁移登记数对得上(这条迁移被记账一次)。 if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { t.Errorf("迁移登记数 = %d,期望 %d", got, want) } } // dumpEntriesSquad 把报名按稳定顺序导成文本(含 upgrade 后新增的队伍)。 func dumpEntriesSquad(t *testing.T, db *sql.DB) string { t.Helper() var b strings.Builder rows, err := db.Query(`SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(status, ''), squad FROM entries ORDER BY id`) if err != nil { t.Fatalf("读报名: %v", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( id, teamID, athleteID int64 status, squad string ) if err := rows.Scan(&id, &teamID, &athleteID, &status, &squad); err != nil { t.Fatalf("扫描报名: %v", err) } fmt.Fprintf(&b, "%d:%d:%d:%s:%s\n", id, teamID, athleteID, status, squad) } if err := rows.Err(); err != nil { t.Fatalf("遍历报名: %v", err) } return b.String() }