Files
kebab/crates/kebab-app/tests/config_migrate.rs
altair823 8537e5765e chore: PR #235 회차 3 리뷰 반영 — 판정 보류 분기 + vector_store env
3회차 리뷰가 2회차 지적 8건이 동작 수준에서 해결됐음을 확인하고 머지
가능으로 결론냈다. 남은 LOW 중 값이 있는 셋을 반영한다.

1) 마이그레이션 이력을 못 읽었을 때 파괴적 조언으로 떨어졌다

   `Some(Some((None, Ok(어긋남))))` — 버전은 모르는데 표본은 읽힌 경우 —
   가 `ok: false` + "`kebab reset`" 분기로 갔다. 버전을 모르는 것은 KB 를
   날리라고 말할 근거가 못 된다. 판정을 "V016 이상임이 확인된 스토어"로
   한정하고, 이력을 못 읽으면 `ok: true` + "판정 보류" 로 둔다. 실제
   도달 경로는 사실상 없지만(chunks_fts 가 있으면 이력도 있다) 분기의
   기본값이 파괴적인 쪽인 게 문제였다.

2) "점검하지 못했다" hint 가 가장 흔한 원인을 안 적었다

   2회차에서 고친 HIGH — 이전 버전 doctor 가 남긴 0바이트 kebab.sqlite —
   가 정확히 이 분기로 온다. 테이블이 없어 읽기에 실패한다. "아직 색인
   전이거나" 를 앞에 넣었다.

3) vector_store 블록이 아직 env 를 안 얹고 있었다

   2회차에서 fts_shadow 에 대해 고친 것과 같은 결함이 #234 에서 들어온
   바로 아래 블록에 그대로 남아 있었다. 정보성 체크라 종료 코드에는
   영향이 없지만 detail 이 엉뚱한 디렉토리를 가리킨다. 같이 맞췄다.

미반영: shadow 의 chunk_id 가 NULL 이면 분자에서 빠지는 과소 계수 —
트리거가 NOT NULL 컬럼을 미러링하므로 도달 불가이고 방향도 오탐이 아닌
과소 쪽이다. 새 테스트가 `KEBAB_STORAGE_DATA_DIR` 이 export 된 셸에서
실패하는 것은 점검이 제대로 동작하는 결과라, 원인을 빨리 찾도록 테스트
주석에 적어 두는 선에서 끝냈다.

실측 확인: KB 없음 / V015 / V016 / env override 네 경로 모두 의도한 문구가
나오고, KB 없는 경로는 파일을 남기지 않는다. env override 를 주면
fts_shadow 와 vector_store 가 같은 디렉토리를 본다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2026-08-16 20:17:15 +09:00

184 lines
6.4 KiB
Rust

use std::fs;
#[test]
fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n",
)
.unwrap();
// dry-run: 파일·백업 미변경.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap();
assert!(report.changed);
assert!(report.dry_run);
assert!(report.backup_path.is_none());
assert!(!dir.path().join("config.toml.bak").exists());
assert!(
fs::read_to_string(&cfg).unwrap().contains("include"),
"dry-run modified file"
);
// 실제 적용: 백업 생성 + 파일 갱신.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
assert!(report.changed);
assert!(!report.dry_run);
assert!(report.backup_path.is_some());
assert!(dir.path().join("config.toml.bak").exists());
let new = fs::read_to_string(&cfg).unwrap();
assert!(!new.contains("include"));
assert!(new.contains("[ingest.code]"));
// 멱등: 재실행 changed=false.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
assert!(!report.changed);
}
#[test]
fn migrate_missing_file_errors() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("nope.toml");
assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err());
}
#[test]
fn annotated_default_serialization_contains_section_comments() {
let doc = kebab_config::migrate::annotated_default_document();
let text = doc.to_string();
assert!(
text.contains("code ingest skip 정책"),
"section comment missing:\n{text}"
);
assert!(text.contains("[ingest.code]"));
}
#[test]
fn doctor_flags_outdated_config() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n",
)
.unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "config_migration")
.unwrap();
assert!(!check.ok, "outdated config should fail check");
assert!(check.hint.as_deref().unwrap().contains("config migrate"));
assert!(!report.ok, "overall doctor should be false");
// migrate 후엔 통과.
kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "config_migration")
.unwrap();
assert!(check.ok, "after migrate should pass");
}
/// `doctor` is a diagnostic and must not manufacture the store it is
/// asked about. The `fts_shadow` check (issue #229 / V016) reads SQLite,
/// and `SqliteStore::open` creates the file — so on a machine with no KB
/// yet, running doctor once used to leave an empty `kebab.sqlite` behind.
#[test]
fn doctor_does_not_create_a_store_where_none_exists() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
format!(
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
[storage]\ndata_dir = \"{}\"\n",
data.display()
),
)
.unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "fts_shadow")
.expect("doctor must report fts_shadow even with no store");
assert!(check.ok, "a missing store is not a drifted one");
assert!(
!data.join(kebab_store_sqlite::SQLITE_FILE).exists(),
"doctor must not create {} — it only reports",
kebab_store_sqlite::SQLITE_FILE
);
}
/// A store that predates V016 can be misaligned already — V009's
/// backfill inserted without an explicit rowid — but there the delete
/// trigger still addresses rows by chunk_id, so the drift is harmless.
/// Telling that user to `kebab reset` would destroy a healthy KB over a
/// condition that applying the migration fixes by itself.
///
/// Note: doctor applies env overrides with the same precedence as
/// `Config::load`, so `KEBAB_STORAGE_DATA_DIR` exported in the shell
/// redirects this check away from the temp store and fails the test.
/// That is the check behaving correctly, not a broken test — unset it.
#[test]
fn doctor_does_not_call_a_pre_v016_store_broken() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
std::fs::create_dir_all(&data).unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
format!(
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
[storage]\ndata_dir = \"{}\"\n",
data.display()
),
)
.unwrap();
// A store stamped at V015 with a shadow deliberately misaligned:
// exactly what a pre-V016 upgrade can look like.
let db = data.join(kebab_store_sqlite::SQLITE_FILE);
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute_batch(
"CREATE TABLE refinery_schema_history (version INTEGER);
INSERT INTO refinery_schema_history VALUES (15);
CREATE TABLE chunks (chunk_id TEXT PRIMARY KEY, text TEXT);
CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, text);
INSERT INTO chunks VALUES ('a', 'x'), ('b', 'y');
INSERT INTO chunks_fts(rowid, chunk_id, text) VALUES (77, 'a', 'x');",
)
.unwrap();
drop(conn);
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "fts_shadow")
.unwrap();
assert!(
check.ok,
"a pre-V016 store must not fail the check: {}",
check.detail
);
assert!(
check.detail.contains("V016"),
"the detail should say the migration has not been applied, got {:?}",
check.detail
);
assert!(
check.hint.as_deref().unwrap_or_default().is_empty(),
"and must not tell the user to reset: {:?}",
check.hint
);
}