use chrono::{DateTime, Utc};
use crate::admin::AdminModel;
use crate::http::FormData;
use rustio_macros::RustioAdmin;
#[derive(Debug, RustioAdmin)]
#[allow(dead_code)]
pub struct StampedFixture {
pub id: i64,
pub title: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[test]
fn auto_timestamp_fields_are_not_editable() {
let fields = StampedFixture::FIELDS;
let created = fields
.iter()
.find(|f| f.name == "created_at")
.expect("created_at field present in FIELDS");
let updated = fields
.iter()
.find(|f| f.name == "updated_at")
.expect("updated_at field present in FIELDS");
assert!(!created.editable, "created_at must be non-editable so the form filters it out");
assert!(!updated.editable, "updated_at must be non-editable so the form filters it out");
let title = fields
.iter()
.find(|f| f.name == "title")
.expect("title field present");
assert!(title.editable, "regular fields must remain editable");
}
#[test]
fn from_form_accepts_submission_without_auto_timestamps() {
let before = Utc::now();
let form = FormData::from_urlencoded("title=hello");
let model = StampedFixture::from_form(&form).expect("from_form succeeds with no timestamps");
let after = Utc::now();
assert_eq!(model.title, "hello");
assert!(
model.created_at >= before && model.created_at <= after,
"created_at should be defaulted to Utc::now()",
);
assert!(
model.updated_at >= before && model.updated_at <= after,
"updated_at should be defaulted to Utc::now()",
);
}
#[derive(Debug, RustioAdmin)]
#[allow(dead_code)]
pub struct HardeningFixture {
pub id: i64,
pub title: String,
pub author_id: i64,
pub edition: Option<i64>,
pub published_at: DateTime<Utc>,
}
#[test]
fn invalid_number_input() {
let form = FormData::from_urlencoded(
"title=hi&author_id=abc&edition=&published_at=2026-01-01T12:00",
);
let errs = HardeningFixture::from_form(&form)
.expect_err("from_form must reject invalid i64");
assert!(
errs.iter().any(|e| e.contains("Author Id") && e.contains("number")),
"expected an Author Id number error; got: {errs:?}"
);
}
#[test]
fn invalid_optional_number_input() {
let form = FormData::from_urlencoded(
"title=hi&author_id=1&edition=abc&published_at=2026-01-01T12:00",
);
let errs = HardeningFixture::from_form(&form)
.expect_err("from_form must reject garbage in Option<i64>");
assert!(
errs.iter().any(|e| e.contains("Edition") && e.contains("number")),
"expected an Edition number error; got: {errs:?}"
);
let form2 = FormData::from_urlencoded(
"title=hi&author_id=1&edition=&published_at=2026-01-01T12:00",
);
let model = HardeningFixture::from_form(&form2)
.expect("empty Option<i64> input should NOT error");
assert_eq!(model.edition, None);
let form3 = FormData::from_urlencoded("title=hi&author_id=1&published_at=2026-01-01T12:00");
let model = HardeningFixture::from_form(&form3)
.expect("missing Option<i64> field should NOT error");
assert_eq!(model.edition, None);
}
#[test]
fn invalid_datetime_input() {
let form = FormData::from_urlencoded(
"title=hi&author_id=1&edition=&published_at=tomorrow",
);
let errs = HardeningFixture::from_form(&form)
.expect_err("from_form must reject malformed datetime");
assert!(
errs.iter().any(|e| e.contains("Published At") && e.contains("not a valid date")),
"expected a Published At date error; got: {errs:?}"
);
}
#[test]
fn whitespace_only_string_treated_as_empty() {
let form = FormData::from_urlencoded(
"title=%20%20%20&author_id=1&edition=&published_at=2026-01-01T12:00",
);
let errs = HardeningFixture::from_form(&form)
.expect_err("whitespace-only required String must trigger required error");
assert!(
errs.iter().any(|e| e.contains("Title") && e.contains("required")),
"expected a Title required error; got: {errs:?}"
);
let form = FormData::from_urlencoded(
"title=%20%20hi%20%20&author_id=1&edition=&published_at=2026-01-01T12:00",
);
let model = HardeningFixture::from_form(&form).expect("trimmed String parses");
assert_eq!(model.title, "hi", "leading/trailing whitespace must be stripped");
}
#[test]
fn datetime_display_value_uses_iso_separator() {
use chrono::TimeZone;
use rustio_core::admin::AdminModel;
let dt = chrono::Utc.with_ymd_and_hms(2026, 1, 15, 9, 30, 0).unwrap();
let model = HardeningFixture {
id: 0,
title: "x".into(),
author_id: 1,
edition: None,
published_at: dt,
};
let pairs = model.display_values();
let (_, val) = pairs
.iter()
.find(|(k, _)| k == "published_at")
.expect("published_at value emitted");
assert_eq!(
val, "2026-01-15T09:30",
"DateTime display value must use the literal `T` separator that \
<input type=\"datetime-local\"> requires"
);
assert!(!val.contains(' '), "no space allowed in datetime-local value");
assert_eq!(val.len(), 16, "expected YYYY-MM-DDTHH:MM (16 chars)");
}
#[derive(Debug, RustioAdmin)]
#[allow(dead_code)]
pub struct OptionalStringFixture {
pub id: i64,
pub title: String,
pub notes: Option<String>,
}
impl rustio_core::Model for OptionalStringFixture {
const TABLE: &'static str = "optional_string_fixtures";
const COLUMNS: &'static [&'static str] = &["id", "title", "notes"];
const INSERT_COLUMNS: &'static [&'static str] = &["title", "notes"];
fn id(&self) -> i64 { self.id }
fn from_row(_: rustio_core::Row<'_>) -> Result<Self, rustio_core::Error> {
unimplemented!("not used in this test — display_values is the only path under test")
}
fn insert_values(&self) -> Vec<rustio_core::Value> { Vec::new() }
}
#[test]
fn optional_string_field_display_values_works_for_some_and_none() {
use rustio_core::admin::AdminModel;
let with_value = OptionalStringFixture {
id: 0,
title: "x".into(),
notes: Some("hello world".into()),
};
let pairs = with_value.display_values();
let (_, val) = pairs.iter().find(|(k, _)| k == "notes").expect("notes pair present");
assert_eq!(val, "hello world", "Some(\"hello world\") must surface as the inner string");
let without_value = OptionalStringFixture {
id: 0,
title: "x".into(),
notes: None,
};
let pairs = without_value.display_values();
let (_, val) = pairs.iter().find(|(k, _)| k == "notes").expect("notes pair present");
assert_eq!(val, "", "None must surface as the empty string, never the literal \"None\"");
}