use candid::Encode;
use icydb::db::{
EntityFieldDescription, EntityIndexDescription, EntityRelationDescription,
EntitySchemaCheckDescription, EntitySchemaDescription,
};
use crate::observability::test_support::{decode_schema_check_report, render_schema_check_report};
fn field(
name: String,
slot: Option<u16>,
kind: String,
nullable: bool,
primary_key: bool,
queryable: bool,
origin: String,
) -> EntityFieldDescription {
EntityFieldDescription::new(
name,
slot,
kind,
nullable,
primary_key,
queryable,
origin,
Some(if nullable { "null" } else { "required" }.to_string()),
None,
None,
None,
Some(1),
Some("reject".to_string()),
None,
None,
)
}
fn defaulted_field(name: &str, slot: u16, hash: &str) -> EntityFieldDescription {
EntityFieldDescription::new(
name.to_string(),
Some(slot),
"nat16".to_string(),
false,
false,
true,
"generated".to_string(),
Some("default".to_string()),
Some(format!("slot_payload(bytes=4, sha256={hash})")),
Some(4),
Some(hash.to_string()),
Some(1),
Some("reject".to_string()),
None,
None,
)
}
fn entity(
path: String,
name: String,
primary_key: String,
fields: Vec<EntityFieldDescription>,
indexes: Vec<EntityIndexDescription>,
relations: Vec<EntityRelationDescription>,
) -> EntitySchemaDescription {
let primary_key_fields = vec![primary_key.clone()];
EntitySchemaDescription::new(
path,
name,
primary_key,
primary_key_fields,
fields,
indexes,
relations,
1,
1,
)
}
#[test]
fn decode_schema_check_report_accepts_generated_response_shape() {
let response: Result<Vec<EntitySchemaCheckDescription>, icydb::Error> = Ok(Vec::new());
let candid_bytes = Encode!(&response).expect("schema check response should encode");
let decoded = decode_schema_check_report(candid_bytes.as_slice())
.expect("schema check response should decode")
.expect("schema check response should be ok");
assert_eq!(decoded.len(), 0);
}
#[test]
fn schema_check_report_renders_generated_accepted_drift() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
),
field(
"name".to_string(),
Some(1),
"Text".to_string(),
false,
false,
true,
"generated".to_string(),
),
],
vec![EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["name".to_string()],
"generated".to_string(),
)],
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
),
field(
"name".to_string(),
Some(1),
"Text".to_string(),
false,
false,
true,
"generated".to_string(),
),
field(
"nickname".to_string(),
Some(2),
"Text".to_string(),
true,
false,
true,
"ddl".to_string(),
),
],
vec![
EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["name".to_string()],
"generated".to_string(),
),
EntityIndexDescription::new(
"character_lower_name_idx".to_string(),
false,
vec!["expr:v1:LOWER(name)".to_string()],
"ddl".to_string(),
),
],
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("IcyDB schema check"));
assert!(text.contains("status: drift"));
assert!(text.contains("accepted-only fields: 1"));
assert!(text.contains("DDL-owned indexes: 1"));
assert!(text.contains("mismatches: 0"));
assert!(text.contains("Character drift"));
assert!(text.contains("accepted-only field"));
assert!(text.contains("DDL index"));
assert!(text.contains("recommendations"));
assert!(
text.contains("ok: DDL-owned accepted fields are preserved catalog drift across upgrade")
);
assert!(text.contains(
"action: add DDL-owned fields to Rust schema only when an explicit adoption flow exists"
));
assert!(text.contains("ok: DDL-owned accepted indexes remain planner-visible catalog drift"));
}
#[test]
fn schema_check_report_renders_empty_report_sections() {
let text = render_schema_check_report(&[]);
assert!(text.contains("IcyDB schema check"));
assert!(text.contains("status: ok"));
assert!(text.contains("entities: 0"));
assert!(text.contains("entities\n None"));
assert!(text.contains("accepted drift\n None"));
assert!(text.contains("mismatches\n None"));
assert!(text.contains("recommendations\n ok: generated and accepted schema are aligned"));
}
#[test]
fn schema_check_report_compares_ordered_primary_key_fields() {
let generated = EntitySchemaDescription::new(
"demo::Placement".to_string(),
"Placement".to_string(),
"tenant_id, local_id".to_string(),
vec!["tenant_id".to_string(), "local_id".to_string()],
Vec::new(),
Vec::new(),
Vec::new(),
1,
1,
);
let accepted = EntitySchemaDescription::new(
"demo::Placement".to_string(),
"Placement".to_string(),
"tenant_id, local_id".to_string(),
vec!["local_id".to_string(), "tenant_id".to_string()],
Vec::new(),
Vec::new(),
Vec::new(),
1,
1,
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("primary key"));
assert!(text.contains("tenant_id, local_id"));
assert!(text.contains("local_id, tenant_id"));
}
#[test]
fn schema_check_report_treats_accepted_only_generated_fields_as_mismatch() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
)],
Vec::new(),
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
),
field(
"retired_generated".to_string(),
Some(1),
"Text".to_string(),
false,
false,
true,
"generated".to_string(),
),
],
Vec::new(),
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("accepted-only fields: 0"));
assert!(text.contains("mismatches: 1"));
assert!(text.contains("accepted-only generated field"));
assert!(
text.contains(
"fix: resolve generated-vs-accepted mismatches before relying on schema parity"
)
);
assert!(text.contains(
"fix: accepted-only generated fields require explicit catalog-native physical removal"
));
}
#[test]
fn schema_check_report_recommends_additive_transition_for_generated_only_fields() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
),
field(
"title".to_string(),
Some(1),
"Text".to_string(),
true,
false,
true,
"generated".to_string(),
),
],
Vec::new(),
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![field(
"id".to_string(),
Some(0),
"Id".to_string(),
false,
true,
true,
"generated".to_string(),
)],
Vec::new(),
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("generated-only field"));
assert!(text.contains(
"action: generated-only fields need an accepted additive transition before deploy"
));
}
#[test]
fn schema_check_report_recommends_explicit_flows_for_default_and_nullability_drift() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
defaulted_field("level", 1, "aaaaaaaaaaaaaaaa"),
field(
"nickname".to_string(),
Some(2),
"Text".to_string(),
false,
false,
true,
"generated".to_string(),
),
],
Vec::new(),
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![
defaulted_field("level", 1, "bbbbbbbbbbbbbbbb"),
field(
"nickname".to_string(),
Some(2),
"Text".to_string(),
true,
false,
true,
"generated".to_string(),
),
],
Vec::new(),
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(
text.contains("fix: default drift requires an explicit ALTER COLUMN SET/DROP DEFAULT flow")
);
assert!(text.contains(
"fix: nullability drift requires an explicit ALTER COLUMN SET/DROP NOT NULL flow"
));
}
#[test]
fn schema_check_report_recommends_explicit_flows_for_generated_index_drift() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
Vec::new(),
vec![
EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["name".to_string()],
"generated".to_string(),
),
EntityIndexDescription::new(
"idx_character__class_name".to_string(),
false,
vec!["class_name".to_string()],
"generated".to_string(),
),
],
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
Vec::new(),
vec![
EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["nickname".to_string()],
"generated".to_string(),
),
EntityIndexDescription::new(
"idx_character__level".to_string(),
false,
vec!["level".to_string()],
"generated".to_string(),
),
],
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("index"));
assert!(text.contains("accepted-only generated index"));
assert!(text.contains("generated-only index"));
assert!(text.contains(
"action: generated-only indexes need accepted index publication before planner parity"
));
assert!(text.contains(
"fix: accepted-only generated indexes require explicit index removal or generated schema restoration"
));
assert!(text.contains(
"fix: index contract drift requires explicit index replacement, not same-name mutation"
));
}
#[test]
fn schema_check_report_treats_generated_index_origin_drift_as_mismatch() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
Vec::new(),
vec![EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["name".to_string()],
"ddl".to_string(),
)],
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
Vec::new(),
vec![EntityIndexDescription::new(
"idx_character__name".to_string(),
false,
vec!["name".to_string()],
"generated".to_string(),
)],
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("idx_character__name:name:no:ddl"));
assert!(text.contains("idx_character__name:name:no:generated"));
assert!(text.contains(
"fix: index contract drift requires explicit index replacement, not same-name mutation"
));
}
#[test]
fn schema_check_report_renders_readable_default_mismatch() {
let generated = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![defaulted_field("level", 1, "aaaaaaaaaaaaaaaa")],
Vec::new(),
Vec::new(),
);
let accepted = entity(
"demo::Character".to_string(),
"Character".to_string(),
"id".to_string(),
vec![defaulted_field("level", 1, "bbbbbbbbbbbbbbbb")],
Vec::new(),
Vec::new(),
);
let text =
render_schema_check_report(&[EntitySchemaCheckDescription::new(generated, accepted)]);
assert!(text.contains("status: mismatch"));
assert!(text.contains("slot_payload(bytes=4, sha256=aaaaaaaaaaaaaaaa)"));
assert!(text.contains("slot_payload(bytes=4, sha256=bbbbbbbbbbbbbbbb)"));
}