test(store): doc-side expansion Task 2 리뷰 보강 (M1/M2/N1)

- M1: chunk_aliases trigger 가드에 AND aliases <> '' (빈 문자열 미색인)
- M2: 재색인 멱등 테스트 (재-put 후 별칭 행 1개)
- N1: 본문 격리 음성 단언 (별칭 term 이 chunks_fts 로 누출 안 됨)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 02:24:24 +00:00
parent b12a616ab2
commit 0df47febf0
2 changed files with 63 additions and 2 deletions

View File

@@ -161,3 +161,60 @@ fn none_aliases_not_indexed() {
"aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다"
);
}
/// Task 2 리뷰 M2: 같은 doc 을 두 번 `put_chunks` 해도 `chunk_aliases_fts`
/// 행이 중복되지 않아야 한다. put_chunks 의 DELETE-then-INSERT 가
/// chunk_aliases_ad → chunk_aliases_ai 를 발화해 멱등 재동기화하는지 검증.
#[test]
fn reput_keeps_single_alias_row() {
let env = common::TestEnv::new();
let store = open_store_with_document(&env);
let doc = DocumentId("d".repeat(32));
let mk = || base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into()));
store.put_chunks(&doc, &[mk()]).unwrap();
store.put_chunks(&doc, &[mk()]).unwrap(); // 같은 doc 재-put
let n: i64 = env.with_conn(|c| {
c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
});
assert_eq!(n, 1, "재색인 후에도 별칭 행은 1개여야 한다 (중복/누락 없음)");
}
/// Task 2 리뷰 N1: 별칭 term 이 본문 `chunks_fts` 로 새지 않아야 한다(§3.3 격리).
/// 본문엔 없고 별칭에만 있는 한국어 term 으로 chunks_fts 를 MATCH 하면 0행.
#[test]
fn aliases_dont_leak_into_body_fts() {
let env = common::TestEnv::new();
let store = open_store_with_document(&env);
let doc = DocumentId("d".repeat(32));
// 본문 "Rust ownership and borrowing" 에 "메모리" 없음, 별칭에만 있음.
let chunk = base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into()));
store.put_chunks(&doc, &[chunk]).unwrap();
let body_hits: i64 = env.with_conn(|c| {
c.query_row(
"SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH 'text : (\"메모리\")'",
[],
|r| r.get(0),
)
});
assert_eq!(body_hits, 0, "별칭 term 이 본문 chunks_fts 로 누출되면 안 된다");
}
/// Task 2 리뷰 M1: 빈 문자열 별칭은 색인하지 않는다(trigger 가드
/// `AND new.aliases <> ''`). producer 가 Some("") 를 넘겨도 무용한 행이
/// chunk_aliases_fts 에 쌓이지 않아야 한다.
#[test]
fn empty_string_alias_not_indexed() {
let env = common::TestEnv::new();
let store = open_store_with_document(&env);
let doc = DocumentId("d".repeat(32));
let chunk = base_chunk(&"e".repeat(32), &doc, Some(String::new()));
store.put_chunks(&doc, &[chunk]).unwrap();
let n: i64 = env.with_conn(|c| {
c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0))
});
assert_eq!(n, 0, "빈 문자열 별칭은 chunk_aliases_fts 에 색인되면 안 된다");
}

View File

@@ -16,7 +16,10 @@ CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5(
tokenize = 'unicode61'
);
CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN
-- 가드 `IS NOT NULL AND <> ''`: producer 가 Some("") 를 넘겨도 내용 없는
-- 행이 chunk_aliases_fts 에 쌓이지 않게 한다(Task 2 리뷰 M1).
CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks
WHEN new.aliases IS NOT NULL AND new.aliases <> '' BEGIN
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
VALUES (new.chunk_id, new.doc_id, new.aliases);
END;
@@ -26,7 +29,8 @@ END;
CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN
DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id;
INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases)
SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL;
SELECT new.chunk_id, new.doc_id, new.aliases
WHERE new.aliases IS NOT NULL AND new.aliases <> '';
END;
-- in-process LRU search cache 무효화 (V009 와 동일 패턴).