package event import ( "database/sql" "fmt" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // 0024:联队 + 份额分成(entry_team_shares)。 // // 三条底线: // - 老库升级后每条既有报名都拿到一条「主单位独占 100%」(权重 1、整枚奖牌), // 于是引擎与榜单对老数据的行为一分不差; // - 其它行(尤其是 entries.team_id 这个主单位)一行不动,也没有任何报名被删; // - 幂等:重复执行(含把这条 SQL 的正文重放一遍)不会覆盖已经配好的联队份额。 func TestMigration0024EntryTeamShares(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0024") // 回到「升级前」:库结构到 0023 为止 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, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 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 非空)与团体报名(athlete_id 为空)都要被回填。 `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 2, NULL, 'registered')`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } // 升级前:这张表还不该存在。 if _, err := db.Exec(`SELECT count(*) FROM entry_team_shares`); err == nil { t.Fatal("升级前不应存在 entry_team_shares 表") } else if !strings.Contains(err.Error(), "entry_team_shares") { t.Fatalf("查询报错应点明缺表 entry_team_shares,实际 %v", err) } if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级赛事库: %v", err) } // 回填:每条报名一条「主单位独占 100%」,主单位就是 entries.team_id。 rows, err := db.Query(`SELECT e.id, e.team_id, s.team_id, s.points_share, s.medal_share, s.sort_order FROM entries e JOIN entry_team_shares s ON s.entry_id = e.id ORDER BY e.id`) if err != nil { t.Fatalf("读回填结果: %v", err) } defer func() { _ = rows.Close() }() seen := 0 for rows.Next() { var ( entryID, primary, shareTeam int64 pointsShare float64 medalShare, sortOrder int ) if err := rows.Scan(&entryID, &primary, &shareTeam, &pointsShare, &medalShare, &sortOrder); err != nil { t.Fatalf("扫描回填结果: %v", err) } if shareTeam != primary { t.Errorf("报名 %d 回填出来的份额单位 = %d,期望主单位 %d", entryID, shareTeam, primary) } if pointsShare != 1 || medalShare != 1 || sortOrder != 0 { t.Errorf("报名 %d 的独占份额应为 points_share=1 medal_share=1 sort_order=0,得到 %v/%d/%d", entryID, pointsShare, medalShare, sortOrder) } seen++ } if err := rows.Err(); err != nil { t.Fatalf("遍历回填结果: %v", err) } if seen != 2 { t.Fatalf("回填行数 = %d,期望 2(每条既有报名一条)", seen) } // entries 一行不少、主单位一列不改(回填只增一张表)。 if got := countRows(t, db, "entries"); got != 2 { t.Fatalf("升级后报名数 = %d,期望 2", got) } if got := queryInt(t, db, `SELECT count(*) FROM entries WHERE team_id NOT IN (SELECT id FROM teams)`); got != 0 { t.Errorf("条目里出现了不存在的单位: %d 条", got) } // 约束:权重必须为正、奖牌份数只有 0/1、同一报名同一单位只能一条。 if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share) VALUES (1, 2, 0)`); err == nil { t.Error("points_share = 0 应被 CHECK 拒绝(份额为零等于没写)") } if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, medal_share) VALUES (1, 2, 2)`); err == nil { t.Error("medal_share = 2 应被 CHECK 拒绝(奖牌只有整枚/不记两态)") } if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id) VALUES (1, 1)`); err == nil { t.Error("同一报名同一单位重复应被主键拒绝") } // 联队:给报名 1 再加一条成员单位 B(这就是升级后写侧要写的东西)。 if _, err := db.Exec(`INSERT INTO entry_team_shares(entry_id, team_id, points_share, medal_share, sort_order) VALUES (1, 2, 1, 1, 1)`); err != nil { t.Fatalf("写联队份额应成功: %v", err) } // 幂等:再跑一次迁移,行数与内容都不变。 before := dumpShares(t, db) if err := store.MigrateEvent(db); err != nil { t.Fatalf("重复升级应无副作用: %v", err) } if got := dumpShares(t, db); got != before { t.Fatalf("重复迁移改动了份额:\n之前 %s\n之后 %s", before, got) } // 迁移被重放(schema_migrations 少一行)时:只补缺的行,绝不覆盖已配好的联队份额。 if _, err := db.Exec(readMigration(t, "0024")); err != nil { t.Fatalf("重放 0024: %v", err) } if got := dumpShares(t, db); got != before { t.Fatalf("重放 0024 改动了份额:\n之前 %s\n之后 %s", before, got) } // 报名被删时份额跟着走(ON DELETE CASCADE),不留孤儿。 if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil { t.Fatalf("删报名: %v", err) } if got := queryInt(t, db, `SELECT count(*) FROM entry_team_shares WHERE entry_id = 2`); got != 0 { t.Errorf("报名删掉后份额应级联清空,还剩 %d 条", 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) } } // dumpShares 把份额表按稳定顺序导成文本。 func dumpShares(t *testing.T, db *sql.DB) string { t.Helper() var b strings.Builder rows, err := db.Query(`SELECT entry_id, team_id, points_share, medal_share, sort_order FROM entry_team_shares ORDER BY entry_id, team_id`) if err != nil { t.Fatalf("读份额: %v", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( entryID, teamID int64 pointsShare float64 medalShare, sortOrder int ) if err := rows.Scan(&entryID, &teamID, &pointsShare, &medalShare, &sortOrder); err != nil { t.Fatalf("扫描份额: %v", err) } fmt.Fprintf(&b, "%d:%d:%v:%d:%d\n", entryID, teamID, pointsShare, medalShare, sortOrder) } if err := rows.Err(); err != nil { t.Fatalf("遍历份额: %v", err) } return b.String() }