DatabaseMigrationRunnerTest.java 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. package com.emoon.okr.config;
  2. import com.emoon.okr.service.DatabaseBackupService;
  3. import org.junit.jupiter.api.BeforeEach;
  4. import org.junit.jupiter.api.Test;
  5. import org.junit.jupiter.api.io.TempDir;
  6. import org.springframework.jdbc.core.JdbcTemplate;
  7. import org.springframework.jdbc.datasource.DataSourceTransactionManager;
  8. import org.springframework.jdbc.datasource.SingleConnectionDataSource;
  9. import org.springframework.transaction.support.TransactionTemplate;
  10. import java.nio.file.Files;
  11. import java.nio.file.Path;
  12. import static org.junit.jupiter.api.Assertions.*;
  13. class DatabaseMigrationRunnerTest {
  14. private JdbcTemplate jdbcTemplate;
  15. private TransactionTemplate transactionTemplate;
  16. private DatabaseBackupService backupService;
  17. private Path tempDir;
  18. @BeforeEach
  19. void setUp(@TempDir Path tempDir) throws Exception {
  20. this.tempDir = tempDir;
  21. // Create temp db file for backup service to find
  22. Path dbFile = tempDir.resolve("test.db");
  23. Files.createFile(dbFile);
  24. SingleConnectionDataSource dataSource = new SingleConnectionDataSource();
  25. dataSource.setDriverClassName("org.sqlite.JDBC");
  26. dataSource.setUrl("jdbc:sqlite::memory:");
  27. dataSource.setSuppressClose(true);
  28. jdbcTemplate = new JdbcTemplate(dataSource);
  29. DataSourceTransactionManager txManager = new DataSourceTransactionManager(dataSource);
  30. transactionTemplate = new TransactionTemplate(txManager);
  31. backupService = new DatabaseBackupService(
  32. "jdbc:sqlite:" + dbFile.toString(),
  33. tempDir.resolve("backups").toString());
  34. }
  35. @Test
  36. void runAppliesVersionedMigrationsOnlyOnce() {
  37. createBaseTables();
  38. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  39. runner.run();
  40. runner.run();
  41. assertColumnExists(jdbcTemplate, "sys_user", "status");
  42. assertColumnExists(jdbcTemplate, "performance_score", "published_at");
  43. assertTableExists(jdbcTemplate, "period_participant");
  44. assertEquals(1, jdbcTemplate.queryForObject(
  45. "SELECT COUNT(*) FROM schema_migration WHERE version = 1", Integer.class));
  46. }
  47. @Test
  48. void migrationAddsPeriodStageDatesAndParticipantReportingSnapshot() {
  49. createBaseTables();
  50. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  51. runner.run();
  52. assertColumnExists(jdbcTemplate, "assessment_period", "okr_due_date");
  53. assertColumnExists(jdbcTemplate, "assessment_period", "self_review_due_date");
  54. assertColumnExists(jdbcTemplate, "period_participant", "department_id_snapshot");
  55. assertColumnExists(jdbcTemplate, "period_participant", "superior_id_snapshot");
  56. assertColumnExists(jdbcTemplate, "period_participant", "position_snapshot");
  57. assertColumnExists(jdbcTemplate, "period_participant", "role_snapshot");
  58. assertColumnExists(jdbcTemplate, "period_participant", "evaluator_id");
  59. assertEquals(1, jdbcTemplate.queryForObject(
  60. "SELECT COUNT(*) FROM schema_migration WHERE version = 5", Integer.class));
  61. }
  62. @Test
  63. void migrationAddsPeerReviewTablesAndDeadlines() {
  64. createBaseTables();
  65. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  66. runner.run();
  67. runner.run();
  68. assertColumnExists(jdbcTemplate, "assessment_period", "peer_invitation_deadline");
  69. assertColumnExists(jdbcTemplate, "assessment_period", "peer_review_deadline");
  70. assertTableExists(jdbcTemplate, "peer_review_request");
  71. assertTableExists(jdbcTemplate, "peer_review_invitation");
  72. assertEquals(1, jdbcTemplate.queryForObject(
  73. "SELECT COUNT(*) FROM schema_migration WHERE version = 13", Integer.class));
  74. }
  75. @Test
  76. void migrationAddsPeriodDimensionSnapshotAndExplicitTemplateAssignments() {
  77. createBaseTables();
  78. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  79. runner.run();
  80. assertTableExists(jdbcTemplate, "period_dimension");
  81. assertTableExists(jdbcTemplate, "kpi_template_assignment");
  82. assertColumnExists(jdbcTemplate, "assessment_period", "company_owner_user_id");
  83. assertEquals(1, jdbcTemplate.queryForObject(
  84. "SELECT COUNT(*) FROM schema_migration WHERE version = 8", Integer.class));
  85. }
  86. @Test
  87. void migrationNormalizesLegacyPerformanceTemplatesToFortyPoints() throws Exception {
  88. createBaseTables();
  89. jdbcTemplate.execute("""
  90. CREATE TABLE kpi_template (
  91. id INTEGER PRIMARY KEY AUTOINCREMENT,
  92. name VARCHAR(128) NOT NULL,
  93. category VARCHAR(64),
  94. items_json TEXT,
  95. deleted INTEGER NOT NULL DEFAULT 0
  96. )
  97. """);
  98. jdbcTemplate.update("INSERT INTO kpi_template(name, items_json) VALUES (?, ?)",
  99. "研发工程师", "[{\"name\":\"交付\",\"maxScore\":20},{\"name\":\"质量\",\"maxScore\":15},{\"name\":\"协作\",\"maxScore\":10},{\"name\":\"成长\",\"maxScore\":10},{\"name\":\"创新\",\"maxScore\":5}]");
  100. new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService).run();
  101. String json = jdbcTemplate.queryForObject("SELECT items_json FROM kpi_template WHERE id = 1", String.class);
  102. var items = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
  103. int total = 0;
  104. for (var item : items) total += item.get("maxScore").asInt();
  105. assertEquals(40, total);
  106. assertEquals(1, jdbcTemplate.queryForObject(
  107. "SELECT COUNT(*) FROM schema_migration WHERE version = 9", Integer.class));
  108. }
  109. @Test
  110. void migrationBackfillsLegacyParticipantEvaluatorAndExemptsUsersWithoutSuperior() {
  111. createBaseTables();
  112. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN superior_id INTEGER");
  113. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN department_id INTEGER");
  114. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN position VARCHAR(128)");
  115. jdbcTemplate.update("INSERT INTO sys_user(id,username,password_hash,real_name,role,superior_id,created_at,updated_at) " +
  116. "VALUES (1,'manager','x','上级','EMPLOYEE',NULL,datetime('now'),datetime('now'))");
  117. jdbcTemplate.update("INSERT INTO sys_user(id,username,password_hash,real_name,role,superior_id,department_id,position,created_at,updated_at) " +
  118. "VALUES (2,'employee','x','员工','EMPLOYEE',1,3,'后端开发工程师',datetime('now'),datetime('now'))");
  119. jdbcTemplate.execute("""
  120. CREATE TABLE period_participant (
  121. id INTEGER PRIMARY KEY AUTOINCREMENT,
  122. period_id INTEGER NOT NULL,
  123. user_id INTEGER NOT NULL,
  124. status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
  125. reason TEXT,
  126. operated_by INTEGER,
  127. exempted_at TEXT,
  128. created_at TEXT NOT NULL,
  129. updated_at TEXT NOT NULL
  130. )
  131. """);
  132. jdbcTemplate.update("INSERT INTO period_participant(period_id,user_id,status,created_at,updated_at) " +
  133. "VALUES (1,1,'ACTIVE',datetime('now'),datetime('now'))");
  134. jdbcTemplate.update("INSERT INTO period_participant(period_id,user_id,status,created_at,updated_at) " +
  135. "VALUES (1,2,'ACTIVE',datetime('now'),datetime('now'))");
  136. new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService).run();
  137. assertEquals(1L, jdbcTemplate.queryForObject(
  138. "SELECT evaluator_id FROM period_participant WHERE user_id = 2", Long.class));
  139. assertEquals("ACTIVE", jdbcTemplate.queryForObject(
  140. "SELECT status FROM period_participant WHERE user_id = 2", String.class));
  141. assertEquals("后端开发工程师", jdbcTemplate.queryForObject(
  142. "SELECT position_snapshot FROM period_participant WHERE user_id = 2", String.class));
  143. assertEquals(3L, jdbcTemplate.queryForObject(
  144. "SELECT department_id_snapshot FROM period_participant WHERE user_id = 2", Long.class));
  145. assertEquals("EMPLOYEE", jdbcTemplate.queryForObject(
  146. "SELECT role_snapshot FROM period_participant WHERE user_id = 2", String.class));
  147. assertEquals("EXEMPT_NO_EVALUATOR", jdbcTemplate.queryForObject(
  148. "SELECT status FROM period_participant WHERE user_id = 1", String.class));
  149. assertEquals(1, jdbcTemplate.queryForObject(
  150. "SELECT COUNT(*) FROM schema_migration WHERE version = 10", Integer.class));
  151. assertEquals(1, jdbcTemplate.queryForObject(
  152. "SELECT COUNT(*) FROM schema_migration WHERE version = 11", Integer.class));
  153. }
  154. @Test
  155. void migrationAssignsWangkangAsTechnicalDirectorWithoutRewritingExecutingSnapshot() {
  156. createBaseTables();
  157. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN position VARCHAR(128)");
  158. jdbcTemplate.update("INSERT INTO sys_user(id,username,password_hash,real_name,role,position,created_at,updated_at) " +
  159. "VALUES (3,'wangkang','x','王康','SUPER_ADMIN','后端开发工程师',datetime('now'),datetime('now'))");
  160. jdbcTemplate.execute("""
  161. CREATE TABLE period_participant (
  162. id INTEGER PRIMARY KEY AUTOINCREMENT,
  163. period_id INTEGER NOT NULL,
  164. user_id INTEGER NOT NULL,
  165. status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
  166. position_snapshot VARCHAR(128),
  167. created_at TEXT NOT NULL,
  168. updated_at TEXT NOT NULL
  169. )
  170. """);
  171. jdbcTemplate.update("INSERT INTO assessment_period(id,name,start_date,end_date,status,created_at,updated_at) " +
  172. "VALUES (1,'执行周期','2026-07-01','2026-07-31','EXECUTING',datetime('now'),datetime('now'))");
  173. jdbcTemplate.update("INSERT INTO period_participant(period_id,user_id,status,position_snapshot,created_at,updated_at) " +
  174. "VALUES (1,3,'ACTIVE','后端开发工程师',datetime('now'),datetime('now'))");
  175. new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService).run();
  176. assertEquals("技术总监", jdbcTemplate.queryForObject(
  177. "SELECT position FROM sys_user WHERE username = 'wangkang'", String.class));
  178. assertEquals("后端开发工程师", jdbcTemplate.queryForObject(
  179. "SELECT position_snapshot FROM period_participant WHERE user_id = 3", String.class));
  180. assertEquals(1, jdbcTemplate.queryForObject(
  181. "SELECT COUNT(*) FROM schema_migration WHERE version = 12", Integer.class));
  182. }
  183. @Test
  184. void shouldRollbackMigrationAndNotRecordVersionWhenMigrationFails() {
  185. createBaseTables();
  186. // Create a custom runner with a failing migration
  187. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService) {
  188. @Override
  189. public void run(String... args) {
  190. jdbcTemplate.execute("""
  191. CREATE TABLE IF NOT EXISTS schema_migration (
  192. version INTEGER PRIMARY KEY,
  193. description TEXT NOT NULL,
  194. applied_at TEXT NOT NULL
  195. )
  196. """);
  197. // Apply migration 1 first (succeeds)
  198. MigrationContext ctx = new MigrationContext(jdbcTemplate);
  199. transactionTemplate.executeWithoutResult(status -> {
  200. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'");
  201. jdbcTemplate.update(
  202. "INSERT INTO schema_migration(version, description, applied_at) VALUES (?, ?, datetime('now'))",
  203. 1, "test migration 1");
  204. });
  205. // Now try a failing migration 2
  206. try {
  207. transactionTemplate.executeWithoutResult(status -> {
  208. jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'");
  209. // Force failure
  210. jdbcTemplate.execute("THIS IS INVALID SQL");
  211. jdbcTemplate.update(
  212. "INSERT INTO schema_migration(version, description, applied_at) VALUES (?, ?, datetime('now'))",
  213. 2, "should not be recorded");
  214. });
  215. fail("Expected exception was not thrown");
  216. } catch (Exception e) {
  217. // Expected — migration failed
  218. }
  219. }
  220. };
  221. try {
  222. runner.run();
  223. } catch (Exception e) {
  224. // May throw RuntimeException wrapping the migration failure
  225. }
  226. // Migration 1 should be recorded
  227. assertEquals(1, jdbcTemplate.queryForObject(
  228. "SELECT COUNT(*) FROM schema_migration WHERE version = 1", Integer.class));
  229. // Migration 2 should NOT be recorded
  230. assertEquals(0, jdbcTemplate.queryForObject(
  231. "SELECT COUNT(*) FROM schema_migration WHERE version = 2", Integer.class));
  232. // Column should exist (from migration 1, not rolled back)
  233. assertColumnExists(jdbcTemplate, "sys_user", "status");
  234. }
  235. @Test
  236. void shouldBackfillCommentsJsonIntoScoreItems() {
  237. createBaseTables();
  238. // Insert a score with comments_json
  239. jdbcTemplate.update(
  240. "INSERT INTO performance_score(id, period_id, user_id, evaluator_id, type, comments_json, created_at, updated_at) " +
  241. "VALUES (1, 1, 1, 2, 'SELF', ?, datetime('now'), datetime('now'))",
  242. "{\"krScores\":[{\"krId\":1,\"title\":\"KR1\",\"score\":0.8},{\"krId\":2,\"title\":\"KR2\",\"score\":0.6}]," +
  243. "\"dimensions\":[{\"id\":1,\"name\":\"质量\",\"maxScore\":10,\"score\":8},{\"id\":2,\"name\":\"效率\",\"maxScore\":10,\"score\":7}]}");
  244. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  245. runner.run();
  246. // Verify performance_score_item was populated
  247. int krCount = jdbcTemplate.queryForObject(
  248. "SELECT COUNT(*) FROM performance_score_item WHERE score_id = 1 AND item_type = 'KR'", Integer.class);
  249. assertEquals(2, krCount, "Should have 2 KR items");
  250. int dimCount = jdbcTemplate.queryForObject(
  251. "SELECT COUNT(*) FROM performance_score_item WHERE score_id = 1 AND item_type = 'DIMENSION'", Integer.class);
  252. assertEquals(2, dimCount, "Should have 2 dimension items");
  253. // Verify original comments_json is preserved
  254. String json = jdbcTemplate.queryForObject(
  255. "SELECT comments_json FROM performance_score WHERE id = 1", String.class);
  256. assertNotNull(json);
  257. assertTrue(json.contains("krScores"));
  258. // Verify migrations recorded
  259. assertEquals(1, jdbcTemplate.queryForObject(
  260. "SELECT COUNT(*) FROM schema_migration WHERE version = 3", Integer.class));
  261. }
  262. private void createBaseTables() {
  263. jdbcTemplate.execute("""
  264. CREATE TABLE sys_user (
  265. id INTEGER PRIMARY KEY AUTOINCREMENT,
  266. username VARCHAR(64) NOT NULL UNIQUE,
  267. password_hash VARCHAR(256) NOT NULL,
  268. real_name VARCHAR(64) NOT NULL,
  269. role VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE',
  270. deleted INTEGER NOT NULL DEFAULT 0,
  271. created_at TEXT NOT NULL,
  272. updated_at TEXT NOT NULL
  273. )
  274. """);
  275. jdbcTemplate.execute("""
  276. CREATE TABLE assessment_period (
  277. id INTEGER PRIMARY KEY AUTOINCREMENT,
  278. name VARCHAR(128) NOT NULL,
  279. period_type VARCHAR(20) NOT NULL DEFAULT 'MONTHLY',
  280. start_date TEXT NOT NULL,
  281. end_date TEXT NOT NULL,
  282. status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
  283. created_at TEXT NOT NULL,
  284. updated_at TEXT NOT NULL
  285. )
  286. """);
  287. jdbcTemplate.execute("""
  288. CREATE TABLE performance_score (
  289. id INTEGER PRIMARY KEY AUTOINCREMENT,
  290. period_id INTEGER NOT NULL,
  291. user_id INTEGER NOT NULL,
  292. evaluator_id INTEGER NOT NULL,
  293. type VARCHAR(20) NOT NULL,
  294. comments_json TEXT,
  295. deleted INTEGER NOT NULL DEFAULT 0,
  296. created_at TEXT NOT NULL,
  297. updated_at TEXT NOT NULL
  298. )
  299. """);
  300. jdbcTemplate.execute("""
  301. CREATE TABLE performance_feedback (
  302. id INTEGER PRIMARY KEY AUTOINCREMENT,
  303. period_id INTEGER NOT NULL,
  304. user_id INTEGER NOT NULL,
  305. superior_id INTEGER NOT NULL,
  306. created_at TEXT NOT NULL,
  307. updated_at TEXT NOT NULL
  308. )
  309. """);
  310. jdbcTemplate.execute("""
  311. CREATE TABLE okr_objective (
  312. id INTEGER PRIMARY KEY AUTOINCREMENT,
  313. period_id INTEGER NOT NULL,
  314. user_id INTEGER,
  315. dept_id INTEGER,
  316. title VARCHAR(256) NOT NULL,
  317. created_at TEXT NOT NULL,
  318. updated_at TEXT NOT NULL
  319. )
  320. """);
  321. jdbcTemplate.execute("""
  322. CREATE TABLE kr_key_result (
  323. id INTEGER PRIMARY KEY AUTOINCREMENT,
  324. objective_id INTEGER NOT NULL,
  325. title VARCHAR(256) NOT NULL,
  326. source_kr_id INTEGER,
  327. created_at TEXT NOT NULL,
  328. updated_at TEXT NOT NULL
  329. )
  330. """);
  331. jdbcTemplate.execute("""
  332. CREATE TABLE notification (
  333. id INTEGER PRIMARY KEY AUTOINCREMENT,
  334. user_id INTEGER NOT NULL,
  335. type VARCHAR(32) NOT NULL,
  336. title VARCHAR(256) NOT NULL,
  337. is_read INTEGER DEFAULT 0,
  338. created_at TEXT NOT NULL
  339. )
  340. """);
  341. }
  342. private void assertColumnExists(JdbcTemplate jdbcTemplate, String tableName, String columnName) {
  343. boolean exists = jdbcTemplate.queryForList("PRAGMA table_info(" + tableName + ")").stream()
  344. .anyMatch(row -> columnName.equalsIgnoreCase(String.valueOf(row.get("name"))));
  345. assertTrue(exists, tableName + "." + columnName + " should exist");
  346. }
  347. @Test
  348. void shouldMigrateLegacy4x15DimensionsTo4x10() {
  349. createBaseTables();
  350. jdbcTemplate.execute("""
  351. CREATE TABLE assessment_dimension (
  352. id INTEGER PRIMARY KEY AUTOINCREMENT,
  353. name VARCHAR(64) NOT NULL,
  354. max_score INTEGER NOT NULL DEFAULT 10,
  355. sort_order INTEGER DEFAULT 0,
  356. deleted INTEGER NOT NULL DEFAULT 0,
  357. created_at TEXT NOT NULL,
  358. updated_at TEXT NOT NULL
  359. )
  360. """);
  361. jdbcTemplate.update("INSERT INTO assessment_dimension(id,name,max_score,sort_order,created_at,updated_at) VALUES (1,\"交付质量\",15,1,datetime(\"now\"),datetime(\"now\"))");
  362. jdbcTemplate.update("INSERT INTO assessment_dimension(id,name,max_score,sort_order,created_at,updated_at) VALUES (2,\"协作沟通\",15,2,datetime(\"now\"),datetime(\"now\"))");
  363. jdbcTemplate.update("INSERT INTO assessment_dimension(id,name,max_score,sort_order,created_at,updated_at) VALUES (3,\"学习成长\",15,3,datetime(\"now\"),datetime(\"now\"))");
  364. jdbcTemplate.update("INSERT INTO assessment_dimension(id,name,max_score,sort_order,created_at,updated_at) VALUES (4,\"创新贡献\",15,4,datetime(\"now\"),datetime(\"now\"))");
  365. DatabaseMigrationRunner runner = new DatabaseMigrationRunner(jdbcTemplate, transactionTemplate, backupService);
  366. runner.run();
  367. for (int i = 1; i <= 4; i++) {
  368. int maxScore = jdbcTemplate.queryForObject(
  369. "SELECT max_score FROM assessment_dimension WHERE id = " + i, Integer.class);
  370. assertEquals(10, maxScore, "Dim " + i + " should be 10");
  371. }
  372. assertEquals(1, jdbcTemplate.queryForObject(
  373. "SELECT COUNT(*) FROM schema_migration WHERE version = 4", Integer.class));
  374. }
  375. private void assertTableExists(JdbcTemplate jdbcTemplate, String tableName) {
  376. Integer count = jdbcTemplate.queryForObject("""
  377. SELECT COUNT(*) FROM sqlite_master
  378. WHERE type = 'table' AND name = ?
  379. """, Integer.class, tableName);
  380. assertEquals(1, count);
  381. }
  382. }