use std::future::Future;
use std::pin::Pin;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminFieldKind {
Text,
TextArea,
Integer,
Float,
Boolean,
Date,
DateTime,
Select(Vec<SelectOption>),
Hidden,
Password,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)] pub struct AdminField {
pub name: &'static str,
pub label: String,
pub kind: AdminFieldKind,
pub list_display: bool,
pub searchable: bool,
pub filterable: bool,
pub required: bool,
pub editable: bool,
pub sortable: bool,
pub encrypted: bool,
pub encrypted_visible: bool,
}
impl AdminField {
#[must_use]
pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
let editable = !matches!(kind, AdminFieldKind::Hidden);
Self {
name,
label: humanize_field_name(name),
kind,
list_display: true,
searchable: false,
filterable: false,
required: true,
editable,
sortable: true,
encrypted: false,
encrypted_visible: false,
}
}
#[must_use]
pub const fn encrypted(mut self) -> Self {
self.encrypted = true;
self
}
#[must_use]
pub const fn encrypted_visible(mut self) -> Self {
self.encrypted = true;
self.encrypted_visible = true;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub const fn searchable(mut self) -> Self {
self.searchable = true;
self
}
#[must_use]
pub const fn filterable(mut self) -> Self {
self.filterable = true;
self
}
#[must_use]
pub const fn optional(mut self) -> Self {
self.required = false;
self
}
#[must_use]
pub const fn readonly(mut self) -> Self {
self.editable = false;
self
}
#[must_use]
pub const fn hide_from_list(mut self) -> Self {
self.list_display = false;
self
}
}
pub struct AdminAction {
pub name: &'static str,
pub label: String,
pub style: ActionStyle,
pub confirm: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionStyle {
Default,
Primary,
Danger,
}
#[derive(Debug, Clone)]
pub struct AdminHistoryEntry {
pub id: i64,
pub actor: String,
pub op: String,
pub request_id: Option<String>,
pub changes: Vec<Value>,
pub recorded_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct AdminHistoryPage {
pub entries: Vec<AdminHistoryEntry>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
impl AdminHistoryPage {
#[must_use]
pub const fn total_pages(&self) -> u64 {
if self.per_page == 0 {
return 0;
}
self.total.div_ceil(self.per_page)
}
#[must_use]
pub const fn has_next_page(&self) -> bool {
self.page < self.total_pages()
}
}
pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
#[derive(Debug, thiserror::Error)]
pub enum AdminError {
#[error("Record not found")]
NotFound,
#[error("Validation failed: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(String),
#[error("{0}")]
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CsvImportMode {
#[default]
Insert,
DryRun,
}
impl CsvImportMode {
#[must_use]
pub fn from_form_value(s: &str) -> Option<Self> {
match s {
"insert" | "Insert" => Some(Self::Insert),
"dry_run" | "DryRun" | "dry-run" => Some(Self::DryRun),
_ => None,
}
}
}
#[derive(Debug)]
pub enum AdminImportRowResult {
Inserted,
Updated,
Skipped,
RowError(String),
FieldError { column: String, message: String },
}
#[derive(Debug, Default, Clone)]
pub struct AdminImportReport {
pub inserted: u64,
pub updated: u64,
pub skipped: u64,
pub errors: Vec<AdminImportError>,
}
#[derive(Debug, Clone)]
pub struct AdminImportError {
pub line: u64,
pub column: Option<String>,
pub message: String,
}
pub trait AdminModel: Send + Sync + 'static {
fn slug(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn display_name_plural(&self) -> &'static str;
fn fields(&self) -> Vec<AdminField>;
fn actions(&self) -> Vec<AdminAction> {
let mut acts = vec![AdminAction {
name: "delete",
label: "Delete selected".to_owned(),
style: ActionStyle::Danger,
confirm: true,
}];
if self.supports_soft_delete() {
acts.push(AdminAction {
name: "restore",
label: "Restore selected".to_owned(),
style: ActionStyle::Default,
confirm: false,
});
acts.push(AdminAction {
name: "purge",
label: "Purge selected".to_owned(),
style: ActionStyle::Danger,
confirm: true,
});
}
acts
}
fn list(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
params: ListParams,
) -> AdminFuture<'_, ListResult>;
fn get(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, Option<Value>>;
fn create(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
data: Value,
) -> AdminFuture<'_, Value>;
fn update(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
data: Value,
) -> AdminFuture<'_, Value>;
fn delete(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, ()>;
fn supports_soft_delete(&self) -> bool {
false
}
fn restore<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
_id: i64,
) -> AdminFuture<'a, ()> {
Box::pin(async move {
Err(AdminError::Other(
"this model does not support soft delete; \
override supports_soft_delete() to return true and implement restore()"
.to_owned(),
))
})
}
fn purge<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
_id: i64,
) -> AdminFuture<'a, ()> {
Box::pin(async move {
Err(AdminError::Other(
"this model does not support soft delete; \
override supports_soft_delete() to return true and implement purge()"
.to_owned(),
))
})
}
fn list_deleted<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
_params: ListParams,
) -> AdminFuture<'a, ListResult> {
Box::pin(async move {
Err(AdminError::Other(
"this model does not support soft delete; \
override supports_soft_delete() to return true and implement list_deleted()"
.to_owned(),
))
})
}
fn execute_action(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
action: &str,
ids: Vec<i64>,
) -> AdminFuture<'_, u64> {
let action = action.to_owned();
let pool = pool.clone();
Box::pin(async move {
match action.as_str() {
"delete" => {
let mut count: u64 = 0;
for id in ids {
self.delete(&pool, id).await?;
count += 1;
}
Ok(count)
}
"restore" => {
let mut count: u64 = 0;
for id in ids {
self.restore(&pool, id).await?;
count += 1;
}
Ok(count)
}
"purge" => {
let mut count: u64 = 0;
for id in ids {
self.purge(&pool, id).await?;
count += 1;
}
Ok(count)
}
other => Err(AdminError::Other(format!(
"unhandled bulk action '{other}'; \
override AdminModel::execute_action to support it"
))),
}
})
}
fn record_display(&self, record: &Value) -> String {
record_id(record).map_or_else(
|| format!("{} <no id>", self.display_name()),
|id| format!("{} #{id}", self.display_name()),
)
}
fn per_page(&self) -> u64 {
25
}
fn count(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
) -> AdminFuture<'_, u64> {
let params = ListParams {
page: 1,
per_page: 0,
..Default::default()
};
let fut = self.list(pool, params);
Box::pin(async move { fut.await.map(|r| r.total) })
}
fn supports_csv_export(&self) -> bool {
false
}
fn csv_export_columns(&self) -> Vec<&'static str> {
self.fields()
.into_iter()
.filter(|f| {
!matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden) && !f.encrypted
})
.map(|f| f.name)
.collect()
}
fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
columns
.iter()
.map(|col| {
record
.get(*col)
.map(|v| match v {
Value::String(s) => escape_csv_formula(s),
Value::Null => String::new(),
other => other.to_string(),
})
.unwrap_or_default()
})
.collect()
}
fn supports_csv_import(&self) -> bool {
false
}
fn import_csv_row<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
_line: u64,
_row: std::collections::HashMap<String, String>,
_mode: CsvImportMode,
) -> AdminFuture<'a, AdminImportRowResult> {
Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
}
fn has_history(&self) -> bool {
false
}
fn get_history<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
_record_id: i64,
_page: u64,
_per_page: u64,
) -> AdminFuture<'a, AdminHistoryPage> {
Box::pin(async move {
Err(AdminError::Other(
"this model does not have version history enabled; \
use #[repository(Model, versioned = true)] to opt in"
.to_owned(),
))
})
}
}
impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
fn from(e: autumn_web::version_history::VersionEntry) -> Self {
Self {
id: e.id,
actor: e.actor,
op: e.op.to_string(),
request_id: e.request_id,
changes: e
.changes
.into_iter()
.map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
.collect(),
recorded_at: e.recorded_at,
}
}
}
impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
fn from(vp: autumn_web::version_history::VersionPage) -> Self {
Self {
entries: vp
.entries
.into_iter()
.map(AdminHistoryEntry::from)
.collect(),
total: vp.total,
page: vp.page,
per_page: vp.per_page,
}
}
}
#[must_use]
pub fn record_id(record: &Value) -> Option<i64> {
record.get("id").and_then(Value::as_i64)
}
#[derive(Debug, Clone, Default)]
pub struct ListParams {
pub page: u64,
pub per_page: u64,
pub search: Option<String>,
pub sort_by: Option<String>,
pub sort_dir: SortDirection,
pub filters: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortDirection {
#[default]
Asc,
Desc,
}
impl SortDirection {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Asc => "asc",
Self::Desc => "desc",
}
}
#[must_use]
pub const fn flipped(self) -> Self {
match self {
Self::Asc => Self::Desc,
Self::Desc => Self::Asc,
}
}
}
#[derive(Debug, Clone)]
pub struct ListResult {
pub records: Vec<Value>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
impl ListResult {
#[must_use]
pub const fn total_pages(&self) -> u64 {
if self.per_page == 0 {
return 0;
}
self.total.div_ceil(self.per_page)
}
}
fn escape_csv_formula(s: &str) -> String {
match s.bytes().next() {
Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
let mut out = String::with_capacity(s.len() + 1);
out.push('\'');
out.push_str(s);
out
}
_ => s.to_owned(),
}
}
fn humanize_field_name(name: &str) -> String {
let mut s = String::with_capacity(name.len());
for (i, word) in name.split('_').enumerate() {
if i > 0 {
s.push(' ');
}
let mut chars = word.chars();
if let Some(c) = chars.next() {
s.extend(c.to_uppercase());
s.extend(chars);
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct DeletingModel {
deleted: Mutex<Vec<i64>>,
fail_on: Option<i64>,
}
impl AdminModel for DeletingModel {
fn slug(&self) -> &'static str {
"tracked"
}
fn display_name(&self) -> &'static str {
"Tracked"
}
fn display_name_plural(&self) -> &'static str {
"Tracked"
}
fn fields(&self) -> Vec<AdminField> {
vec![]
}
fn list(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_params: ListParams,
) -> AdminFuture<'_, ListResult> {
Box::pin(async {
Ok(ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
})
})
}
fn get(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
) -> AdminFuture<'_, Option<Value>> {
Box::pin(async { Ok(None) })
}
fn create(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn update(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn delete(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
id: i64,
) -> AdminFuture<'_, ()> {
let deleted = &self.deleted;
let fail_on = self.fail_on;
Box::pin(async move {
if Some(id) == fail_on {
return Err(AdminError::Database("simulated failure".into()));
}
deleted.lock().unwrap().push(id);
Ok(())
})
}
}
#[derive(Default)]
struct SoftDeleteModel {
restored: Mutex<Vec<i64>>,
purged: Mutex<Vec<i64>>,
}
impl AdminModel for SoftDeleteModel {
fn slug(&self) -> &'static str {
"soft"
}
fn display_name(&self) -> &'static str {
"Soft"
}
fn display_name_plural(&self) -> &'static str {
"Softs"
}
fn fields(&self) -> Vec<AdminField> {
vec![]
}
fn list(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_params: ListParams,
) -> AdminFuture<'_, ListResult> {
Box::pin(async {
Ok(ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
})
})
}
fn get(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
) -> AdminFuture<'_, Option<Value>> {
Box::pin(async { Ok(None) })
}
fn create(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn update(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn delete(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
) -> AdminFuture<'_, ()> {
Box::pin(async { Ok(()) })
}
fn supports_soft_delete(&self) -> bool {
true
}
fn restore<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
id: i64,
) -> AdminFuture<'a, ()> {
Box::pin(async move {
self.restored.lock().unwrap().push(id);
Ok(())
})
}
fn purge<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
id: i64,
) -> AdminFuture<'a, ()> {
Box::pin(async move {
self.purged.lock().unwrap().push(id);
Ok(())
})
}
fn list_deleted<'a>(
&'a self,
_pool: &'a diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_params: ListParams,
) -> AdminFuture<'a, ListResult> {
Box::pin(async {
Ok(ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
})
})
}
}
fn dummy_pool()
-> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::deadpool::Pool;
let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
"postgresql://test",
);
Pool::builder(mgr).build().expect("build pool")
}
#[tokio::test]
async fn default_execute_action_delete_invokes_delete_for_each_id() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let count = model
.execute_action(&pool, "delete", vec![10, 20, 30])
.await
.expect("default delete should succeed");
assert_eq!(count, 3);
assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
}
#[tokio::test]
async fn default_execute_action_delete_aborts_on_first_failure() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: Some(20),
};
let pool = dummy_pool();
let err = model
.execute_action(&pool, "delete", vec![10, 20, 30])
.await
.expect_err("delete should propagate failure");
assert!(matches!(err, AdminError::Database(_)));
assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
}
#[tokio::test]
async fn default_execute_action_rejects_unknown_action() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let err = model
.execute_action(&pool, "promote", vec![1])
.await
.expect_err("unknown actions must error, not silently no-op");
assert!(
matches!(err, AdminError::Other(msg) if msg.contains("promote")),
"error should name the unhandled action"
);
assert!(model.deleted.lock().unwrap().is_empty());
}
#[test]
fn humanize_converts_snake_case() {
assert_eq!(humanize_field_name("created_at"), "Created At");
assert_eq!(humanize_field_name("user_id"), "User Id");
assert_eq!(humanize_field_name("name"), "Name");
assert_eq!(humanize_field_name(""), "");
}
#[test]
fn list_result_total_pages() {
let result = ListResult {
records: vec![],
total: 25,
page: 1,
per_page: 10,
};
assert_eq!(result.total_pages(), 3);
}
#[test]
fn list_result_total_pages_exact() {
let result = ListResult {
records: vec![],
total: 20,
page: 1,
per_page: 10,
};
assert_eq!(result.total_pages(), 2);
}
#[test]
fn list_result_total_pages_zero_per_page() {
let result = ListResult {
records: vec![],
total: 20,
page: 1,
per_page: 0,
};
assert_eq!(result.total_pages(), 0);
}
#[test]
fn admin_field_builder() {
let field = AdminField::new("email", AdminFieldKind::Text)
.label("Email Address")
.searchable()
.filterable()
.optional();
assert_eq!(field.name, "email");
assert_eq!(field.label, "Email Address");
assert!(field.searchable);
assert!(field.filterable);
assert!(!field.required);
assert!(field.editable);
}
#[test]
fn record_id_extracts_numeric_id() {
assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
}
#[test]
fn record_id_returns_none_for_missing_or_non_numeric() {
assert_eq!(record_id(&serde_json::json!({})), None);
assert_eq!(record_id(&serde_json::json!({"id": null})), None);
assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
}
#[test]
fn hidden_fields_default_to_not_editable() {
let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
assert!(
!hidden.editable,
"Hidden fields must default to editable=false"
);
let text = AdminField::new("name", AdminFieldKind::Text);
assert!(text.editable);
}
#[test]
fn admin_model_supports_soft_delete_defaults_to_false() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
assert!(
!model.supports_soft_delete(),
"AdminModel::supports_soft_delete() must default to false"
);
}
#[tokio::test]
async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let err = model
.restore(&pool, 1)
.await
.expect_err("restore must error when supports_soft_delete() is false");
assert!(
matches!(err, AdminError::Other(_)),
"restore on non-soft-delete model must return AdminError::Other: {err:?}"
);
}
#[tokio::test]
async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let err = model
.purge(&pool, 1)
.await
.expect_err("purge must error when supports_soft_delete() is false");
assert!(
matches!(err, AdminError::Other(_)),
"purge on non-soft-delete model must return AdminError::Other: {err:?}"
);
}
#[tokio::test]
async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let params = ListParams {
page: 1,
per_page: 25,
..Default::default()
};
let err = model
.list_deleted(&pool, params)
.await
.expect_err("list_deleted must error when supports_soft_delete() is false");
assert!(
matches!(err, AdminError::Other(_)),
"list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
);
}
#[test]
fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let acts = model.actions();
assert_eq!(
acts.len(),
1,
"default model must advertise exactly one action"
);
assert_eq!(acts[0].name, "delete");
}
#[test]
fn actions_includes_restore_and_purge_when_soft_delete_supported() {
let model = SoftDeleteModel::default();
let acts = model.actions();
let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
assert!(
names.contains(&"restore"),
"soft-delete model must advertise restore action; got: {names:?}"
);
assert!(
names.contains(&"purge"),
"soft-delete model must advertise purge action; got: {names:?}"
);
}
#[tokio::test]
async fn execute_action_restore_dispatches_to_restore_method() {
let model = SoftDeleteModel::default();
let pool = dummy_pool();
let count = model
.execute_action(&pool, "restore", vec![10, 20])
.await
.expect("restore action should succeed on soft-delete model");
assert_eq!(
count, 2,
"restore action must return count of restored records"
);
assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
}
#[tokio::test]
async fn execute_action_purge_dispatches_to_purge_method() {
let model = SoftDeleteModel::default();
let pool = dummy_pool();
let count = model
.execute_action(&pool, "purge", vec![5])
.await
.expect("purge action should succeed on soft-delete model");
assert_eq!(count, 1, "purge action must return count of purged records");
assert_eq!(*model.purged.lock().unwrap(), vec![5]);
}
#[test]
fn admin_model_has_history_defaults_to_false() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
assert!(
!model.has_history(),
"AdminModel::has_history() must default to false"
);
}
#[tokio::test]
async fn admin_model_get_history_returns_error_when_not_opted_in() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let err = model
.get_history(&pool, 42, 1, 25)
.await
.expect_err("get_history must error when has_history() is false");
assert!(
matches!(err, AdminError::Other(_)),
"get_history on non-versioned model must return AdminError::Other: {err:?}"
);
}
#[test]
fn admin_history_page_total_pages() {
let page = AdminHistoryPage {
entries: vec![],
total: 51,
page: 1,
per_page: 25,
};
assert_eq!(page.total_pages(), 3);
}
#[test]
fn admin_history_page_has_next_page() {
let page = AdminHistoryPage {
entries: vec![],
total: 50,
page: 1,
per_page: 25,
};
assert!(page.has_next_page());
}
#[test]
fn admin_history_page_no_next_on_last() {
let page = AdminHistoryPage {
entries: vec![],
total: 50,
page: 2,
per_page: 25,
};
assert!(!page.has_next_page());
}
#[test]
fn admin_history_page_zero_per_page() {
let page = AdminHistoryPage {
entries: vec![],
total: 10,
page: 1,
per_page: 0,
};
assert_eq!(page.total_pages(), 0);
}
#[test]
fn sort_direction_as_str_returns_correct_values() {
assert_eq!(SortDirection::Asc.as_str(), "asc");
assert_eq!(SortDirection::Desc.as_str(), "desc");
}
#[test]
fn sort_direction_flipped_returns_opposite() {
assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
}
#[test]
fn admin_field_readonly_sets_editable_false() {
let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
assert!(!field.editable, "readonly() must set editable = false");
}
#[test]
fn admin_field_hide_from_list_sets_list_display_false() {
let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
assert!(
!field.list_display,
"hide_from_list() must set list_display = false"
);
}
#[test]
fn admin_model_record_display_includes_display_name_and_id() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let record = serde_json::json!({"id": 7, "name": "foo"});
assert_eq!(model.record_display(&record), "Tracked #7");
}
#[test]
fn admin_model_record_display_placeholder_when_no_id() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let record = serde_json::json!({"name": "bar"});
assert_eq!(model.record_display(&record), "Tracked <no id>");
}
#[test]
fn admin_model_per_page_default_is_25() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
assert_eq!(model.per_page(), 25);
}
#[test]
fn version_page_converts_to_admin_history_page() {
use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
use chrono::Utc;
let entry = VersionEntry {
id: 1,
table_name: "posts".to_owned(),
record_id: 42,
op: VersionOp::Update,
actor: "admin".to_owned(),
request_id: Some("req-1".to_owned()),
changes: vec![ColumnChange::new(
"title",
Some(serde_json::json!("old")),
Some(serde_json::json!("new")),
)],
recorded_at: Utc::now(),
};
let vp = VersionPage {
entries: vec![entry],
total: 1,
page: 1,
per_page: 25,
};
let ap = AdminHistoryPage::from(vp);
assert_eq!(ap.total, 1);
assert_eq!(ap.page, 1);
assert_eq!(ap.per_page, 25);
assert_eq!(ap.entries.len(), 1);
let e = &ap.entries[0];
assert_eq!(e.id, 1);
assert_eq!(e.actor, "admin");
assert_eq!(e.op, "update");
assert_eq!(e.request_id.as_deref(), Some("req-1"));
assert_eq!(e.changes.len(), 1);
}
#[test]
fn version_entry_converts_to_admin_history_entry() {
use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
use chrono::Utc;
let entry = VersionEntry {
id: 7,
table_name: "users".to_owned(),
record_id: 3,
op: VersionOp::Delete,
actor: "system".to_owned(),
request_id: None,
changes: vec![ColumnChange::sensitive("password_digest")],
recorded_at: Utc::now(),
};
let admin_entry = AdminHistoryEntry::from(entry);
assert_eq!(admin_entry.id, 7);
assert_eq!(admin_entry.actor, "system");
assert_eq!(admin_entry.op, "delete");
assert!(admin_entry.request_id.is_none());
assert_eq!(admin_entry.changes.len(), 1);
}
#[test]
fn csv_import_mode_from_form_value_recognises_insert() {
assert_eq!(
CsvImportMode::from_form_value("insert"),
Some(CsvImportMode::Insert)
);
assert_eq!(
CsvImportMode::from_form_value("Insert"),
Some(CsvImportMode::Insert)
);
}
#[test]
fn csv_import_mode_from_form_value_recognises_dry_run() {
assert_eq!(
CsvImportMode::from_form_value("dry_run"),
Some(CsvImportMode::DryRun)
);
assert_eq!(
CsvImportMode::from_form_value("DryRun"),
Some(CsvImportMode::DryRun)
);
assert_eq!(
CsvImportMode::from_form_value("dry-run"),
Some(CsvImportMode::DryRun)
);
}
#[test]
fn csv_import_mode_from_form_value_rejects_unknown() {
assert_eq!(CsvImportMode::from_form_value("upsert"), None);
assert_eq!(CsvImportMode::from_form_value(""), None);
assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
}
#[test]
fn csv_import_mode_default_is_insert() {
assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
}
#[test]
fn escape_csv_formula_prefixes_equals_sign() {
assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
}
#[test]
fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
assert_eq!(escape_csv_formula("@A1"), "'@A1");
}
#[test]
fn escape_csv_formula_prefixes_tab_and_cr() {
assert_eq!(escape_csv_formula("\thello"), "'\thello");
assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
}
#[test]
fn escape_csv_formula_leaves_normal_strings_unchanged() {
assert_eq!(escape_csv_formula("hello world"), "hello world");
assert_eq!(escape_csv_formula("123"), "123");
assert_eq!(escape_csv_formula(""), "");
assert_eq!(escape_csv_formula("normal,value"), "normal,value");
}
#[test]
fn admin_model_supports_csv_export_defaults_to_false() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
assert!(
!model.supports_csv_export(),
"supports_csv_export must default to false to require explicit opt-in"
);
}
#[test]
fn admin_model_supports_csv_import_defaults_to_false() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
assert!(
!model.supports_csv_import(),
"supports_csv_import must default to false"
);
}
#[test]
fn csv_export_row_extracts_columns_and_escapes_formulas() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let record = serde_json::json!({
"id": 1,
"name": "Alice",
"formula": "=EVIL()",
"amount": 42.5,
"active": true,
"notes": null,
});
let columns = &[
"id", "name", "formula", "amount", "active", "notes", "missing",
];
let row = model.csv_export_row(columns, &record);
assert_eq!(row[0], "1");
assert_eq!(row[1], "Alice");
assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
assert_eq!(row[3], "42.5");
assert_eq!(row[4], "true");
assert_eq!(row[5], "", "null becomes empty string");
assert_eq!(row[6], "", "missing column becomes empty string");
}
}