You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
373 lines
19 KiB
373 lines
19 KiB
package com.mydreamjob.skill;
|
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
import org.springframework.dao.DataAccessException;
|
|
import org.springframework.data.redis.core.RedisTemplate;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.test.context.ActiveProfiles;
|
|
import org.springframework.test.context.TestPropertySource;
|
|
|
|
import javax.sql.DataSource;
|
|
import java.math.BigDecimal;
|
|
import java.sql.Connection;
|
|
import java.sql.DatabaseMetaData;
|
|
import java.sql.ResultSet;
|
|
import java.sql.SQLException;
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.TreeMap;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
@SpringBootTest
|
|
@TestPropertySource(properties =
|
|
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_taxonomy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
|
|
@ActiveProfiles("integration-test")
|
|
class SkillTaxonomyMigrationTest {
|
|
|
|
@Autowired
|
|
private JdbcTemplate jdbcTemplate;
|
|
|
|
@Autowired
|
|
private DataSource dataSource;
|
|
|
|
@MockBean
|
|
private RedisTemplate<String, Object> redisTemplate;
|
|
|
|
private Long sourceVersionId;
|
|
private Long industryId;
|
|
private Long occupationId;
|
|
private Long categoryId;
|
|
private Long skillId;
|
|
private Long secondSkillId;
|
|
private Long occupationSkillId;
|
|
|
|
@Test
|
|
void migrationCreatesTaxonomyTablesColumnsConstraintsAndIndexes() throws SQLException {
|
|
assertTableAndColumns("skill_source_versions",
|
|
"id", "source_name", "source_version", "license", "attribution_url",
|
|
"imported_at", "created_at", "updated_at");
|
|
assertTableAndColumns("skill_industries",
|
|
"id", "code", "name_zh", "source_code", "sort_order", "created_at", "updated_at");
|
|
assertTableAndColumns("skill_occupations",
|
|
"id", "industry_id", "code", "name_zh", "name_en", "source_code",
|
|
"source_version_id", "sort_order", "created_at", "updated_at");
|
|
assertTableAndColumns("skill_categories",
|
|
"id", "occupation_id", "code", "name_zh", "suggestion_template_code",
|
|
"sort_order", "created_at", "updated_at");
|
|
assertTableAndColumns("skills",
|
|
"id", "code", "name_zh", "name_en", "source_type", "source_code",
|
|
"source_version_id", "sort_order", "created_at", "updated_at");
|
|
assertTableAndColumns("skill_aliases",
|
|
"id", "skill_id", "alias", "normalized_alias", "created_at");
|
|
assertTableAndColumns("occupation_skills",
|
|
"id", "occupation_id", "skill_id", "category_id", "importance", "importance_source_code", "importance_element_id", "importance_is_proxy",
|
|
"relationship_type", "source_version_id", "created_at", "updated_at");
|
|
|
|
assertUniqueIndex("skill_source_versions", "source_name", "source_version");
|
|
assertUniqueIndex("skill_industries", "code");
|
|
assertUniqueIndex("skill_occupations", "industry_id", "code");
|
|
assertUniqueIndex("skill_categories", "occupation_id", "code");
|
|
assertUniqueIndex("skills", "code");
|
|
assertUniqueIndex("skills", "source_type", "source_code");
|
|
assertUniqueIndex("skill_aliases", "normalized_alias");
|
|
assertUniqueIndex("skill_aliases", "skill_id", "alias");
|
|
assertUniqueIndex("occupation_skills", "occupation_id", "skill_id");
|
|
|
|
assertForeignKey("skill_occupations", "industry_id", "skill_industries", "id");
|
|
assertForeignKey("skill_occupations", "source_version_id", "skill_source_versions", "id");
|
|
assertForeignKey("skill_categories", "occupation_id", "skill_occupations", "id");
|
|
assertForeignKey("skills", "source_version_id", "skill_source_versions", "id");
|
|
assertForeignKey("skill_aliases", "skill_id", "skills", "id");
|
|
assertForeignKey("occupation_skills", "occupation_id", "skill_occupations", "id");
|
|
assertForeignKey("occupation_skills", "skill_id", "skills", "id");
|
|
assertForeignKey("occupation_skills", "category_id", "skill_categories", "id");
|
|
assertForeignKey("occupation_skills", "source_version_id", "skill_source_versions", "id");
|
|
|
|
assertIndex("occupation_skills", "occupation_id", "category_id");
|
|
assertIndex("occupation_skills", "occupation_id", "importance");
|
|
assertBooleanColumnMetadata("occupation_skills", "importance_is_proxy");
|
|
assertEquals(1L, jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
|
"WHERE UPPER(table_name) = 'OCCUPATION_SKILLS' " +
|
|
"AND UPPER(constraint_name) = 'CK_OCCUPATION_SKILLS_IMPORTANCE_PROXY' " +
|
|
"AND UPPER(constraint_type) = 'CHECK'",
|
|
Long.class));
|
|
}
|
|
|
|
@Test
|
|
void minimumTaxonomyChainIsUsableAndCoversCoreConstraints() {
|
|
sourceVersionId = insertAndReturnId(
|
|
"INSERT INTO skill_source_versions " +
|
|
"(source_name, source_version, license, attribution_url, imported_at) " +
|
|
"VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
|
"ESCO", "test-1.2.1", "Commission Decision 2011/833/EU",
|
|
"https://esco.ec.europa.eu/en/use-esco/download");
|
|
industryId = insertAndReturnId(
|
|
"INSERT INTO skill_industries (code, name_zh, source_code, sort_order) " +
|
|
"VALUES (?, ?, ?, ?)",
|
|
"TEST_INTERNET", "互联网", "PROJECT-INTERNET", 1);
|
|
occupationId = insertAndReturnId(
|
|
"INSERT INTO skill_occupations " +
|
|
"(industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
industryId, "TEST_FRONTEND", "前端开发工程师", "Frontend Developer",
|
|
"ESCO-2513.2", sourceVersionId, 1);
|
|
categoryId = insertAndReturnId(
|
|
"INSERT INTO skill_categories " +
|
|
"(occupation_id, code, name_zh, suggestion_template_code, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
occupationId, "TEST_PROGRAMMING_LANGUAGE", "编程语言",
|
|
"PROGRAMMING_LANGUAGE", 1);
|
|
skillId = insertAndReturnId(
|
|
"INSERT INTO skills " +
|
|
"(code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
"TEST_SKILL_JAVA", "Java", "Java", "ESCO", "ESCO-SKILL-JAVA",
|
|
sourceVersionId, 1);
|
|
insertAndReturnId(
|
|
"INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)",
|
|
skillId, "Java SE", "java se");
|
|
occupationSkillId = insertAndReturnId(
|
|
"INSERT INTO occupation_skills " +
|
|
"(occupation_id, skill_id, category_id, importance, relationship_type, source_version_id) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
occupationId, skillId, categoryId, null, "ESSENTIAL", sourceVersionId);
|
|
|
|
Map<String, Object> joined = jdbcTemplate.queryForMap(
|
|
"SELECT s.name_zh AS skill_name, c.name_zh AS category_name, os.importance, " +
|
|
"os.importance_source_code, os.importance_element_id, os.importance_is_proxy " +
|
|
"FROM occupation_skills os " +
|
|
"JOIN skills s ON s.id = os.skill_id " +
|
|
"JOIN skill_categories c ON c.id = os.category_id " +
|
|
"WHERE os.id = ?", occupationSkillId);
|
|
assertEquals("Java", joined.get("skill_name"));
|
|
assertEquals("编程语言", joined.get("category_name"));
|
|
assertNull(joined.get("importance"));
|
|
assertNull(joined.get("importance_source_code"));
|
|
assertNull(joined.get("importance_element_id"));
|
|
assertEquals(Boolean.FALSE, joined.get("importance_is_proxy"));
|
|
assertEquals(1L, jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = FALSE",
|
|
Long.class, occupationSkillId));
|
|
|
|
assertConstraintFailure("UPDATE occupation_skills SET importance = ? WHERE id = ?",
|
|
new BigDecimal("4.25"), occupationSkillId);
|
|
jdbcTemplate.update("UPDATE occupation_skills SET importance = ?, " +
|
|
"importance_source_code = ?, importance_element_id = ?, importance_is_proxy = ? " +
|
|
"WHERE id = ?",
|
|
new BigDecimal("4.25"), "15-1252.00", "4.A.3.b.1", Boolean.TRUE, occupationSkillId);
|
|
Map<String, Object> weighted = jdbcTemplate.queryForMap(
|
|
"SELECT importance, importance_source_code, importance_element_id, importance_is_proxy " +
|
|
"FROM occupation_skills WHERE id = ?",
|
|
occupationSkillId);
|
|
assertEquals(new BigDecimal("4.25"), weighted.get("importance"));
|
|
assertEquals("15-1252.00", weighted.get("importance_source_code"));
|
|
assertEquals("4.A.3.b.1", weighted.get("importance_element_id"));
|
|
assertEquals(Boolean.TRUE, weighted.get("importance_is_proxy"));
|
|
assertEquals(1L, jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = TRUE",
|
|
Long.class, occupationSkillId));
|
|
assertConstraintFailure(
|
|
"INSERT INTO skill_source_versions " +
|
|
"(source_name, source_version, license, attribution_url) VALUES (?, ?, ?, ?)",
|
|
"ESCO", "test-1.2.1", "duplicate", "https://example.invalid");
|
|
assertConstraintFailure(
|
|
"INSERT INTO skill_categories " +
|
|
"(occupation_id, code, name_zh, suggestion_template_code, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
occupationId, "TEST_INVALID_CATEGORY", "非法分类", "INVALID_TEMPLATE", 2);
|
|
assertConstraintFailure(
|
|
"INSERT INTO skills " +
|
|
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
"TEST_INVALID_SKILL", "非法技能", "INVALID_SOURCE", "INVALID-SOURCE-1",
|
|
sourceVersionId, 2);
|
|
secondSkillId = insertAndReturnId(
|
|
"INSERT INTO skills " +
|
|
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
"TEST_SKILL_SPRING", "Spring Boot", "ESCO", "ESCO-SKILL-SPRING",
|
|
sourceVersionId, 2);
|
|
assertConstraintFailure(
|
|
"INSERT INTO skills " +
|
|
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
"TEST_SKILL_NULL_NAME", null, "PROJECT", "PROJECT-NULL-NAME",
|
|
sourceVersionId, 3);
|
|
assertConstraintFailure(
|
|
"INSERT INTO occupation_skills " +
|
|
"(occupation_id, skill_id, category_id, relationship_type, source_version_id) " +
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
occupationId, secondSkillId, categoryId, "INVALID_RELATIONSHIP", sourceVersionId);
|
|
assertConstraintFailure(
|
|
"INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)",
|
|
secondSkillId, "Java SE alternate", "java se");
|
|
|
|
assertConstraintFailure(
|
|
"UPDATE occupation_skills SET importance_is_proxy = ? WHERE id = ?",
|
|
(Object) null, occupationSkillId);
|
|
assertTrue(jdbcTemplate.queryForObject(
|
|
"SELECT COUNT(*) FROM skill_aliases WHERE skill_id = ? AND normalized_alias = ?",
|
|
Integer.class, skillId, "java se") == 1);
|
|
}
|
|
|
|
@AfterEach
|
|
void cleanUp() {
|
|
if (occupationSkillId != null) {
|
|
jdbcTemplate.update("DELETE FROM occupation_skills WHERE id = ?", occupationSkillId);
|
|
}
|
|
if (secondSkillId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", secondSkillId);
|
|
jdbcTemplate.update("DELETE FROM skills WHERE id = ?", secondSkillId);
|
|
}
|
|
if (skillId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", skillId);
|
|
jdbcTemplate.update("DELETE FROM skills WHERE id = ?", skillId);
|
|
}
|
|
if (categoryId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_categories WHERE id = ?", categoryId);
|
|
}
|
|
if (occupationId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_occupations WHERE id = ?", occupationId);
|
|
}
|
|
if (industryId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_industries WHERE id = ?", industryId);
|
|
}
|
|
if (sourceVersionId != null) {
|
|
jdbcTemplate.update("DELETE FROM skill_source_versions WHERE id = ?", sourceVersionId);
|
|
}
|
|
}
|
|
|
|
private void assertBooleanColumnMetadata(String table, String column) {
|
|
Map<String, Object> metadata = jdbcTemplate.queryForMap(
|
|
"SELECT data_type, column_default, is_nullable FROM information_schema.columns " +
|
|
"WHERE UPPER(table_name) = ? AND UPPER(column_name) = ?",
|
|
table.toUpperCase(Locale.ROOT), column.toUpperCase(Locale.ROOT));
|
|
assertEquals("BOOLEAN", String.valueOf(metadata.get("data_type")).toUpperCase(Locale.ROOT));
|
|
assertEquals("FALSE", String.valueOf(metadata.get("column_default")).toUpperCase(Locale.ROOT));
|
|
assertEquals("NO", String.valueOf(metadata.get("is_nullable")).toUpperCase(Locale.ROOT));
|
|
}
|
|
|
|
private void assertTableAndColumns(String table, String... columns) throws SQLException {
|
|
try (Connection connection = dataSource.getConnection()) {
|
|
DatabaseMetaData metadata = connection.getMetaData();
|
|
String actualTable = findTable(metadata, table);
|
|
assertTrue(actualTable != null, "Missing table: " + table);
|
|
|
|
List<String> actualColumns = new ArrayList<>();
|
|
try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) {
|
|
while (resultSet.next()) {
|
|
actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase());
|
|
}
|
|
}
|
|
for (String column : columns) {
|
|
assertTrue(actualColumns.contains(column.toLowerCase()),
|
|
"Missing column " + table + "." + column + "; actual=" + actualColumns);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void assertUniqueIndex(String table, String... columns) throws SQLException {
|
|
assertTrue(hasIndex(table, true, columns),
|
|
"Missing unique index on " + table + "(" + String.join(", ", columns) + ")");
|
|
}
|
|
|
|
private void assertIndex(String table, String... columns) throws SQLException {
|
|
assertTrue(hasIndex(table, false, columns),
|
|
"Missing index on " + table + "(" + String.join(", ", columns) + ")");
|
|
}
|
|
|
|
private boolean hasIndex(String table, boolean unique, String... expectedColumns) throws SQLException {
|
|
try (Connection connection = dataSource.getConnection()) {
|
|
DatabaseMetaData metadata = connection.getMetaData();
|
|
String actualTable = findTable(metadata, table);
|
|
if (actualTable == null) {
|
|
return false;
|
|
}
|
|
Map<String, TreeMap<Short, String>> indexes = new LinkedHashMap<>();
|
|
try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) {
|
|
while (resultSet.next()) {
|
|
String indexName = resultSet.getString("INDEX_NAME");
|
|
if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) {
|
|
continue;
|
|
}
|
|
indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>())
|
|
.put(resultSet.getShort("ORDINAL_POSITION"),
|
|
resultSet.getString("COLUMN_NAME").toLowerCase());
|
|
}
|
|
}
|
|
List<String> expected = new ArrayList<>();
|
|
for (String column : expectedColumns) {
|
|
expected.add(column.toLowerCase());
|
|
}
|
|
return indexes.values().stream()
|
|
.map(TreeMap::values)
|
|
.map(values -> new ArrayList<>(values))
|
|
.anyMatch(expected::equals);
|
|
}
|
|
}
|
|
|
|
private void assertForeignKey(String childTable, String childColumn,
|
|
String parentTable, String parentColumn) throws SQLException {
|
|
try (Connection connection = dataSource.getConnection()) {
|
|
DatabaseMetaData metadata = connection.getMetaData();
|
|
String actualChildTable = findTable(metadata, childTable);
|
|
String actualParentTable = findTable(metadata, parentTable);
|
|
if (actualChildTable == null || actualParentTable == null) {
|
|
throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn);
|
|
}
|
|
try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) {
|
|
while (resultSet.next()) {
|
|
if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME"))
|
|
&& childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME"))
|
|
&& parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
throw new AssertionError("Missing foreign key " + childTable + "." + childColumn
|
|
+ " -> " + parentTable + "." + parentColumn);
|
|
}
|
|
|
|
private String findTable(DatabaseMetaData metadata, String expected) throws SQLException {
|
|
try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) {
|
|
while (resultSet.next()) {
|
|
String actual = resultSet.getString("TABLE_NAME");
|
|
if (expected.equalsIgnoreCase(actual)) {
|
|
return actual;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private Long insertAndReturnId(String sql, Object... args) {
|
|
jdbcTemplate.update(sql, args);
|
|
String table = extractTable(sql);
|
|
return jdbcTemplate.queryForObject("SELECT MAX(id) FROM " + table, Long.class);
|
|
}
|
|
|
|
private String extractTable(String sql) {
|
|
String upper = sql.toUpperCase();
|
|
int fromIndex = upper.indexOf(" INTO ");
|
|
int openIndex = sql.indexOf('(', fromIndex);
|
|
return sql.substring(fromIndex + " INTO ".length(), openIndex).trim();
|
|
}
|
|
|
|
private void assertConstraintFailure(String sql, Object... args) {
|
|
assertThrows(DataAccessException.class, () -> jdbcTemplate.update(sql, args));
|
|
}
|
|
}
|
|
|
|
|