fix(personal): align schema with phase two contract
This commit is contained in:
+86
-13
@@ -18,33 +18,106 @@ class PersonalSchemaContractTest {
|
||||
|
||||
@Test
|
||||
void personalKnowledgeSchemaIsOwnerScopedAndIndependent() throws IOException {
|
||||
Path sqlPath = locateSqlDirectory().resolve(SCHEMA_FILE);
|
||||
Path projectRoot = locateProjectRoot();
|
||||
Path sqlPath = projectRoot.resolve("backend/script/sql").resolve(SCHEMA_FILE);
|
||||
assertTrue(Files.isRegularFile(sqlPath), "Missing personal knowledge schema: " + sqlPath);
|
||||
|
||||
String sql = Files.readString(sqlPath).toLowerCase(Locale.ROOT);
|
||||
assertTrue(sql.contains("create table if not exists `aihr_personal_space`"));
|
||||
assertTrue(sql.contains("create table if not exists `aihr_personal_item`"));
|
||||
assertTrue(sql.contains("create table if not exists `aihr_personal_fragment`"));
|
||||
assertTrue(sql.contains("create table if not exists `aihr_personal_chat_session`"));
|
||||
assertTrue(sql.contains("create table if not exists `aihr_personal_chat_message`"));
|
||||
assertTrue(sql.contains("`owner_user_id`"), "Personal records must be owner scoped");
|
||||
assertTrue(sql.contains("fulltext key"), "Personal fragments must provide full-text search");
|
||||
String space = tableDefinition(sql, "aihr_personal_space");
|
||||
String item = tableDefinition(sql, "aihr_personal_item");
|
||||
String fragment = tableDefinition(sql, "aihr_personal_fragment");
|
||||
String session = tableDefinition(sql, "aihr_personal_chat_session");
|
||||
String message = tableDefinition(sql, "aihr_personal_chat_message");
|
||||
|
||||
for (String definition : new String[] {space, item, fragment, session, message}) {
|
||||
assertTrue(definition.contains("`owner_user_id` bigint not null"),
|
||||
"Every personal table must carry a non-null owner_user_id");
|
||||
}
|
||||
|
||||
assertTrue(space.contains("`quota_bytes` bigint not null default 524288000"),
|
||||
"Personal space must default to a 500 MiB quota");
|
||||
assertTrue(space.contains("`status` varchar(20) not null default 'active'"));
|
||||
assertTrue(space.contains("`used_bytes` bigint not null default 0"));
|
||||
assertTrue(space.contains("`item_count` int not null default 0"));
|
||||
assertTrue(space.contains("unique key `uk_personal_space_owner` (`tenant_id`, `owner_user_id`)"));
|
||||
|
||||
assertTrue(item.contains("`original_url` varchar(2000) default null"));
|
||||
assertTrue(item.contains("`content_hash` varchar(64) not null"));
|
||||
assertTrue(item.contains("`status` varchar(20) not null default 'queued'"));
|
||||
assertTrue(item.contains("`error_code` varchar(80) default null"));
|
||||
assertTrue(item.contains("`error_message` varchar(500) default null"));
|
||||
assertTrue(item.contains("`tags_json` json"));
|
||||
assertTrue(item.contains("`captured_at` datetime not null"));
|
||||
assertTrue(item.contains("`parsed_at` datetime default null"));
|
||||
assertTrue(item.contains("`deleted_at` datetime default null"));
|
||||
assertTrue(item.contains(
|
||||
"key `idx_personal_item_owner` (`tenant_id`, `owner_user_id`, `status`, `create_time`)"));
|
||||
assertTrue(item.contains(
|
||||
"key `idx_personal_item_date` (`tenant_id`, `owner_user_id`, `captured_at`)"));
|
||||
assertTrue(item.contains("key `idx_personal_item_hash` (`space_id`, `content_hash`)"));
|
||||
assertFalse(item.contains("`source_url`"));
|
||||
assertFalse(item.contains("`last_error`"));
|
||||
assertFalse(item.contains("`captured_time`"));
|
||||
|
||||
assertTrue(fragment.contains("`space_id` bigint not null"));
|
||||
assertTrue(fragment.contains("`idx` int not null"));
|
||||
assertTrue(fragment.contains("`embedding_json` longtext"));
|
||||
assertTrue(fragment.contains("`embedding_model` varchar(100) default null"));
|
||||
assertTrue(fragment.contains("`embedding_time` datetime default null"));
|
||||
assertTrue(fragment.contains("unique key `uk_personal_fragment_idx` (`item_id`, `idx`)"));
|
||||
assertTrue(fragment.contains(
|
||||
"key `idx_personal_fragment_owner` (`tenant_id`, `owner_user_id`, `item_id`)"));
|
||||
assertTrue(fragment.contains(
|
||||
"fulltext key `ft_personal_fragment_content` (`content`) with parser ngram"),
|
||||
"Personal fragments must use the MySQL ngram full-text parser");
|
||||
|
||||
assertTrue(session.contains("`default_scope` varchar(50) not null default 'personal'"));
|
||||
assertTrue(session.contains(
|
||||
"key `idx_personal_session_owner` (`tenant_id`, `owner_user_id`, `update_time`)"));
|
||||
|
||||
assertTrue(message.contains("`scope_json` json"));
|
||||
assertTrue(message.contains("`citations_json` json"));
|
||||
assertTrue(message.contains("`model_name` varchar(100) default null"));
|
||||
assertTrue(message.contains("`prompt_version` varchar(100) default null"));
|
||||
assertTrue(message.contains("`input_tokens` int not null default 0"));
|
||||
assertTrue(message.contains("`output_tokens` int not null default 0"));
|
||||
assertTrue(message.contains("`latency_ms` bigint not null default 0"));
|
||||
assertTrue(message.contains(
|
||||
"key `idx_personal_message_owner` (`tenant_id`, `owner_user_id`, `session_id`, `create_time`)"));
|
||||
|
||||
assertFalse(sql.contains("alter table aihr_knowledge_fragment"),
|
||||
"Personal schema must not mutate enterprise knowledge tables");
|
||||
assertFalse(sql.contains("alter table `aihr_knowledge_fragment`"),
|
||||
"Personal schema must not mutate enterprise knowledge tables");
|
||||
|
||||
String resetScript = Files.readString(projectRoot.resolve("scripts/reset-dev-db.sh"));
|
||||
int knowledgeImport = resetScript.indexOf("aihr_knowledge_mysql8.sql");
|
||||
int personalImport = resetScript.indexOf(SCHEMA_FILE);
|
||||
assertTrue(knowledgeImport >= 0 && personalImport > knowledgeImport,
|
||||
"reset-dev-db.sh must import personal schema after enterprise knowledge schema");
|
||||
assertTrue(resetScript.contains("--default-character-set=utf8mb4 ry-vue < "
|
||||
+ "\"$ROOT_DIR/backend/script/sql/" + SCHEMA_FILE + "\""));
|
||||
}
|
||||
|
||||
private Path locateSqlDirectory() {
|
||||
private String tableDefinition(String sql, String tableName) {
|
||||
String marker = "create table if not exists `" + tableName + "`";
|
||||
int start = sql.indexOf(marker);
|
||||
assertTrue(start >= 0, "Missing table definition: " + tableName);
|
||||
int end = sql.indexOf(';', start);
|
||||
assertTrue(end > start, "Unterminated table definition: " + tableName);
|
||||
return sql.substring(start, end).replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private Path locateProjectRoot() {
|
||||
Path current = Path.of("").toAbsolutePath().normalize();
|
||||
while (current != null) {
|
||||
Path candidate = current.resolve("backend/script/sql");
|
||||
if (Files.isDirectory(candidate)) {
|
||||
return candidate;
|
||||
if (Files.isDirectory(current.resolve("backend/script/sql"))
|
||||
&& Files.isRegularFile(current.resolve("scripts/reset-dev-db.sh"))) {
|
||||
return current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
throw new IllegalStateException("Unable to locate backend/script/sql from "
|
||||
throw new IllegalStateException("Unable to locate project root from "
|
||||
+ Path.of("").toAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user