1use std::future::Future;
4use std::pin::Pin;
5
6use chrono::{DateTime, Utc};
7use serde::Deserialize;
8use serde_json::Value;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum AdminFieldKind {
15 Text,
17 TextArea,
19 Integer,
21 Float,
23 Boolean,
25 Date,
27 DateTime,
29 Select(Vec<SelectOption>),
31 Hidden,
33 Password,
35 Json,
37}
38
39impl AdminFieldKind {
40 #[must_use]
56 pub const fn blank_submission_is_null(&self) -> bool {
57 matches!(
58 self,
59 Self::Integer
60 | Self::Float
61 | Self::Date
62 | Self::DateTime
63 | Self::Text
64 | Self::TextArea
65 | Self::Select(_)
66 | Self::Json
67 )
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SelectOption {
74 pub value: String,
75 pub label: String,
76}
77
78#[derive(Debug, Clone)]
80#[allow(clippy::struct_excessive_bools)] #[non_exhaustive]
82pub struct AdminField {
83 pub name: &'static str,
85 pub label: String,
87 pub kind: AdminFieldKind,
89 pub list_display: bool,
91 pub searchable: bool,
93 pub filterable: bool,
95 pub required: bool,
97 pub editable: bool,
99 pub create_only: bool,
102 pub sortable: bool,
104 pub encrypted: bool,
113 pub encrypted_visible: bool,
117}
118
119impl AdminField {
120 #[must_use]
128 pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
129 let editable = !matches!(kind, AdminFieldKind::Hidden);
130 Self {
131 name,
132 label: humanize_field_name(name),
133 kind,
134 list_display: true,
135 searchable: false,
136 filterable: false,
137 required: true,
138 editable,
139 create_only: false,
140 sortable: true,
141 encrypted: false,
142 encrypted_visible: false,
143 }
144 }
145
146 #[must_use]
149 pub const fn encrypted(mut self) -> Self {
150 self.encrypted = true;
151 self
152 }
153
154 #[must_use]
158 pub const fn encrypted_visible(mut self) -> Self {
159 self.encrypted = true;
160 self.encrypted_visible = true;
161 self
162 }
163
164 #[must_use]
166 pub fn label(mut self, label: impl Into<String>) -> Self {
167 self.label = label.into();
168 self
169 }
170
171 #[must_use]
173 pub const fn searchable(mut self) -> Self {
174 self.searchable = true;
175 self
176 }
177
178 #[must_use]
180 pub const fn filterable(mut self) -> Self {
181 self.filterable = true;
182 self
183 }
184
185 #[must_use]
187 pub const fn optional(mut self) -> Self {
188 self.required = false;
189 self
190 }
191
192 #[must_use]
194 pub const fn readonly(mut self) -> Self {
195 self.editable = false;
196 self
197 }
198
199 #[must_use]
203 pub const fn create_only(mut self) -> Self {
204 self.create_only = true;
205 self
206 }
207
208 #[must_use]
210 pub const fn hide_from_list(mut self) -> Self {
211 self.list_display = false;
212 self
213 }
214}
215
216pub struct AdminAction {
220 pub name: &'static str,
222 pub label: String,
224 pub style: ActionStyle,
226 pub confirm: bool,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ActionStyle {
233 Default,
235 Primary,
237 Danger,
239}
240
241#[derive(Debug, Clone)]
248pub struct AdminHistoryEntry {
249 pub id: i64,
251 pub actor: String,
253 pub op: String,
255 pub request_id: Option<String>,
257 pub changes: Vec<Value>,
259 pub recorded_at: DateTime<Utc>,
261}
262
263#[derive(Debug, Clone)]
265pub struct AdminHistoryPage {
266 pub entries: Vec<AdminHistoryEntry>,
267 pub total: u64,
268 pub page: u64,
269 pub per_page: u64,
270}
271
272impl AdminHistoryPage {
273 #[must_use]
275 pub const fn total_pages(&self) -> u64 {
276 if self.per_page == 0 {
277 return 0;
278 }
279 self.total.div_ceil(self.per_page)
280 }
281
282 #[must_use]
284 pub const fn has_next_page(&self) -> bool {
285 self.page < self.total_pages()
286 }
287}
288
289pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
293
294#[derive(Debug, thiserror::Error)]
296pub enum AdminError {
297 #[error("Record not found")]
298 NotFound,
299
300 #[error("Validation failed: {0}")]
301 Validation(String),
302
303 #[error("Database error: {0}")]
304 Database(String),
305
306 #[error("{0}")]
307 Other(String),
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum CsvImportMode {
315 #[default]
317 Insert,
318 DryRun,
320}
321
322impl CsvImportMode {
323 #[must_use]
329 pub fn from_form_value(s: &str) -> Option<Self> {
330 match s {
331 "insert" | "Insert" => Some(Self::Insert),
332 "dry_run" | "DryRun" | "dry-run" => Some(Self::DryRun),
333 _ => None,
334 }
335 }
336}
337
338#[derive(Debug)]
340pub enum AdminImportRowResult {
341 Inserted,
343 Updated,
345 Skipped,
347 RowError(String),
349 FieldError { column: String, message: String },
351}
352
353#[derive(Debug, Default, Clone)]
355pub struct AdminImportReport {
356 pub inserted: u64,
357 pub updated: u64,
358 pub skipped: u64,
359 pub errors: Vec<AdminImportError>,
360}
361
362#[derive(Debug, Clone)]
364pub struct AdminImportError {
365 pub line: u64,
367 pub column: Option<String>,
369 pub message: String,
371}
372
373pub trait AdminModel: Send + Sync + 'static {
385 fn slug(&self) -> &'static str;
388
389 fn display_name(&self) -> &'static str;
391
392 fn display_name_plural(&self) -> &'static str;
394
395 fn fields(&self) -> Vec<AdminField>;
397
398 fn actions(&self) -> Vec<AdminAction> {
404 let mut acts = vec![AdminAction {
405 name: "delete",
406 label: "Delete selected".to_owned(),
407 style: ActionStyle::Danger,
408 confirm: true,
409 }];
410 if self.supports_soft_delete() {
411 acts.push(AdminAction {
412 name: "restore",
413 label: "Restore selected".to_owned(),
414 style: ActionStyle::Default,
415 confirm: false,
416 });
417 acts.push(AdminAction {
418 name: "purge",
419 label: "Purge selected".to_owned(),
420 style: ActionStyle::Danger,
421 confirm: true,
422 });
423 }
424 acts
425 }
426
427 fn list(
431 &self,
432 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
433 params: ListParams,
434 ) -> AdminFuture<'_, ListResult>;
435
436 fn get(
438 &self,
439 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
440 id: i64,
441 ) -> AdminFuture<'_, Option<Value>>;
442
443 fn create(
445 &self,
446 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
447 data: Value,
448 ) -> AdminFuture<'_, Value>;
449
450 fn update(
452 &self,
453 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
454 id: i64,
455 data: Value,
456 ) -> AdminFuture<'_, Value>;
457
458 fn delete(
460 &self,
461 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
462 id: i64,
463 ) -> AdminFuture<'_, ()>;
464
465 fn supports_soft_delete(&self) -> bool {
469 false
470 }
471
472 fn restore<'a>(
477 &'a self,
478 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
479 _id: i64,
480 ) -> AdminFuture<'a, ()> {
481 Box::pin(async move {
482 Err(AdminError::Other(
483 "this model does not support soft delete; \
484 override supports_soft_delete() to return true and implement restore()"
485 .to_owned(),
486 ))
487 })
488 }
489
490 fn purge<'a>(
495 &'a self,
496 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
497 _id: i64,
498 ) -> AdminFuture<'a, ()> {
499 Box::pin(async move {
500 Err(AdminError::Other(
501 "this model does not support soft delete; \
502 override supports_soft_delete() to return true and implement purge()"
503 .to_owned(),
504 ))
505 })
506 }
507
508 fn list_deleted<'a>(
513 &'a self,
514 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
515 _params: ListParams,
516 ) -> AdminFuture<'a, ListResult> {
517 Box::pin(async move {
518 Err(AdminError::Other(
519 "this model does not support soft delete; \
520 override supports_soft_delete() to return true and implement list_deleted()"
521 .to_owned(),
522 ))
523 })
524 }
525
526 fn execute_action(
528 &self,
529 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
530 action: &str,
531 ids: Vec<i64>,
532 ) -> AdminFuture<'_, u64> {
533 let action = action.to_owned();
543 let pool = pool.clone();
544 Box::pin(async move {
545 match action.as_str() {
546 "delete" => {
547 let mut count: u64 = 0;
548 for id in ids {
549 self.delete(&pool, id).await?;
550 count += 1;
551 }
552 Ok(count)
553 }
554 "restore" => {
555 let mut count: u64 = 0;
556 for id in ids {
557 self.restore(&pool, id).await?;
558 count += 1;
559 }
560 Ok(count)
561 }
562 "purge" => {
563 let mut count: u64 = 0;
564 for id in ids {
565 self.purge(&pool, id).await?;
566 count += 1;
567 }
568 Ok(count)
569 }
570 other => Err(AdminError::Other(format!(
571 "unhandled bulk action '{other}'; \
572 override AdminModel::execute_action to support it"
573 ))),
574 }
575 })
576 }
577
578 fn record_display(&self, record: &Value) -> String {
583 record_id(record).map_or_else(
584 || format!("{} <no id>", self.display_name()),
585 |id| format!("{} #{id}", self.display_name()),
586 )
587 }
588
589 fn per_page(&self) -> u64 {
591 25
592 }
593
594 fn count(
598 &self,
599 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
600 ) -> AdminFuture<'_, u64> {
601 let params = ListParams {
602 page: 1,
603 per_page: 0,
604 ..Default::default()
605 };
606 let fut = self.list(pool, params);
607 Box::pin(async move { fut.await.map(|r| r.total) })
608 }
609
610 fn supports_csv_export(&self) -> bool {
618 false
619 }
620
621 fn csv_export_columns(&self) -> Vec<&'static str> {
635 self.fields()
636 .into_iter()
637 .filter(|f| {
638 !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden) && !f.encrypted
639 })
640 .map(|f| f.name)
641 .collect()
642 }
643
644 fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
654 columns
655 .iter()
656 .map(|col| {
657 record
658 .get(*col)
659 .map(|v| match v {
660 Value::String(s) => escape_csv_formula(s),
661 Value::Null => String::new(),
662 other => other.to_string(),
663 })
664 .unwrap_or_default()
665 })
666 .collect()
667 }
668
669 fn supports_csv_import(&self) -> bool {
677 false
678 }
679
680 fn import_csv_row<'a>(
691 &'a self,
692 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
693 _line: u64,
694 _row: std::collections::HashMap<String, String>,
695 _mode: CsvImportMode,
696 ) -> AdminFuture<'a, AdminImportRowResult> {
697 Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
698 }
699
700 fn has_history(&self) -> bool {
705 false
706 }
707
708 fn get_history<'a>(
713 &'a self,
714 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
715 _record_id: i64,
716 _page: u64,
717 _per_page: u64,
718 ) -> AdminFuture<'a, AdminHistoryPage> {
719 Box::pin(async move {
720 Err(AdminError::Other(
721 "this model does not have version history enabled; \
722 use #[repository(Model, versioned = true)] to opt in"
723 .to_owned(),
724 ))
725 })
726 }
727}
728
729impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
732 fn from(e: autumn_web::version_history::VersionEntry) -> Self {
733 Self {
734 id: e.id,
735 actor: e.actor,
736 op: e.op.to_string(),
737 request_id: e.request_id,
738 changes: e
739 .changes
740 .into_iter()
741 .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
742 .collect(),
743 recorded_at: e.recorded_at,
744 }
745 }
746}
747
748impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
749 fn from(vp: autumn_web::version_history::VersionPage) -> Self {
769 Self {
770 entries: vp
771 .entries
772 .into_iter()
773 .map(AdminHistoryEntry::from)
774 .collect(),
775 total: vp.total,
776 page: vp.page,
777 per_page: vp.per_page,
778 }
779 }
780}
781
782#[must_use]
789pub fn record_id(record: &Value) -> Option<i64> {
790 record.get("id").and_then(Value::as_i64)
791}
792
793#[derive(Debug, Clone, Default)]
797pub struct ListParams {
798 pub page: u64,
800 pub per_page: u64,
802 pub search: Option<String>,
804 pub sort_by: Option<String>,
806 pub sort_dir: SortDirection,
808 pub filters: Vec<(String, String)>,
810}
811
812#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
814#[serde(rename_all = "lowercase")]
815pub enum SortDirection {
816 #[default]
817 Asc,
818 Desc,
819}
820
821impl SortDirection {
822 #[must_use]
824 pub const fn as_str(self) -> &'static str {
825 match self {
826 Self::Asc => "asc",
827 Self::Desc => "desc",
828 }
829 }
830
831 #[must_use]
833 pub const fn flipped(self) -> Self {
834 match self {
835 Self::Asc => Self::Desc,
836 Self::Desc => Self::Asc,
837 }
838 }
839}
840
841#[derive(Debug, Clone)]
843pub struct ListResult {
844 pub records: Vec<Value>,
846 pub total: u64,
848 pub page: u64,
850 pub per_page: u64,
852}
853
854impl ListResult {
855 #[must_use]
857 pub const fn total_pages(&self) -> u64 {
858 if self.per_page == 0 {
859 return 0;
860 }
861 self.total.div_ceil(self.per_page)
862 }
863}
864
865fn escape_csv_formula(s: &str) -> String {
871 match s.bytes().next() {
872 Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
873 let mut out = String::with_capacity(s.len() + 1);
874 out.push('\'');
875 out.push_str(s);
876 out
877 }
878 _ => s.to_owned(),
879 }
880}
881
882fn humanize_field_name(name: &str) -> String {
886 let mut s = String::with_capacity(name.len());
887 for (i, word) in name.split('_').enumerate() {
888 if i > 0 {
889 s.push(' ');
890 }
891 let mut chars = word.chars();
892 if let Some(c) = chars.next() {
893 s.extend(c.to_uppercase());
894 s.extend(chars);
895 }
896 }
897 s
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use std::sync::Mutex;
904
905 struct DeletingModel {
909 deleted: Mutex<Vec<i64>>,
910 fail_on: Option<i64>,
911 }
912
913 impl AdminModel for DeletingModel {
914 fn slug(&self) -> &'static str {
915 "tracked"
916 }
917 fn display_name(&self) -> &'static str {
918 "Tracked"
919 }
920 fn display_name_plural(&self) -> &'static str {
921 "Tracked"
922 }
923 fn fields(&self) -> Vec<AdminField> {
924 vec![]
925 }
926 fn list(
927 &self,
928 _pool: &diesel_async::pooled_connection::deadpool::Pool<
929 diesel_async::AsyncPgConnection,
930 >,
931 _params: ListParams,
932 ) -> AdminFuture<'_, ListResult> {
933 Box::pin(async {
934 Ok(ListResult {
935 records: vec![],
936 total: 0,
937 page: 1,
938 per_page: 25,
939 })
940 })
941 }
942 fn get(
943 &self,
944 _pool: &diesel_async::pooled_connection::deadpool::Pool<
945 diesel_async::AsyncPgConnection,
946 >,
947 _id: i64,
948 ) -> AdminFuture<'_, Option<Value>> {
949 Box::pin(async { Ok(None) })
950 }
951 fn create(
952 &self,
953 _pool: &diesel_async::pooled_connection::deadpool::Pool<
954 diesel_async::AsyncPgConnection,
955 >,
956 data: Value,
957 ) -> AdminFuture<'_, Value> {
958 Box::pin(async move { Ok(data) })
959 }
960 fn update(
961 &self,
962 _pool: &diesel_async::pooled_connection::deadpool::Pool<
963 diesel_async::AsyncPgConnection,
964 >,
965 _id: i64,
966 data: Value,
967 ) -> AdminFuture<'_, Value> {
968 Box::pin(async move { Ok(data) })
969 }
970 fn delete(
971 &self,
972 _pool: &diesel_async::pooled_connection::deadpool::Pool<
973 diesel_async::AsyncPgConnection,
974 >,
975 id: i64,
976 ) -> AdminFuture<'_, ()> {
977 let deleted = &self.deleted;
978 let fail_on = self.fail_on;
979 Box::pin(async move {
980 if Some(id) == fail_on {
981 return Err(AdminError::Database("simulated failure".into()));
982 }
983 deleted.lock().unwrap().push(id);
984 Ok(())
985 })
986 }
987 }
988
989 #[derive(Default)]
993 struct SoftDeleteModel {
994 restored: Mutex<Vec<i64>>,
995 purged: Mutex<Vec<i64>>,
996 }
997
998 impl AdminModel for SoftDeleteModel {
999 fn slug(&self) -> &'static str {
1000 "soft"
1001 }
1002 fn display_name(&self) -> &'static str {
1003 "Soft"
1004 }
1005 fn display_name_plural(&self) -> &'static str {
1006 "Softs"
1007 }
1008 fn fields(&self) -> Vec<AdminField> {
1009 vec![]
1010 }
1011 fn list(
1012 &self,
1013 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1014 diesel_async::AsyncPgConnection,
1015 >,
1016 _params: ListParams,
1017 ) -> AdminFuture<'_, ListResult> {
1018 Box::pin(async {
1019 Ok(ListResult {
1020 records: vec![],
1021 total: 0,
1022 page: 1,
1023 per_page: 25,
1024 })
1025 })
1026 }
1027 fn get(
1028 &self,
1029 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1030 diesel_async::AsyncPgConnection,
1031 >,
1032 _id: i64,
1033 ) -> AdminFuture<'_, Option<Value>> {
1034 Box::pin(async { Ok(None) })
1035 }
1036 fn create(
1037 &self,
1038 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1039 diesel_async::AsyncPgConnection,
1040 >,
1041 data: Value,
1042 ) -> AdminFuture<'_, Value> {
1043 Box::pin(async move { Ok(data) })
1044 }
1045 fn update(
1046 &self,
1047 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1048 diesel_async::AsyncPgConnection,
1049 >,
1050 _id: i64,
1051 data: Value,
1052 ) -> AdminFuture<'_, Value> {
1053 Box::pin(async move { Ok(data) })
1054 }
1055 fn delete(
1056 &self,
1057 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1058 diesel_async::AsyncPgConnection,
1059 >,
1060 _id: i64,
1061 ) -> AdminFuture<'_, ()> {
1062 Box::pin(async { Ok(()) })
1063 }
1064 fn supports_soft_delete(&self) -> bool {
1065 true
1066 }
1067 fn restore<'a>(
1068 &'a self,
1069 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1070 diesel_async::AsyncPgConnection,
1071 >,
1072 id: i64,
1073 ) -> AdminFuture<'a, ()> {
1074 Box::pin(async move {
1075 self.restored.lock().unwrap().push(id);
1076 Ok(())
1077 })
1078 }
1079 fn purge<'a>(
1080 &'a self,
1081 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1082 diesel_async::AsyncPgConnection,
1083 >,
1084 id: i64,
1085 ) -> AdminFuture<'a, ()> {
1086 Box::pin(async move {
1087 self.purged.lock().unwrap().push(id);
1088 Ok(())
1089 })
1090 }
1091 fn list_deleted<'a>(
1092 &'a self,
1093 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1094 diesel_async::AsyncPgConnection,
1095 >,
1096 _params: ListParams,
1097 ) -> AdminFuture<'a, ListResult> {
1098 Box::pin(async {
1099 Ok(ListResult {
1100 records: vec![],
1101 total: 0,
1102 page: 1,
1103 per_page: 25,
1104 })
1105 })
1106 }
1107 }
1108
1109 fn dummy_pool()
1112 -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
1113 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1114 use diesel_async::pooled_connection::deadpool::Pool;
1115 let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
1116 "postgresql://test",
1117 );
1118 Pool::builder(mgr).build().expect("build pool")
1119 }
1120
1121 #[tokio::test]
1122 async fn default_execute_action_delete_invokes_delete_for_each_id() {
1123 let model = DeletingModel {
1124 deleted: Mutex::new(vec![]),
1125 fail_on: None,
1126 };
1127 let pool = dummy_pool();
1128 let count = model
1129 .execute_action(&pool, "delete", vec![10, 20, 30])
1130 .await
1131 .expect("default delete should succeed");
1132 assert_eq!(count, 3);
1133 assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
1134 }
1135
1136 #[tokio::test]
1137 async fn default_execute_action_delete_aborts_on_first_failure() {
1138 let model = DeletingModel {
1139 deleted: Mutex::new(vec![]),
1140 fail_on: Some(20),
1141 };
1142 let pool = dummy_pool();
1143 let err = model
1144 .execute_action(&pool, "delete", vec![10, 20, 30])
1145 .await
1146 .expect_err("delete should propagate failure");
1147 assert!(matches!(err, AdminError::Database(_)));
1148 assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
1150 }
1151
1152 #[tokio::test]
1153 async fn default_execute_action_rejects_unknown_action() {
1154 let model = DeletingModel {
1155 deleted: Mutex::new(vec![]),
1156 fail_on: None,
1157 };
1158 let pool = dummy_pool();
1159 let err = model
1160 .execute_action(&pool, "promote", vec![1])
1161 .await
1162 .expect_err("unknown actions must error, not silently no-op");
1163 assert!(
1164 matches!(err, AdminError::Other(msg) if msg.contains("promote")),
1165 "error should name the unhandled action"
1166 );
1167 assert!(model.deleted.lock().unwrap().is_empty());
1168 }
1169
1170 #[test]
1171 fn humanize_converts_snake_case() {
1172 assert_eq!(humanize_field_name("created_at"), "Created At");
1173 assert_eq!(humanize_field_name("user_id"), "User Id");
1174 assert_eq!(humanize_field_name("name"), "Name");
1175 assert_eq!(humanize_field_name(""), "");
1176 }
1177
1178 #[test]
1179 fn list_result_total_pages() {
1180 let result = ListResult {
1181 records: vec![],
1182 total: 25,
1183 page: 1,
1184 per_page: 10,
1185 };
1186 assert_eq!(result.total_pages(), 3);
1187 }
1188
1189 #[test]
1190 fn list_result_total_pages_exact() {
1191 let result = ListResult {
1192 records: vec![],
1193 total: 20,
1194 page: 1,
1195 per_page: 10,
1196 };
1197 assert_eq!(result.total_pages(), 2);
1198 }
1199
1200 #[test]
1201 fn list_result_total_pages_zero_per_page() {
1202 let result = ListResult {
1203 records: vec![],
1204 total: 20,
1205 page: 1,
1206 per_page: 0,
1207 };
1208 assert_eq!(result.total_pages(), 0);
1209 }
1210
1211 #[test]
1212 fn admin_field_builder() {
1213 let field = AdminField::new("email", AdminFieldKind::Text)
1214 .label("Email Address")
1215 .searchable()
1216 .filterable()
1217 .optional();
1218
1219 assert_eq!(field.name, "email");
1220 assert_eq!(field.label, "Email Address");
1221 assert!(field.searchable);
1222 assert!(field.filterable);
1223 assert!(!field.required);
1224 assert!(field.editable);
1225 }
1226
1227 #[test]
1228 fn record_id_extracts_numeric_id() {
1229 assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
1230 }
1231
1232 #[test]
1233 fn record_id_returns_none_for_missing_or_non_numeric() {
1234 assert_eq!(record_id(&serde_json::json!({})), None);
1235 assert_eq!(record_id(&serde_json::json!({"id": null})), None);
1236 assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
1237 assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
1239 }
1240
1241 #[test]
1242 fn hidden_fields_default_to_not_editable() {
1243 let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
1247 assert!(
1248 !hidden.editable,
1249 "Hidden fields must default to editable=false"
1250 );
1251
1252 let text = AdminField::new("name", AdminFieldKind::Text);
1254 assert!(text.editable);
1255 }
1256
1257 #[test]
1260 fn admin_model_supports_soft_delete_defaults_to_false() {
1261 let model = DeletingModel {
1262 deleted: Mutex::new(vec![]),
1263 fail_on: None,
1264 };
1265 assert!(
1266 !model.supports_soft_delete(),
1267 "AdminModel::supports_soft_delete() must default to false"
1268 );
1269 }
1270
1271 #[tokio::test]
1272 async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
1273 let model = DeletingModel {
1274 deleted: Mutex::new(vec![]),
1275 fail_on: None,
1276 };
1277 let pool = dummy_pool();
1278 let err = model
1279 .restore(&pool, 1)
1280 .await
1281 .expect_err("restore must error when supports_soft_delete() is false");
1282 assert!(
1283 matches!(err, AdminError::Other(_)),
1284 "restore on non-soft-delete model must return AdminError::Other: {err:?}"
1285 );
1286 }
1287
1288 #[tokio::test]
1289 async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
1290 let model = DeletingModel {
1291 deleted: Mutex::new(vec![]),
1292 fail_on: None,
1293 };
1294 let pool = dummy_pool();
1295 let err = model
1296 .purge(&pool, 1)
1297 .await
1298 .expect_err("purge must error when supports_soft_delete() is false");
1299 assert!(
1300 matches!(err, AdminError::Other(_)),
1301 "purge on non-soft-delete model must return AdminError::Other: {err:?}"
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
1307 let model = DeletingModel {
1308 deleted: Mutex::new(vec![]),
1309 fail_on: None,
1310 };
1311 let pool = dummy_pool();
1312 let params = ListParams {
1313 page: 1,
1314 per_page: 25,
1315 ..Default::default()
1316 };
1317 let err = model
1318 .list_deleted(&pool, params)
1319 .await
1320 .expect_err("list_deleted must error when supports_soft_delete() is false");
1321 assert!(
1322 matches!(err, AdminError::Other(_)),
1323 "list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
1324 );
1325 }
1326
1327 #[test]
1328 fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
1329 let model = DeletingModel {
1330 deleted: Mutex::new(vec![]),
1331 fail_on: None,
1332 };
1333 let acts = model.actions();
1334 assert_eq!(
1335 acts.len(),
1336 1,
1337 "default model must advertise exactly one action"
1338 );
1339 assert_eq!(acts[0].name, "delete");
1340 }
1341
1342 #[test]
1343 fn actions_includes_restore_and_purge_when_soft_delete_supported() {
1344 let model = SoftDeleteModel::default();
1345 let acts = model.actions();
1346 let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
1347 assert!(
1348 names.contains(&"restore"),
1349 "soft-delete model must advertise restore action; got: {names:?}"
1350 );
1351 assert!(
1352 names.contains(&"purge"),
1353 "soft-delete model must advertise purge action; got: {names:?}"
1354 );
1355 }
1356
1357 #[tokio::test]
1358 async fn execute_action_restore_dispatches_to_restore_method() {
1359 let model = SoftDeleteModel::default();
1360 let pool = dummy_pool();
1361 let count = model
1362 .execute_action(&pool, "restore", vec![10, 20])
1363 .await
1364 .expect("restore action should succeed on soft-delete model");
1365 assert_eq!(
1366 count, 2,
1367 "restore action must return count of restored records"
1368 );
1369 assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
1370 }
1371
1372 #[tokio::test]
1373 async fn execute_action_purge_dispatches_to_purge_method() {
1374 let model = SoftDeleteModel::default();
1375 let pool = dummy_pool();
1376 let count = model
1377 .execute_action(&pool, "purge", vec![5])
1378 .await
1379 .expect("purge action should succeed on soft-delete model");
1380 assert_eq!(count, 1, "purge action must return count of purged records");
1381 assert_eq!(*model.purged.lock().unwrap(), vec![5]);
1382 }
1383
1384 #[test]
1387 fn admin_model_has_history_defaults_to_false() {
1388 let model = DeletingModel {
1389 deleted: Mutex::new(vec![]),
1390 fail_on: None,
1391 };
1392 assert!(
1393 !model.has_history(),
1394 "AdminModel::has_history() must default to false"
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn admin_model_get_history_returns_error_when_not_opted_in() {
1400 let model = DeletingModel {
1401 deleted: Mutex::new(vec![]),
1402 fail_on: None,
1403 };
1404 let pool = dummy_pool();
1405 let err = model
1406 .get_history(&pool, 42, 1, 25)
1407 .await
1408 .expect_err("get_history must error when has_history() is false");
1409 assert!(
1410 matches!(err, AdminError::Other(_)),
1411 "get_history on non-versioned model must return AdminError::Other: {err:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn admin_history_page_total_pages() {
1417 let page = AdminHistoryPage {
1418 entries: vec![],
1419 total: 51,
1420 page: 1,
1421 per_page: 25,
1422 };
1423 assert_eq!(page.total_pages(), 3);
1424 }
1425
1426 #[test]
1427 fn admin_history_page_has_next_page() {
1428 let page = AdminHistoryPage {
1429 entries: vec![],
1430 total: 50,
1431 page: 1,
1432 per_page: 25,
1433 };
1434 assert!(page.has_next_page());
1435 }
1436
1437 #[test]
1438 fn admin_history_page_no_next_on_last() {
1439 let page = AdminHistoryPage {
1440 entries: vec![],
1441 total: 50,
1442 page: 2,
1443 per_page: 25,
1444 };
1445 assert!(!page.has_next_page());
1446 }
1447
1448 #[test]
1449 fn admin_history_page_zero_per_page() {
1450 let page = AdminHistoryPage {
1451 entries: vec![],
1452 total: 10,
1453 page: 1,
1454 per_page: 0,
1455 };
1456 assert_eq!(page.total_pages(), 0);
1457 }
1458
1459 #[test]
1462 fn sort_direction_as_str_returns_correct_values() {
1463 assert_eq!(SortDirection::Asc.as_str(), "asc");
1464 assert_eq!(SortDirection::Desc.as_str(), "desc");
1465 }
1466
1467 #[test]
1468 fn sort_direction_flipped_returns_opposite() {
1469 assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
1470 assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
1471 }
1472
1473 #[test]
1474 fn admin_field_readonly_sets_editable_false() {
1475 let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
1476 assert!(!field.editable, "readonly() must set editable = false");
1477 }
1478
1479 #[test]
1480 fn admin_field_hide_from_list_sets_list_display_false() {
1481 let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
1482 assert!(
1483 !field.list_display,
1484 "hide_from_list() must set list_display = false"
1485 );
1486 }
1487
1488 #[test]
1489 fn admin_model_record_display_includes_display_name_and_id() {
1490 let model = DeletingModel {
1491 deleted: Mutex::new(vec![]),
1492 fail_on: None,
1493 };
1494 let record = serde_json::json!({"id": 7, "name": "foo"});
1495 assert_eq!(model.record_display(&record), "Tracked #7");
1496 }
1497
1498 #[test]
1499 fn admin_model_record_display_placeholder_when_no_id() {
1500 let model = DeletingModel {
1501 deleted: Mutex::new(vec![]),
1502 fail_on: None,
1503 };
1504 let record = serde_json::json!({"name": "bar"});
1505 assert_eq!(model.record_display(&record), "Tracked <no id>");
1506 }
1507
1508 #[test]
1509 fn admin_model_per_page_default_is_25() {
1510 let model = DeletingModel {
1511 deleted: Mutex::new(vec![]),
1512 fail_on: None,
1513 };
1514 assert_eq!(model.per_page(), 25);
1515 }
1516
1517 #[test]
1518 fn version_page_converts_to_admin_history_page() {
1519 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
1520 use chrono::Utc;
1521
1522 let entry = VersionEntry {
1523 id: 1,
1524 table_name: "posts".to_owned(),
1525 record_id: 42,
1526 op: VersionOp::Update,
1527 actor: "admin".to_owned(),
1528 request_id: Some("req-1".to_owned()),
1529 changes: vec![ColumnChange::new(
1530 "title",
1531 Some(serde_json::json!("old")),
1532 Some(serde_json::json!("new")),
1533 )],
1534 recorded_at: Utc::now(),
1535 };
1536 let vp = VersionPage {
1537 entries: vec![entry],
1538 total: 1,
1539 page: 1,
1540 per_page: 25,
1541 };
1542
1543 let ap = AdminHistoryPage::from(vp);
1544 assert_eq!(ap.total, 1);
1545 assert_eq!(ap.page, 1);
1546 assert_eq!(ap.per_page, 25);
1547 assert_eq!(ap.entries.len(), 1);
1548 let e = &ap.entries[0];
1549 assert_eq!(e.id, 1);
1550 assert_eq!(e.actor, "admin");
1551 assert_eq!(e.op, "update");
1552 assert_eq!(e.request_id.as_deref(), Some("req-1"));
1553 assert_eq!(e.changes.len(), 1);
1554 }
1555
1556 #[test]
1557 fn version_entry_converts_to_admin_history_entry() {
1558 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
1559 use chrono::Utc;
1560
1561 let entry = VersionEntry {
1562 id: 7,
1563 table_name: "users".to_owned(),
1564 record_id: 3,
1565 op: VersionOp::Delete,
1566 actor: "system".to_owned(),
1567 request_id: None,
1568 changes: vec![ColumnChange::sensitive("password_digest")],
1569 recorded_at: Utc::now(),
1570 };
1571
1572 let admin_entry = AdminHistoryEntry::from(entry);
1573 assert_eq!(admin_entry.id, 7);
1574 assert_eq!(admin_entry.actor, "system");
1575 assert_eq!(admin_entry.op, "delete");
1576 assert!(admin_entry.request_id.is_none());
1577 assert_eq!(admin_entry.changes.len(), 1);
1578 }
1579
1580 #[test]
1583 fn csv_import_mode_from_form_value_recognises_insert() {
1584 assert_eq!(
1585 CsvImportMode::from_form_value("insert"),
1586 Some(CsvImportMode::Insert)
1587 );
1588 assert_eq!(
1589 CsvImportMode::from_form_value("Insert"),
1590 Some(CsvImportMode::Insert)
1591 );
1592 }
1593
1594 #[test]
1595 fn csv_import_mode_from_form_value_recognises_dry_run() {
1596 assert_eq!(
1597 CsvImportMode::from_form_value("dry_run"),
1598 Some(CsvImportMode::DryRun)
1599 );
1600 assert_eq!(
1601 CsvImportMode::from_form_value("DryRun"),
1602 Some(CsvImportMode::DryRun)
1603 );
1604 assert_eq!(
1605 CsvImportMode::from_form_value("dry-run"),
1606 Some(CsvImportMode::DryRun)
1607 );
1608 }
1609
1610 #[test]
1611 fn csv_import_mode_from_form_value_rejects_unknown() {
1612 assert_eq!(CsvImportMode::from_form_value("upsert"), None);
1613 assert_eq!(CsvImportMode::from_form_value(""), None);
1614 assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
1615 assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
1616 }
1617
1618 #[test]
1619 fn csv_import_mode_default_is_insert() {
1620 assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
1621 }
1622
1623 #[test]
1626 fn escape_csv_formula_prefixes_equals_sign() {
1627 assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
1628 }
1629
1630 #[test]
1631 fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
1632 assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
1633 assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
1634 assert_eq!(escape_csv_formula("@A1"), "'@A1");
1635 }
1636
1637 #[test]
1638 fn escape_csv_formula_prefixes_tab_and_cr() {
1639 assert_eq!(escape_csv_formula("\thello"), "'\thello");
1640 assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
1641 }
1642
1643 #[test]
1644 fn escape_csv_formula_leaves_normal_strings_unchanged() {
1645 assert_eq!(escape_csv_formula("hello world"), "hello world");
1646 assert_eq!(escape_csv_formula("123"), "123");
1647 assert_eq!(escape_csv_formula(""), "");
1648 assert_eq!(escape_csv_formula("normal,value"), "normal,value");
1649 }
1650
1651 #[test]
1654 fn admin_model_supports_csv_export_defaults_to_false() {
1655 let model = DeletingModel {
1656 deleted: Mutex::new(vec![]),
1657 fail_on: None,
1658 };
1659 assert!(
1660 !model.supports_csv_export(),
1661 "supports_csv_export must default to false to require explicit opt-in"
1662 );
1663 }
1664
1665 #[test]
1666 fn admin_model_supports_csv_import_defaults_to_false() {
1667 let model = DeletingModel {
1668 deleted: Mutex::new(vec![]),
1669 fail_on: None,
1670 };
1671 assert!(
1672 !model.supports_csv_import(),
1673 "supports_csv_import must default to false"
1674 );
1675 }
1676
1677 #[test]
1678 fn csv_export_row_extracts_columns_and_escapes_formulas() {
1679 let model = DeletingModel {
1680 deleted: Mutex::new(vec![]),
1681 fail_on: None,
1682 };
1683 let record = serde_json::json!({
1684 "id": 1,
1685 "name": "Alice",
1686 "formula": "=EVIL()",
1687 "amount": 42.5,
1688 "active": true,
1689 "notes": null,
1690 });
1691 let columns = &[
1692 "id", "name", "formula", "amount", "active", "notes", "missing",
1693 ];
1694 let row = model.csv_export_row(columns, &record);
1695 assert_eq!(row[0], "1");
1696 assert_eq!(row[1], "Alice");
1697 assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
1698 assert_eq!(row[3], "42.5");
1699 assert_eq!(row[4], "true");
1700 assert_eq!(row[5], "", "null becomes empty string");
1701 assert_eq!(row[6], "", "missing column becomes empty string");
1702 }
1703}