989 lines
32 KiB
Rust
989 lines
32 KiB
Rust
//! M1 validation integration tests (Steps 19-21).
|
|
//!
|
|
//! - Step 19: Round-trip tests (create -> publish -> read -> verify)
|
|
//! - Step 20: Golden-file comparisons against fixture files
|
|
//! - Step 21: Metadata diff coverage matrix (every diffable field covered)
|
|
|
|
use bds_core::db::Database;
|
|
use bds_core::db::fts::ensure_fts_tables;
|
|
use bds_core::db::queries::project::insert_project;
|
|
use bds_core::db::queries::script as qs;
|
|
use bds_core::db::queries::template as qtpl;
|
|
use bds_core::engine::media;
|
|
use bds_core::engine::meta;
|
|
use bds_core::engine::metadata_diff;
|
|
use bds_core::engine::post;
|
|
use bds_core::engine::tag;
|
|
use bds_core::model::{
|
|
PostStatus, Project, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus,
|
|
};
|
|
use bds_core::util::frontmatter::{
|
|
ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_template_file,
|
|
read_translation_file, write_script_file, write_template_file,
|
|
};
|
|
use bds_core::util::sidecar::{read_sidecar, read_translation_sidecar};
|
|
use image::DynamicImage;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use tempfile::TempDir;
|
|
|
|
// ── Common helpers ──────────────────────────────────────────────────
|
|
|
|
fn make_test_project(id: &str, slug: &str) -> Project {
|
|
Project {
|
|
id: id.to_string(),
|
|
name: format!("Project {id}"),
|
|
slug: slug.to_string(),
|
|
description: Some("A test project".into()),
|
|
data_path: Some("/data".into()),
|
|
is_active: false,
|
|
created_at: 1000,
|
|
updated_at: 2000,
|
|
}
|
|
}
|
|
|
|
fn setup() -> (Database, TempDir) {
|
|
let db = Database::open_in_memory().unwrap();
|
|
db.migrate().unwrap();
|
|
ensure_fts_tables(db.conn()).unwrap();
|
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
|
let dir = TempDir::new().unwrap();
|
|
// Seed meta directory and tags.json for tag operations
|
|
fs::create_dir_all(dir.path().join("meta")).unwrap();
|
|
fs::write(dir.path().join("meta/tags.json"), "[]").unwrap();
|
|
(db, dir)
|
|
}
|
|
|
|
fn fixture_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../fixtures/compatibility-projects/rfc1437-sample")
|
|
}
|
|
|
|
fn create_test_image(dir: &Path) -> PathBuf {
|
|
let path = dir.join("test_image.png");
|
|
let img = DynamicImage::new_rgb8(10, 10);
|
|
img.save(&path).unwrap();
|
|
path
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Step 19: Round-trip Integration Tests
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn test_post_create_publish_read_roundtrip() {
|
|
let (db, dir) = setup();
|
|
|
|
// Create a post with full metadata
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Round-Trip Post",
|
|
Some("This is the body of the post."),
|
|
vec!["rust".into(), "testing".into()],
|
|
vec!["tech".into()],
|
|
Some("Alice"),
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(created.status, PostStatus::Draft);
|
|
|
|
// Publish the post
|
|
let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();
|
|
assert_eq!(published.status, PostStatus::Published);
|
|
assert!(!published.file_path.is_empty());
|
|
|
|
// Read the file from disk
|
|
let abs_path = dir.path().join(&published.file_path);
|
|
assert!(abs_path.exists(), "published file must exist on disk");
|
|
let file_content = fs::read_to_string(&abs_path).unwrap();
|
|
|
|
// Parse via read_post_file
|
|
let (fm, body) = read_post_file(&file_content).unwrap();
|
|
|
|
// Verify all frontmatter fields match the DB values
|
|
assert_eq!(fm.id, published.id);
|
|
assert_eq!(fm.title, "Round-Trip Post");
|
|
assert_eq!(fm.slug, published.slug);
|
|
assert_eq!(fm.status, "published");
|
|
assert_eq!(fm.tags, vec!["rust", "testing"]);
|
|
assert_eq!(fm.categories, vec!["tech"]);
|
|
assert_eq!(fm.author.as_deref(), Some("Alice"));
|
|
assert_eq!(fm.language.as_deref(), Some("en"));
|
|
assert!(fm.published_at.is_some());
|
|
assert_eq!(fm.published_at, published.published_at);
|
|
assert_eq!(fm.created_at, published.created_at);
|
|
|
|
// Verify body content matches
|
|
assert_eq!(body, "This is the body of the post.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_post_translation_roundtrip() {
|
|
let (db, dir) = setup();
|
|
|
|
// Create post + publish
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Translated Post",
|
|
Some("English body."),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Create translation before publishing
|
|
let _translation = post::upsert_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&created.id,
|
|
"de",
|
|
"Uebersetzter Beitrag",
|
|
Some("Zusammenfassung"),
|
|
Some("Deutscher Inhalt."),
|
|
)
|
|
.unwrap();
|
|
|
|
// Publish (also publishes translations)
|
|
let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();
|
|
|
|
// Find the translation file on disk
|
|
let trans_rel_path = format!(
|
|
"posts/{}/{}/{}.de.md",
|
|
&published.file_path[6..10], // YYYY
|
|
&published.file_path[11..13], // MM
|
|
published.slug
|
|
);
|
|
let trans_abs = dir.path().join(&trans_rel_path);
|
|
assert!(trans_abs.exists(), "translation file must exist on disk");
|
|
|
|
// Parse the translation file
|
|
let content = fs::read_to_string(&trans_abs).unwrap();
|
|
let (fm, body) = read_translation_file(&content).unwrap();
|
|
|
|
// Verify fields
|
|
assert_eq!(fm.translation_for, created.id);
|
|
assert_eq!(fm.language, "de");
|
|
assert_eq!(fm.title, "Uebersetzter Beitrag");
|
|
assert_eq!(fm.excerpt.as_deref(), Some("Zusammenfassung"));
|
|
assert_eq!(body, "Deutscher Inhalt.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_import_sidecar_roundtrip() {
|
|
let (db, dir) = setup();
|
|
|
|
// Create a test PNG
|
|
let source_path = create_test_image(dir.path());
|
|
|
|
// Import media
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
&source_path,
|
|
"test_photo.png",
|
|
Some("My Photo Title"),
|
|
Some("A test photo alt text"),
|
|
Some("Caption text"),
|
|
Some("Bob"),
|
|
Some("de"),
|
|
vec!["nature".into(), "macro".into()],
|
|
)
|
|
.unwrap();
|
|
|
|
// Read sidecar from disk
|
|
let abs_sidecar = dir.path().join(&imported.sidecar_path);
|
|
assert!(abs_sidecar.exists(), "sidecar file must exist");
|
|
let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
|
|
let sc = read_sidecar(&sidecar_content).unwrap();
|
|
|
|
// Verify sidecar fields match DB
|
|
assert_eq!(sc.id, imported.id);
|
|
assert_eq!(sc.original_name, "test_photo.png");
|
|
assert_eq!(sc.mime_type, "image/png");
|
|
assert!(sc.size > 0);
|
|
assert_eq!(sc.width, Some(10));
|
|
assert_eq!(sc.height, Some(10));
|
|
assert_eq!(sc.title.as_deref(), Some("My Photo Title"));
|
|
assert_eq!(sc.alt.as_deref(), Some("A test photo alt text"));
|
|
assert_eq!(sc.caption.as_deref(), Some("Caption text"));
|
|
assert_eq!(sc.author.as_deref(), Some("Bob"));
|
|
assert_eq!(sc.language.as_deref(), Some("de"));
|
|
assert_eq!(sc.tags, vec!["nature", "macro"]);
|
|
assert_eq!(sc.created_at, imported.created_at);
|
|
assert_eq!(sc.updated_at, imported.updated_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_translation_roundtrip() {
|
|
let (db, dir) = setup();
|
|
|
|
let source_path = create_test_image(dir.path());
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
&source_path,
|
|
"photo.png",
|
|
Some("Title"),
|
|
Some("Alt"),
|
|
None,
|
|
None,
|
|
None,
|
|
vec![],
|
|
)
|
|
.unwrap();
|
|
|
|
// Create translation
|
|
let _t = media::upsert_media_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&imported.id,
|
|
"fr",
|
|
Some("Titre francais"),
|
|
Some("Texte alternatif"),
|
|
Some("Legende"),
|
|
)
|
|
.unwrap();
|
|
|
|
// Read the translation sidecar from disk
|
|
let trans_sidecar_rel =
|
|
bds_core::util::media_translation_sidecar_path(&imported.file_path, "fr");
|
|
let abs_trans_sidecar = dir.path().join(&trans_sidecar_rel);
|
|
assert!(abs_trans_sidecar.exists(), "translation sidecar must exist");
|
|
|
|
let content = fs::read_to_string(&abs_trans_sidecar).unwrap();
|
|
let sc = read_translation_sidecar(&content).unwrap();
|
|
|
|
assert_eq!(sc.translation_for, imported.id);
|
|
assert_eq!(sc.language, "fr");
|
|
assert_eq!(sc.title.as_deref(), Some("Titre francais"));
|
|
assert_eq!(sc.alt.as_deref(), Some("Texte alternatif"));
|
|
assert_eq!(sc.caption.as_deref(), Some("Legende"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tag_sync_roundtrip() {
|
|
let (db, dir) = setup();
|
|
|
|
// Create tags via the engine
|
|
tag::create_tag(db.conn(), dir.path(), "p1", "rust", Some("#ff0000")).unwrap();
|
|
tag::create_tag(db.conn(), dir.path(), "p1", "go", None).unwrap();
|
|
tag::create_tag(db.conn(), dir.path(), "p1", "python", Some("#3776ab")).unwrap();
|
|
|
|
// Read tags.json from disk
|
|
let entries = meta::read_tags_json(dir.path()).unwrap();
|
|
|
|
// Tags should be sorted case-insensitively
|
|
assert_eq!(entries.len(), 3);
|
|
assert_eq!(entries[0].name, "go");
|
|
assert_eq!(entries[1].name, "python");
|
|
assert_eq!(entries[2].name, "rust");
|
|
|
|
// Verify colors
|
|
assert!(entries[0].color.is_none());
|
|
assert_eq!(entries[1].color.as_deref(), Some("#3776ab"));
|
|
assert_eq!(entries[2].color.as_deref(), Some("#ff0000"));
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Step 20: Golden-file Comparisons
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
#[test]
|
|
fn test_golden_post_esmeralda() {
|
|
let path = fixture_dir().join("posts/2005/11/esmeralda.md");
|
|
let expected = fs::read_to_string(&path).unwrap();
|
|
let (fm, body) = read_post_file(&expected).unwrap();
|
|
|
|
// Verify parsed fixture data
|
|
assert_eq!(fm.id, "40a83ab1-423d-4310-aac4-642d84675007");
|
|
assert_eq!(fm.title, "Esmeralda");
|
|
assert_eq!(fm.slug, "esmeralda");
|
|
assert_eq!(fm.status, "published");
|
|
assert_eq!(fm.language.as_deref(), Some("es"));
|
|
assert_eq!(fm.created_at, 1131883200000);
|
|
assert_eq!(fm.published_at, Some(1131883200000));
|
|
assert_eq!(
|
|
fm.tags,
|
|
vec!["fotografie", "makro", "natur", "spinne", "tiere"]
|
|
);
|
|
assert_eq!(fm.categories, vec!["picture"]);
|
|
|
|
// Write back via write_post_file, then reconstruct using from_post-style
|
|
let yaml = fm.to_yaml();
|
|
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
|
|
|
|
// Compare byte-for-byte with fixture
|
|
assert_eq!(actual, expected, "golden output mismatch for esmeralda.md");
|
|
}
|
|
|
|
#[test]
|
|
fn test_golden_translation_esmeralda_en() {
|
|
let path = fixture_dir().join("posts/2005/11/esmeralda.en.md");
|
|
let expected = fs::read_to_string(&path).unwrap();
|
|
let (fm, body) = read_translation_file(&expected).unwrap();
|
|
|
|
// Verify parsed data
|
|
assert_eq!(fm.translation_for, "40a83ab1-423d-4310-aac4-642d84675007");
|
|
assert_eq!(fm.language, "en");
|
|
assert_eq!(fm.title, "Esmeralda");
|
|
|
|
// Write back and compare byte-for-byte
|
|
let yaml = fm.to_yaml();
|
|
let actual = bds_core::util::frontmatter::format_frontmatter(&yaml, &body);
|
|
assert_eq!(
|
|
actual, expected,
|
|
"golden output mismatch for esmeralda.en.md"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_golden_sidecar() {
|
|
let path = fixture_dir().join("media/2005/11/eb0cf9d7-6fbd-4b74-9be3-759d6e16f240.jpg.meta");
|
|
let expected = fs::read_to_string(&path).unwrap();
|
|
let sc = read_sidecar(&expected).unwrap();
|
|
|
|
// Verify parsed data
|
|
assert_eq!(sc.id, "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240");
|
|
assert_eq!(sc.original_name, "CRW_1121.jpg");
|
|
assert_eq!(sc.mime_type, "image/jpeg");
|
|
assert_eq!(sc.size, 706358);
|
|
assert_eq!(sc.width, Some(1800));
|
|
assert_eq!(sc.height, Some(1200));
|
|
assert_eq!(sc.title.as_deref(), Some("Esmeralda"));
|
|
|
|
// Write back via to_string() and compare
|
|
let actual = sc.to_string();
|
|
assert_eq!(
|
|
actual.trim(),
|
|
expected.trim(),
|
|
"golden output mismatch for media sidecar"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_golden_meta_files() {
|
|
let fd = fixture_dir();
|
|
|
|
// project.json: parse and verify key fields
|
|
let project_json = fs::read_to_string(fd.join("meta/project.json")).unwrap();
|
|
let project_meta: bds_core::model::ProjectMetadata =
|
|
serde_json::from_str(&project_json).unwrap();
|
|
assert_eq!(project_meta.name, "rfc1437");
|
|
assert_eq!(project_meta.main_language.as_deref(), Some("de"));
|
|
assert_eq!(project_meta.default_author.as_deref(), Some("hugo"));
|
|
assert_eq!(project_meta.max_posts_per_page, 50);
|
|
assert_eq!(
|
|
project_meta.public_url.as_deref(),
|
|
Some("https://www.rfc1437.de")
|
|
);
|
|
assert!(project_meta.semantic_similarity_enabled);
|
|
assert_eq!(project_meta.blog_languages, vec!["en"]);
|
|
|
|
// categories.json: parse and verify sorted order
|
|
let categories_json = fs::read_to_string(fd.join("meta/categories.json")).unwrap();
|
|
let categories: Vec<String> = serde_json::from_str(&categories_json).unwrap();
|
|
assert_eq!(
|
|
categories,
|
|
vec![
|
|
"article",
|
|
"aside",
|
|
"kochbuch",
|
|
"metaowl",
|
|
"page",
|
|
"picture",
|
|
"spielelog",
|
|
"wiki"
|
|
]
|
|
);
|
|
// Verify they are sorted
|
|
let mut sorted = categories.clone();
|
|
sorted.sort();
|
|
assert_eq!(categories, sorted, "categories should be sorted");
|
|
|
|
// tags.json: parse and verify structure
|
|
let tags_json = fs::read_to_string(fd.join("meta/tags.json")).unwrap();
|
|
let tags: Vec<bds_core::model::TagEntry> = serde_json::from_str(&tags_json).unwrap();
|
|
assert!(!tags.is_empty());
|
|
// Verify sorted by name
|
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
|
let mut sorted_names = names.clone();
|
|
sorted_names.sort_by_key(|a| a.to_lowercase());
|
|
assert_eq!(names, sorted_names, "tags should be sorted by name");
|
|
// Spot-check a known tag
|
|
assert!(tags.iter().any(|t| t.name == "rust"));
|
|
assert!(tags.iter().any(|t| t.name == "fotografie"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_golden_template() {
|
|
let path = fixture_dir().join("templates/testvorlage.liquid");
|
|
let expected = fs::read_to_string(&path).unwrap();
|
|
let (fm, body) = read_template_file(&expected).unwrap();
|
|
|
|
// Verify parsed data
|
|
assert_eq!(fm.id, "38704737-b7e7-4dd4-b010-9208bcf80ef6");
|
|
assert_eq!(
|
|
fm.project_id.as_deref(),
|
|
Some("1979237c-034d-41f6-99a0-f35eb57b3f6c")
|
|
);
|
|
assert_eq!(fm.slug, "testvorlage");
|
|
assert_eq!(fm.title, "Testvorlage");
|
|
assert_eq!(fm.kind, "post");
|
|
assert!(fm.enabled);
|
|
assert_eq!(fm.version, 3);
|
|
|
|
// Write back and compare byte-for-byte
|
|
let actual = write_template_file(&fm, &body);
|
|
assert_eq!(
|
|
actual, expected,
|
|
"golden output mismatch for testvorlage.liquid"
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Step 21: Metadata Diff Coverage Matrix
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
// ── Post diff: 13 fields ────────────────────────────────────────────
|
|
|
|
/// Helper: create a post, publish it, then modify a specific field in the
|
|
/// file on disk. Run the diff and return all detected field names.
|
|
fn post_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
|
let (db, dir) = setup();
|
|
|
|
let created = post::create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Diff Test Post",
|
|
Some("body text"),
|
|
vec!["tag1".into()],
|
|
vec!["cat1".into()],
|
|
Some("Author"),
|
|
Some("en"),
|
|
Some("tpl-slug"),
|
|
)
|
|
.unwrap();
|
|
|
|
let published = post::publish_post(db.conn(), dir.path(), &created.id).unwrap();
|
|
let abs_path = dir.path().join(&published.file_path);
|
|
let content = fs::read_to_string(&abs_path).unwrap();
|
|
|
|
// Apply modification to file
|
|
let modified = modify_fn(&content);
|
|
fs::write(&abs_path, modified).unwrap();
|
|
|
|
let report = metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
|
|
|
report
|
|
.diffs
|
|
.iter()
|
|
.filter(|d| d.entity_type == "post")
|
|
.flat_map(|d| d.fields.iter())
|
|
.map(|f| f.field_name.clone())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_title() {
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace("title: Diff Test Post", "title: CHANGED Title")
|
|
});
|
|
assert!(
|
|
fields.contains(&"title".to_string()),
|
|
"expected 'title' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_slug() {
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace("slug: diff-test-post", "slug: changed-slug")
|
|
});
|
|
assert!(
|
|
fields.contains(&"slug".to_string()),
|
|
"expected 'slug' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_status() {
|
|
let fields =
|
|
post_diff_for_field(|content| content.replace("status: published", "status: draft"));
|
|
assert!(
|
|
fields.contains(&"status".to_string()),
|
|
"expected 'status' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_tags() {
|
|
let fields = post_diff_for_field(|content| content.replace(" - tag1", " - changed-tag"));
|
|
assert!(
|
|
fields.contains(&"tags".to_string()),
|
|
"expected 'tags' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_categories() {
|
|
let fields = post_diff_for_field(|content| content.replace(" - cat1", " - changed-cat"));
|
|
assert!(
|
|
fields.contains(&"categories".to_string()),
|
|
"expected 'categories' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_excerpt() {
|
|
// The post was created without excerpt. Add one in the file.
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace(
|
|
"templateSlug: tpl-slug",
|
|
"templateSlug: tpl-slug\nexcerpt: Added excerpt",
|
|
)
|
|
});
|
|
assert!(
|
|
fields.contains(&"excerpt".to_string()),
|
|
"expected 'excerpt' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_author() {
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace("author: Author", "author: Different Author")
|
|
});
|
|
assert!(
|
|
fields.contains(&"author".to_string()),
|
|
"expected 'author' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_language() {
|
|
let fields = post_diff_for_field(|content| content.replace("language: en", "language: de"));
|
|
assert!(
|
|
fields.contains(&"language".to_string()),
|
|
"expected 'language' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_do_not_translate() {
|
|
// Post was created with do_not_translate=false (omitted from file).
|
|
// Add it to the file.
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace(
|
|
"templateSlug: tpl-slug",
|
|
"doNotTranslate: true\ntemplateSlug: tpl-slug",
|
|
)
|
|
});
|
|
assert!(
|
|
fields.contains(&"doNotTranslate".to_string()),
|
|
"expected 'doNotTranslate' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_template_slug() {
|
|
let fields = post_diff_for_field(|content| {
|
|
content.replace("templateSlug: tpl-slug", "templateSlug: different-tpl")
|
|
});
|
|
assert!(
|
|
fields.contains(&"templateSlug".to_string()),
|
|
"expected 'templateSlug' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_created_at() {
|
|
let fields = post_diff_for_field(|content| {
|
|
// Replace the createdAt ISO timestamp with a different one
|
|
// The fixture will have something like createdAt: '2026-...'
|
|
// Replace with a very different date
|
|
let mut result = content.to_string();
|
|
if let Some(start) = result.find("createdAt: '") {
|
|
let val_start = start + "createdAt: '".len();
|
|
if let Some(end) = result[val_start..].find('\'') {
|
|
let old_val = &result[val_start..val_start + end].to_string();
|
|
result = result.replace(
|
|
&format!("createdAt: '{old_val}'"),
|
|
"createdAt: '2000-01-01T00:00:00.000Z'",
|
|
);
|
|
}
|
|
}
|
|
result
|
|
});
|
|
assert!(
|
|
fields.contains(&"createdAt".to_string()),
|
|
"expected 'createdAt' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_updated_at() {
|
|
let fields = post_diff_for_field(|content| {
|
|
let mut result = content.to_string();
|
|
if let Some(start) = result.find("updatedAt: '") {
|
|
let val_start = start + "updatedAt: '".len();
|
|
if let Some(end) = result[val_start..].find('\'') {
|
|
let old_val = &result[val_start..val_start + end].to_string();
|
|
result = result.replace(
|
|
&format!("updatedAt: '{old_val}'"),
|
|
"updatedAt: '2000-01-01T00:00:00.000Z'",
|
|
);
|
|
}
|
|
}
|
|
result
|
|
});
|
|
assert!(
|
|
fields.contains(&"updatedAt".to_string()),
|
|
"expected 'updatedAt' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_post_published_at() {
|
|
let fields = post_diff_for_field(|content| {
|
|
let mut result = content.to_string();
|
|
if let Some(start) = result.find("publishedAt: '") {
|
|
let val_start = start + "publishedAt: '".len();
|
|
if let Some(end) = result[val_start..].find('\'') {
|
|
let old_val = &result[val_start..val_start + end].to_string();
|
|
result = result.replace(
|
|
&format!("publishedAt: '{old_val}'"),
|
|
"publishedAt: '2000-01-01T00:00:00.000Z'",
|
|
);
|
|
}
|
|
}
|
|
result
|
|
});
|
|
assert!(
|
|
fields.contains(&"publishedAt".to_string()),
|
|
"expected 'publishedAt' in diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
// ── Media diff: 6 fields ────────────────────────────────────────────
|
|
|
|
/// Helper: import media, then modify a field in the sidecar file.
|
|
/// Returns the list of diff field names detected.
|
|
fn media_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
|
let (db, dir) = setup();
|
|
|
|
let source_path = create_test_image(dir.path());
|
|
let imported = media::import_media(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
&source_path,
|
|
"photo.png",
|
|
Some("Original Title"),
|
|
Some("Original Alt"),
|
|
Some("Original Caption"),
|
|
Some("Original Author"),
|
|
Some("en"),
|
|
vec!["original-tag".into()],
|
|
)
|
|
.unwrap();
|
|
|
|
let abs_sidecar = dir.path().join(&imported.sidecar_path);
|
|
let content = fs::read_to_string(&abs_sidecar).unwrap();
|
|
|
|
let modified = modify_fn(&content);
|
|
fs::write(&abs_sidecar, modified).unwrap();
|
|
|
|
let report = metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
|
|
|
report
|
|
.diffs
|
|
.iter()
|
|
.filter(|d| d.entity_type == "media")
|
|
.flat_map(|d| d.fields.iter())
|
|
.map(|f| f.field_name.clone())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_title() {
|
|
let fields = media_diff_for_field(|content| {
|
|
content.replace("title: \"Original Title\"", "title: \"Changed Title\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"title".to_string()),
|
|
"expected 'title' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_alt() {
|
|
let fields = media_diff_for_field(|content| {
|
|
content.replace("alt: \"Original Alt\"", "alt: \"Changed Alt\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"alt".to_string()),
|
|
"expected 'alt' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_caption() {
|
|
let fields = media_diff_for_field(|content| {
|
|
content.replace(
|
|
"caption: \"Original Caption\"",
|
|
"caption: \"Changed Caption\"",
|
|
)
|
|
});
|
|
assert!(
|
|
fields.contains(&"caption".to_string()),
|
|
"expected 'caption' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_author() {
|
|
let fields = media_diff_for_field(|content| {
|
|
content.replace("author: \"Original Author\"", "author: \"Changed Author\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"author".to_string()),
|
|
"expected 'author' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_tags() {
|
|
let fields = media_diff_for_field(|content| {
|
|
content.replace("tags: [\"original-tag\"]", "tags: [\"changed-tag\"]")
|
|
});
|
|
assert!(
|
|
fields.contains(&"tags".to_string()),
|
|
"expected 'tags' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_media_language() {
|
|
let fields = media_diff_for_field(|content| content.replace("language: en", "language: de"));
|
|
assert!(
|
|
fields.contains(&"language".to_string()),
|
|
"expected 'language' in media diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
// ── Template diff: 4 fields ─────────────────────────────────────────
|
|
|
|
/// Helper: insert a template in DB + write file, then modify a field in
|
|
/// the file. Returns the list of diff field names.
|
|
fn template_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
|
let (db, dir) = setup();
|
|
|
|
let tpl = Template {
|
|
id: "tpl-diff-1".to_string(),
|
|
project_id: "p1".to_string(),
|
|
slug: "diff-template".to_string(),
|
|
title: "Diff Template".to_string(),
|
|
kind: TemplateKind::Post,
|
|
enabled: true,
|
|
version: 1,
|
|
file_path: "templates/diff-template.liquid".to_string(),
|
|
status: TemplateStatus::Published,
|
|
content: None,
|
|
created_at: 1000,
|
|
updated_at: 2000,
|
|
};
|
|
qtpl::insert_template(db.conn(), &tpl).unwrap();
|
|
|
|
// Write matching template file
|
|
let fm = TemplateFrontmatter {
|
|
id: "tpl-diff-1".to_string(),
|
|
project_id: Some("p1".to_string()),
|
|
slug: "diff-template".to_string(),
|
|
title: "Diff Template".to_string(),
|
|
kind: "post".to_string(),
|
|
enabled: true,
|
|
version: 1,
|
|
created_at: 1000,
|
|
updated_at: 2000,
|
|
};
|
|
let file_content = write_template_file(&fm, "<div>template body</div>");
|
|
let tpl_dir = dir.path().join("templates");
|
|
fs::create_dir_all(&tpl_dir).unwrap();
|
|
fs::write(tpl_dir.join("diff-template.liquid"), &file_content).unwrap();
|
|
|
|
// Apply modification
|
|
let modified = modify_fn(&file_content);
|
|
fs::write(tpl_dir.join("diff-template.liquid"), modified).unwrap();
|
|
|
|
let report = metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
|
|
|
report
|
|
.diffs
|
|
.iter()
|
|
.filter(|d| d.entity_type == "template")
|
|
.flat_map(|d| d.fields.iter())
|
|
.map(|f| f.field_name.clone())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_template_title() {
|
|
let fields = template_diff_for_field(|content| {
|
|
content.replace("title: \"Diff Template\"", "title: \"Changed Template\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"title".to_string()),
|
|
"expected 'title' in template diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_template_kind() {
|
|
let fields =
|
|
template_diff_for_field(|content| content.replace("kind: \"post\"", "kind: \"list\""));
|
|
assert!(
|
|
fields.contains(&"kind".to_string()),
|
|
"expected 'kind' in template diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_template_enabled() {
|
|
let fields =
|
|
template_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
|
assert!(
|
|
fields.contains(&"enabled".to_string()),
|
|
"expected 'enabled' in template diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_template_version() {
|
|
let fields = template_diff_for_field(|content| content.replace("version: 1", "version: 99"));
|
|
assert!(
|
|
fields.contains(&"version".to_string()),
|
|
"expected 'version' in template diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
// ── Script diff: 5 fields ───────────────────────────────────────────
|
|
|
|
/// Helper: insert a script in DB + write file, then modify a field in the
|
|
/// file. Returns the list of diff field names.
|
|
fn script_diff_for_field(modify_fn: impl FnOnce(&str) -> String) -> Vec<String> {
|
|
let (db, dir) = setup();
|
|
|
|
let script = Script {
|
|
id: "scr-diff-1".to_string(),
|
|
project_id: "p1".to_string(),
|
|
slug: "diff-script".to_string(),
|
|
title: "Diff Script".to_string(),
|
|
kind: ScriptKind::Utility,
|
|
entrypoint: "main".to_string(),
|
|
enabled: true,
|
|
version: 1,
|
|
file_path: "scripts/diff-script.lua".to_string(),
|
|
status: ScriptStatus::Published,
|
|
content: None,
|
|
created_at: 1000,
|
|
updated_at: 2000,
|
|
};
|
|
qs::insert_script(db.conn(), &script).unwrap();
|
|
|
|
// Write matching script file
|
|
let fm = ScriptFrontmatter {
|
|
id: "scr-diff-1".to_string(),
|
|
project_id: Some("p1".to_string()),
|
|
slug: "diff-script".to_string(),
|
|
title: "Diff Script".to_string(),
|
|
kind: "utility".to_string(),
|
|
entrypoint: "main".to_string(),
|
|
enabled: true,
|
|
version: 1,
|
|
created_at: 1000,
|
|
updated_at: 2000,
|
|
};
|
|
let file_content = write_script_file(&fm, "-- lua script\nreturn 1");
|
|
let scripts_dir = dir.path().join("scripts");
|
|
fs::create_dir_all(&scripts_dir).unwrap();
|
|
fs::write(scripts_dir.join("diff-script.lua"), &file_content).unwrap();
|
|
|
|
// Apply modification
|
|
let modified = modify_fn(&file_content);
|
|
fs::write(scripts_dir.join("diff-script.lua"), modified).unwrap();
|
|
|
|
let report = metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap();
|
|
|
|
report
|
|
.diffs
|
|
.iter()
|
|
.filter(|d| d.entity_type == "script")
|
|
.flat_map(|d| d.fields.iter())
|
|
.map(|f| f.field_name.clone())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_script_title() {
|
|
let fields = script_diff_for_field(|content| {
|
|
content.replace("title: \"Diff Script\"", "title: \"Changed Script\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"title".to_string()),
|
|
"expected 'title' in script diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_script_kind() {
|
|
let fields = script_diff_for_field(|content| {
|
|
content.replace("kind: \"utility\"", "kind: \"transform\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"kind".to_string()),
|
|
"expected 'kind' in script diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_script_entrypoint() {
|
|
let fields = script_diff_for_field(|content| {
|
|
content.replace("entrypoint: \"main\"", "entrypoint: \"run\"")
|
|
});
|
|
assert!(
|
|
fields.contains(&"entrypoint".to_string()),
|
|
"expected 'entrypoint' in script diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_script_enabled() {
|
|
let fields =
|
|
script_diff_for_field(|content| content.replace("enabled: true", "enabled: false"));
|
|
assert!(
|
|
fields.contains(&"enabled".to_string()),
|
|
"expected 'enabled' in script diff fields, got: {fields:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diff_detects_script_version() {
|
|
let fields = script_diff_for_field(|content| content.replace("version: 1", "version: 42"));
|
|
assert!(
|
|
fields.contains(&"version".to_string()),
|
|
"expected 'version' in script diff fields, got: {fields:?}"
|
|
);
|
|
}
|