refactor: normalize review and comment storage

This commit is contained in:
2026-10-18 10:29:27 +02:00
parent 3c270ed7b5
commit b062a79bb5
5 changed files with 351 additions and 93 deletions
+117 -45
View File
@@ -151,6 +151,10 @@ fn incompatible_database_schemas_are_preserved() {
for schema in [
"CREATE TABLE other (value TEXT); PRAGMA user_version = 42",
"CREATE TABLE other (value TEXT); PRAGMA user_version = 1",
"CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL,
revision INTEGER NOT NULL, document TEXT NOT NULL) STRICT;
INSERT INTO reviews VALUES (1, '/old/repo', 1, '{\"version\":3}');
PRAGMA user_version = 1",
"",
] {
let directory = directory();
@@ -165,44 +169,73 @@ fn incompatible_database_schemas_are_preserved() {
}
#[test]
fn malformed_or_mismatched_database_documents_are_rejected() {
let other = directory();
fn malformed_database_rows_are_rejected_before_loading_snapshots() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone());
review.comments.push(Comment {
id: 1,
body: "Feedback".into(),
resolved: false,
anchor: Anchor {
revision: "snapshot".into(),
path: "file.rs".into(),
side: Side::After,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 0, column: 1 },
content: "界\n".into(),
},
});
store.save(&mut review).unwrap();
let id = review.storage_id.unwrap();
let mut wrong_root = serde_json::to_value(&review).unwrap();
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
let mut invalid_version = serde_json::to_value(&review).unwrap();
invalid_version["version"] = 1.into();
let mut inherited_identity = serde_json::to_value(&review).unwrap();
inherited_identity["storage_id"] = id.into();
for document in [
"{".to_owned(),
serde_json::to_string(&wrong_root).unwrap(),
serde_json::to_string(&invalid_version).unwrap(),
serde_json::to_string(&inherited_identity).unwrap(),
for update in [
"resolved = 2",
"side = 'invalid'",
"id = '0'",
"id = '18446744073709551616'",
"start_row = -1",
"end_column = -1",
] {
store
.connection
.execute(
"UPDATE reviews SET document = ?1 WHERE id = ?2",
params![document, id],
)
.unwrap();
assert!(store.load(&root, id).is_err());
assert!(store.list(&root).is_err());
assert!(
store
.connection
.execute(
&format!("UPDATE comments SET {update} WHERE review_id = ?1"),
[id],
)
.is_err()
);
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
}
store
.connection
.execute_batch("PRAGMA ignore_check_constraints = ON")
.unwrap();
for update in [
"path = '../outside'",
"end_column = 2",
"resolved = 2",
"side = 'invalid'",
"anchor_revision = ''",
"id = '0'",
"position = 1",
] {
store
.connection
.execute(
&format!("UPDATE comments SET {update} WHERE review_id = ?1"),
[id],
)
.unwrap();
assert!(store.load(&root, id).is_err(), "{update}");
assert!(store.list(&root).is_err(), "{update}");
store.save(&mut review).unwrap();
}
store
.connection
.execute(
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE id = ?2",
"UPDATE comments SET content = CAST(zeroblob(?1) AS TEXT) WHERE review_id = ?2",
params![LIMIT as i64 + 1, id],
)
.unwrap();
@@ -406,43 +439,82 @@ fn persistence_validates_anchors_and_preserves_files_on_failure() {
}
#[test]
fn stored_version_two_is_rejected_without_rewriting_it() {
fn comment_rows_preserve_order_full_ids_and_atomic_replacement() {
let directory = directory();
let root = directory.0.canonicalize().unwrap();
let mut store = Store::open(&root.join("storage")).unwrap();
let mut review = Review::new(root.clone());
review.comments = [u64::MAX, 7]
.into_iter()
.map(|id| Comment {
id,
body: format!("Feedback {id}"),
resolved: id == 7,
anchor: Anchor {
revision: "snapshot".into(),
path: "source.rs".into(),
side: Side::Before,
start: crate::plugin::TextPosition { row: 0, column: 0 },
end: crate::plugin::TextPosition { row: 0, column: 1 },
content: "界\n".into(),
},
})
.collect();
review.summary = "Original".into();
store.save(&mut review).unwrap();
let mut old = serde_json::to_value(&review).unwrap();
old["version"] = 2.into();
let document = serde_json::to_string(&old).unwrap();
let id = review.storage_id.unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
assert!(
store
.connection
.execute(
"UPDATE comments SET review_id = ?1 WHERE review_id = ?2",
params![id + 1, id],
)
.is_err()
);
assert!(
store
.connection
.execute("UPDATE comments SET id = '7' WHERE review_id = ?1", [id],)
.is_err()
);
store
.connection
.execute(
"UPDATE reviews SET document = ?1 WHERE id = ?2",
params![document, review.storage_id.unwrap()],
.execute_batch(
"CREATE TEMP TRIGGER reject_comment BEFORE INSERT ON comments
WHEN NEW.id = '7' BEGIN SELECT RAISE(ABORT, 'blocked comment'); END",
)
.unwrap();
let revision = review.storage_revision;
let original = review.comments.clone();
review.summary = "Unsaved".into();
review.comments[0].body = "Changed".into();
assert!(
store
.load(&root, review.storage_id.unwrap())
.save(&mut review)
.unwrap_err()
.contains("Unsupported review version 2")
.contains("blocked comment")
);
assert!(
store
.list(&root)
.unwrap_err()
.contains("Unsupported review version 2")
);
let preserved: String = store
assert_eq!(review.storage_revision, revision);
let loaded = store.load(&root, id).unwrap();
assert_eq!(loaded.summary, "Original");
assert_eq!(loaded.comments, original);
assert_eq!(loaded.storage_revision, revision);
store
.connection
.query_row(
"SELECT document FROM reviews WHERE id = ?1",
[review.storage_id.unwrap()],
|row| row.get(0),
)
.execute_batch("DROP TRIGGER reject_comment")
.unwrap();
assert_eq!(preserved, document);
review.comments.reverse();
store.save(&mut review).unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
review.comments.pop();
store.save(&mut review).unwrap();
assert_eq!(store.load(&root, id).unwrap().comments, review.comments);
review.comments.clear();
store.save(&mut review).unwrap();
assert!(store.load(&root, id).unwrap().comments.is_empty());
assert_eq!(store.list(&root).unwrap()[0].summary, "Unsaved");
}
#[test]