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
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SelectOption {
42 pub value: String,
43 pub label: String,
44}
45
46#[derive(Debug, Clone)]
48#[allow(clippy::struct_excessive_bools)] pub struct AdminField {
50 pub name: &'static str,
52 pub label: String,
54 pub kind: AdminFieldKind,
56 pub list_display: bool,
58 pub searchable: bool,
60 pub filterable: bool,
62 pub required: bool,
64 pub editable: bool,
66 pub sortable: bool,
68 pub encrypted: bool,
77 pub encrypted_visible: bool,
81}
82
83impl AdminField {
84 #[must_use]
92 pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
93 let editable = !matches!(kind, AdminFieldKind::Hidden);
94 Self {
95 name,
96 label: humanize_field_name(name),
97 kind,
98 list_display: true,
99 searchable: false,
100 filterable: false,
101 required: true,
102 editable,
103 sortable: true,
104 encrypted: false,
105 encrypted_visible: false,
106 }
107 }
108
109 #[must_use]
112 pub const fn encrypted(mut self) -> Self {
113 self.encrypted = true;
114 self
115 }
116
117 #[must_use]
121 pub const fn encrypted_visible(mut self) -> Self {
122 self.encrypted = true;
123 self.encrypted_visible = true;
124 self
125 }
126
127 #[must_use]
129 pub fn label(mut self, label: impl Into<String>) -> Self {
130 self.label = label.into();
131 self
132 }
133
134 #[must_use]
136 pub const fn searchable(mut self) -> Self {
137 self.searchable = true;
138 self
139 }
140
141 #[must_use]
143 pub const fn filterable(mut self) -> Self {
144 self.filterable = true;
145 self
146 }
147
148 #[must_use]
150 pub const fn optional(mut self) -> Self {
151 self.required = false;
152 self
153 }
154
155 #[must_use]
157 pub const fn readonly(mut self) -> Self {
158 self.editable = false;
159 self
160 }
161
162 #[must_use]
164 pub const fn hide_from_list(mut self) -> Self {
165 self.list_display = false;
166 self
167 }
168}
169
170pub struct AdminAction {
174 pub name: &'static str,
176 pub label: String,
178 pub style: ActionStyle,
180 pub confirm: bool,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum ActionStyle {
187 Default,
189 Primary,
191 Danger,
193}
194
195#[derive(Debug, Clone)]
202pub struct AdminHistoryEntry {
203 pub id: i64,
205 pub actor: String,
207 pub op: String,
209 pub request_id: Option<String>,
211 pub changes: Vec<Value>,
213 pub recorded_at: DateTime<Utc>,
215}
216
217#[derive(Debug, Clone)]
219pub struct AdminHistoryPage {
220 pub entries: Vec<AdminHistoryEntry>,
221 pub total: u64,
222 pub page: u64,
223 pub per_page: u64,
224}
225
226impl AdminHistoryPage {
227 #[must_use]
229 pub const fn total_pages(&self) -> u64 {
230 if self.per_page == 0 {
231 return 0;
232 }
233 self.total.div_ceil(self.per_page)
234 }
235
236 #[must_use]
238 pub const fn has_next_page(&self) -> bool {
239 self.page < self.total_pages()
240 }
241}
242
243pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
247
248#[derive(Debug, thiserror::Error)]
250pub enum AdminError {
251 #[error("Record not found")]
252 NotFound,
253
254 #[error("Validation failed: {0}")]
255 Validation(String),
256
257 #[error("Database error: {0}")]
258 Database(String),
259
260 #[error("{0}")]
261 Other(String),
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268pub enum CsvImportMode {
269 #[default]
271 Insert,
272 DryRun,
274}
275
276impl CsvImportMode {
277 #[must_use]
283 pub fn from_form_value(s: &str) -> Option<Self> {
284 match s {
285 "insert" | "Insert" => Some(Self::Insert),
286 "dry_run" | "DryRun" | "dry-run" => Some(Self::DryRun),
287 _ => None,
288 }
289 }
290}
291
292#[derive(Debug)]
294pub enum AdminImportRowResult {
295 Inserted,
297 Updated,
299 Skipped,
301 RowError(String),
303 FieldError { column: String, message: String },
305}
306
307#[derive(Debug, Default, Clone)]
309pub struct AdminImportReport {
310 pub inserted: u64,
311 pub updated: u64,
312 pub skipped: u64,
313 pub errors: Vec<AdminImportError>,
314}
315
316#[derive(Debug, Clone)]
318pub struct AdminImportError {
319 pub line: u64,
321 pub column: Option<String>,
323 pub message: String,
325}
326
327pub trait AdminModel: Send + Sync + 'static {
339 fn slug(&self) -> &'static str;
342
343 fn display_name(&self) -> &'static str;
345
346 fn display_name_plural(&self) -> &'static str;
348
349 fn fields(&self) -> Vec<AdminField>;
351
352 fn actions(&self) -> Vec<AdminAction> {
358 let mut acts = vec![AdminAction {
359 name: "delete",
360 label: "Delete selected".to_owned(),
361 style: ActionStyle::Danger,
362 confirm: true,
363 }];
364 if self.supports_soft_delete() {
365 acts.push(AdminAction {
366 name: "restore",
367 label: "Restore selected".to_owned(),
368 style: ActionStyle::Default,
369 confirm: false,
370 });
371 acts.push(AdminAction {
372 name: "purge",
373 label: "Purge selected".to_owned(),
374 style: ActionStyle::Danger,
375 confirm: true,
376 });
377 }
378 acts
379 }
380
381 fn list(
385 &self,
386 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
387 params: ListParams,
388 ) -> AdminFuture<'_, ListResult>;
389
390 fn get(
392 &self,
393 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
394 id: i64,
395 ) -> AdminFuture<'_, Option<Value>>;
396
397 fn create(
399 &self,
400 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
401 data: Value,
402 ) -> AdminFuture<'_, Value>;
403
404 fn update(
406 &self,
407 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
408 id: i64,
409 data: Value,
410 ) -> AdminFuture<'_, Value>;
411
412 fn delete(
414 &self,
415 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
416 id: i64,
417 ) -> AdminFuture<'_, ()>;
418
419 fn supports_soft_delete(&self) -> bool {
423 false
424 }
425
426 fn restore<'a>(
431 &'a self,
432 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
433 _id: i64,
434 ) -> AdminFuture<'a, ()> {
435 Box::pin(async move {
436 Err(AdminError::Other(
437 "this model does not support soft delete; \
438 override supports_soft_delete() to return true and implement restore()"
439 .to_owned(),
440 ))
441 })
442 }
443
444 fn purge<'a>(
449 &'a self,
450 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
451 _id: i64,
452 ) -> AdminFuture<'a, ()> {
453 Box::pin(async move {
454 Err(AdminError::Other(
455 "this model does not support soft delete; \
456 override supports_soft_delete() to return true and implement purge()"
457 .to_owned(),
458 ))
459 })
460 }
461
462 fn list_deleted<'a>(
467 &'a self,
468 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
469 _params: ListParams,
470 ) -> AdminFuture<'a, ListResult> {
471 Box::pin(async move {
472 Err(AdminError::Other(
473 "this model does not support soft delete; \
474 override supports_soft_delete() to return true and implement list_deleted()"
475 .to_owned(),
476 ))
477 })
478 }
479
480 fn execute_action(
482 &self,
483 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
484 action: &str,
485 ids: Vec<i64>,
486 ) -> AdminFuture<'_, u64> {
487 let action = action.to_owned();
497 let pool = pool.clone();
498 Box::pin(async move {
499 match action.as_str() {
500 "delete" => {
501 let mut count: u64 = 0;
502 for id in ids {
503 self.delete(&pool, id).await?;
504 count += 1;
505 }
506 Ok(count)
507 }
508 "restore" => {
509 let mut count: u64 = 0;
510 for id in ids {
511 self.restore(&pool, id).await?;
512 count += 1;
513 }
514 Ok(count)
515 }
516 "purge" => {
517 let mut count: u64 = 0;
518 for id in ids {
519 self.purge(&pool, id).await?;
520 count += 1;
521 }
522 Ok(count)
523 }
524 other => Err(AdminError::Other(format!(
525 "unhandled bulk action '{other}'; \
526 override AdminModel::execute_action to support it"
527 ))),
528 }
529 })
530 }
531
532 fn record_display(&self, record: &Value) -> String {
537 record_id(record).map_or_else(
538 || format!("{} <no id>", self.display_name()),
539 |id| format!("{} #{id}", self.display_name()),
540 )
541 }
542
543 fn per_page(&self) -> u64 {
545 25
546 }
547
548 fn count(
552 &self,
553 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
554 ) -> AdminFuture<'_, u64> {
555 let params = ListParams {
556 page: 1,
557 per_page: 0,
558 ..Default::default()
559 };
560 let fut = self.list(pool, params);
561 Box::pin(async move { fut.await.map(|r| r.total) })
562 }
563
564 fn supports_csv_export(&self) -> bool {
572 false
573 }
574
575 fn csv_export_columns(&self) -> Vec<&'static str> {
589 self.fields()
590 .into_iter()
591 .filter(|f| {
592 !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden) && !f.encrypted
593 })
594 .map(|f| f.name)
595 .collect()
596 }
597
598 fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
608 columns
609 .iter()
610 .map(|col| {
611 record
612 .get(*col)
613 .map(|v| match v {
614 Value::String(s) => escape_csv_formula(s),
615 Value::Null => String::new(),
616 other => other.to_string(),
617 })
618 .unwrap_or_default()
619 })
620 .collect()
621 }
622
623 fn supports_csv_import(&self) -> bool {
631 false
632 }
633
634 fn import_csv_row<'a>(
645 &'a self,
646 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
647 _line: u64,
648 _row: std::collections::HashMap<String, String>,
649 _mode: CsvImportMode,
650 ) -> AdminFuture<'a, AdminImportRowResult> {
651 Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
652 }
653
654 fn has_history(&self) -> bool {
659 false
660 }
661
662 fn get_history<'a>(
667 &'a self,
668 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
669 _record_id: i64,
670 _page: u64,
671 _per_page: u64,
672 ) -> AdminFuture<'a, AdminHistoryPage> {
673 Box::pin(async move {
674 Err(AdminError::Other(
675 "this model does not have version history enabled; \
676 use #[repository(Model, versioned = true)] to opt in"
677 .to_owned(),
678 ))
679 })
680 }
681}
682
683impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
686 fn from(e: autumn_web::version_history::VersionEntry) -> Self {
687 Self {
688 id: e.id,
689 actor: e.actor,
690 op: e.op.to_string(),
691 request_id: e.request_id,
692 changes: e
693 .changes
694 .into_iter()
695 .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
696 .collect(),
697 recorded_at: e.recorded_at,
698 }
699 }
700}
701
702impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
703 fn from(vp: autumn_web::version_history::VersionPage) -> Self {
723 Self {
724 entries: vp
725 .entries
726 .into_iter()
727 .map(AdminHistoryEntry::from)
728 .collect(),
729 total: vp.total,
730 page: vp.page,
731 per_page: vp.per_page,
732 }
733 }
734}
735
736#[must_use]
743pub fn record_id(record: &Value) -> Option<i64> {
744 record.get("id").and_then(Value::as_i64)
745}
746
747#[derive(Debug, Clone, Default)]
751pub struct ListParams {
752 pub page: u64,
754 pub per_page: u64,
756 pub search: Option<String>,
758 pub sort_by: Option<String>,
760 pub sort_dir: SortDirection,
762 pub filters: Vec<(String, String)>,
764}
765
766#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
768#[serde(rename_all = "lowercase")]
769pub enum SortDirection {
770 #[default]
771 Asc,
772 Desc,
773}
774
775impl SortDirection {
776 #[must_use]
778 pub const fn as_str(self) -> &'static str {
779 match self {
780 Self::Asc => "asc",
781 Self::Desc => "desc",
782 }
783 }
784
785 #[must_use]
787 pub const fn flipped(self) -> Self {
788 match self {
789 Self::Asc => Self::Desc,
790 Self::Desc => Self::Asc,
791 }
792 }
793}
794
795#[derive(Debug, Clone)]
797pub struct ListResult {
798 pub records: Vec<Value>,
800 pub total: u64,
802 pub page: u64,
804 pub per_page: u64,
806}
807
808impl ListResult {
809 #[must_use]
811 pub const fn total_pages(&self) -> u64 {
812 if self.per_page == 0 {
813 return 0;
814 }
815 self.total.div_ceil(self.per_page)
816 }
817}
818
819fn escape_csv_formula(s: &str) -> String {
825 match s.bytes().next() {
826 Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
827 let mut out = String::with_capacity(s.len() + 1);
828 out.push('\'');
829 out.push_str(s);
830 out
831 }
832 _ => s.to_owned(),
833 }
834}
835
836fn humanize_field_name(name: &str) -> String {
840 let mut s = String::with_capacity(name.len());
841 for (i, word) in name.split('_').enumerate() {
842 if i > 0 {
843 s.push(' ');
844 }
845 let mut chars = word.chars();
846 if let Some(c) = chars.next() {
847 s.extend(c.to_uppercase());
848 s.extend(chars);
849 }
850 }
851 s
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use std::sync::Mutex;
858
859 struct DeletingModel {
863 deleted: Mutex<Vec<i64>>,
864 fail_on: Option<i64>,
865 }
866
867 impl AdminModel for DeletingModel {
868 fn slug(&self) -> &'static str {
869 "tracked"
870 }
871 fn display_name(&self) -> &'static str {
872 "Tracked"
873 }
874 fn display_name_plural(&self) -> &'static str {
875 "Tracked"
876 }
877 fn fields(&self) -> Vec<AdminField> {
878 vec![]
879 }
880 fn list(
881 &self,
882 _pool: &diesel_async::pooled_connection::deadpool::Pool<
883 diesel_async::AsyncPgConnection,
884 >,
885 _params: ListParams,
886 ) -> AdminFuture<'_, ListResult> {
887 Box::pin(async {
888 Ok(ListResult {
889 records: vec![],
890 total: 0,
891 page: 1,
892 per_page: 25,
893 })
894 })
895 }
896 fn get(
897 &self,
898 _pool: &diesel_async::pooled_connection::deadpool::Pool<
899 diesel_async::AsyncPgConnection,
900 >,
901 _id: i64,
902 ) -> AdminFuture<'_, Option<Value>> {
903 Box::pin(async { Ok(None) })
904 }
905 fn create(
906 &self,
907 _pool: &diesel_async::pooled_connection::deadpool::Pool<
908 diesel_async::AsyncPgConnection,
909 >,
910 data: Value,
911 ) -> AdminFuture<'_, Value> {
912 Box::pin(async move { Ok(data) })
913 }
914 fn update(
915 &self,
916 _pool: &diesel_async::pooled_connection::deadpool::Pool<
917 diesel_async::AsyncPgConnection,
918 >,
919 _id: i64,
920 data: Value,
921 ) -> AdminFuture<'_, Value> {
922 Box::pin(async move { Ok(data) })
923 }
924 fn delete(
925 &self,
926 _pool: &diesel_async::pooled_connection::deadpool::Pool<
927 diesel_async::AsyncPgConnection,
928 >,
929 id: i64,
930 ) -> AdminFuture<'_, ()> {
931 let deleted = &self.deleted;
932 let fail_on = self.fail_on;
933 Box::pin(async move {
934 if Some(id) == fail_on {
935 return Err(AdminError::Database("simulated failure".into()));
936 }
937 deleted.lock().unwrap().push(id);
938 Ok(())
939 })
940 }
941 }
942
943 #[derive(Default)]
947 struct SoftDeleteModel {
948 restored: Mutex<Vec<i64>>,
949 purged: Mutex<Vec<i64>>,
950 }
951
952 impl AdminModel for SoftDeleteModel {
953 fn slug(&self) -> &'static str {
954 "soft"
955 }
956 fn display_name(&self) -> &'static str {
957 "Soft"
958 }
959 fn display_name_plural(&self) -> &'static str {
960 "Softs"
961 }
962 fn fields(&self) -> Vec<AdminField> {
963 vec![]
964 }
965 fn list(
966 &self,
967 _pool: &diesel_async::pooled_connection::deadpool::Pool<
968 diesel_async::AsyncPgConnection,
969 >,
970 _params: ListParams,
971 ) -> AdminFuture<'_, ListResult> {
972 Box::pin(async {
973 Ok(ListResult {
974 records: vec![],
975 total: 0,
976 page: 1,
977 per_page: 25,
978 })
979 })
980 }
981 fn get(
982 &self,
983 _pool: &diesel_async::pooled_connection::deadpool::Pool<
984 diesel_async::AsyncPgConnection,
985 >,
986 _id: i64,
987 ) -> AdminFuture<'_, Option<Value>> {
988 Box::pin(async { Ok(None) })
989 }
990 fn create(
991 &self,
992 _pool: &diesel_async::pooled_connection::deadpool::Pool<
993 diesel_async::AsyncPgConnection,
994 >,
995 data: Value,
996 ) -> AdminFuture<'_, Value> {
997 Box::pin(async move { Ok(data) })
998 }
999 fn update(
1000 &self,
1001 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1002 diesel_async::AsyncPgConnection,
1003 >,
1004 _id: i64,
1005 data: Value,
1006 ) -> AdminFuture<'_, Value> {
1007 Box::pin(async move { Ok(data) })
1008 }
1009 fn delete(
1010 &self,
1011 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1012 diesel_async::AsyncPgConnection,
1013 >,
1014 _id: i64,
1015 ) -> AdminFuture<'_, ()> {
1016 Box::pin(async { Ok(()) })
1017 }
1018 fn supports_soft_delete(&self) -> bool {
1019 true
1020 }
1021 fn restore<'a>(
1022 &'a self,
1023 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1024 diesel_async::AsyncPgConnection,
1025 >,
1026 id: i64,
1027 ) -> AdminFuture<'a, ()> {
1028 Box::pin(async move {
1029 self.restored.lock().unwrap().push(id);
1030 Ok(())
1031 })
1032 }
1033 fn purge<'a>(
1034 &'a self,
1035 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1036 diesel_async::AsyncPgConnection,
1037 >,
1038 id: i64,
1039 ) -> AdminFuture<'a, ()> {
1040 Box::pin(async move {
1041 self.purged.lock().unwrap().push(id);
1042 Ok(())
1043 })
1044 }
1045 fn list_deleted<'a>(
1046 &'a self,
1047 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1048 diesel_async::AsyncPgConnection,
1049 >,
1050 _params: ListParams,
1051 ) -> AdminFuture<'a, ListResult> {
1052 Box::pin(async {
1053 Ok(ListResult {
1054 records: vec![],
1055 total: 0,
1056 page: 1,
1057 per_page: 25,
1058 })
1059 })
1060 }
1061 }
1062
1063 fn dummy_pool()
1066 -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
1067 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1068 use diesel_async::pooled_connection::deadpool::Pool;
1069 let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
1070 "postgresql://test",
1071 );
1072 Pool::builder(mgr).build().expect("build pool")
1073 }
1074
1075 #[tokio::test]
1076 async fn default_execute_action_delete_invokes_delete_for_each_id() {
1077 let model = DeletingModel {
1078 deleted: Mutex::new(vec![]),
1079 fail_on: None,
1080 };
1081 let pool = dummy_pool();
1082 let count = model
1083 .execute_action(&pool, "delete", vec![10, 20, 30])
1084 .await
1085 .expect("default delete should succeed");
1086 assert_eq!(count, 3);
1087 assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
1088 }
1089
1090 #[tokio::test]
1091 async fn default_execute_action_delete_aborts_on_first_failure() {
1092 let model = DeletingModel {
1093 deleted: Mutex::new(vec![]),
1094 fail_on: Some(20),
1095 };
1096 let pool = dummy_pool();
1097 let err = model
1098 .execute_action(&pool, "delete", vec![10, 20, 30])
1099 .await
1100 .expect_err("delete should propagate failure");
1101 assert!(matches!(err, AdminError::Database(_)));
1102 assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
1104 }
1105
1106 #[tokio::test]
1107 async fn default_execute_action_rejects_unknown_action() {
1108 let model = DeletingModel {
1109 deleted: Mutex::new(vec![]),
1110 fail_on: None,
1111 };
1112 let pool = dummy_pool();
1113 let err = model
1114 .execute_action(&pool, "promote", vec![1])
1115 .await
1116 .expect_err("unknown actions must error, not silently no-op");
1117 assert!(
1118 matches!(err, AdminError::Other(msg) if msg.contains("promote")),
1119 "error should name the unhandled action"
1120 );
1121 assert!(model.deleted.lock().unwrap().is_empty());
1122 }
1123
1124 #[test]
1125 fn humanize_converts_snake_case() {
1126 assert_eq!(humanize_field_name("created_at"), "Created At");
1127 assert_eq!(humanize_field_name("user_id"), "User Id");
1128 assert_eq!(humanize_field_name("name"), "Name");
1129 assert_eq!(humanize_field_name(""), "");
1130 }
1131
1132 #[test]
1133 fn list_result_total_pages() {
1134 let result = ListResult {
1135 records: vec![],
1136 total: 25,
1137 page: 1,
1138 per_page: 10,
1139 };
1140 assert_eq!(result.total_pages(), 3);
1141 }
1142
1143 #[test]
1144 fn list_result_total_pages_exact() {
1145 let result = ListResult {
1146 records: vec![],
1147 total: 20,
1148 page: 1,
1149 per_page: 10,
1150 };
1151 assert_eq!(result.total_pages(), 2);
1152 }
1153
1154 #[test]
1155 fn list_result_total_pages_zero_per_page() {
1156 let result = ListResult {
1157 records: vec![],
1158 total: 20,
1159 page: 1,
1160 per_page: 0,
1161 };
1162 assert_eq!(result.total_pages(), 0);
1163 }
1164
1165 #[test]
1166 fn admin_field_builder() {
1167 let field = AdminField::new("email", AdminFieldKind::Text)
1168 .label("Email Address")
1169 .searchable()
1170 .filterable()
1171 .optional();
1172
1173 assert_eq!(field.name, "email");
1174 assert_eq!(field.label, "Email Address");
1175 assert!(field.searchable);
1176 assert!(field.filterable);
1177 assert!(!field.required);
1178 assert!(field.editable);
1179 }
1180
1181 #[test]
1182 fn record_id_extracts_numeric_id() {
1183 assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
1184 }
1185
1186 #[test]
1187 fn record_id_returns_none_for_missing_or_non_numeric() {
1188 assert_eq!(record_id(&serde_json::json!({})), None);
1189 assert_eq!(record_id(&serde_json::json!({"id": null})), None);
1190 assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
1191 assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
1193 }
1194
1195 #[test]
1196 fn hidden_fields_default_to_not_editable() {
1197 let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
1201 assert!(
1202 !hidden.editable,
1203 "Hidden fields must default to editable=false"
1204 );
1205
1206 let text = AdminField::new("name", AdminFieldKind::Text);
1208 assert!(text.editable);
1209 }
1210
1211 #[test]
1214 fn admin_model_supports_soft_delete_defaults_to_false() {
1215 let model = DeletingModel {
1216 deleted: Mutex::new(vec![]),
1217 fail_on: None,
1218 };
1219 assert!(
1220 !model.supports_soft_delete(),
1221 "AdminModel::supports_soft_delete() must default to false"
1222 );
1223 }
1224
1225 #[tokio::test]
1226 async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
1227 let model = DeletingModel {
1228 deleted: Mutex::new(vec![]),
1229 fail_on: None,
1230 };
1231 let pool = dummy_pool();
1232 let err = model
1233 .restore(&pool, 1)
1234 .await
1235 .expect_err("restore must error when supports_soft_delete() is false");
1236 assert!(
1237 matches!(err, AdminError::Other(_)),
1238 "restore on non-soft-delete model must return AdminError::Other: {err:?}"
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
1244 let model = DeletingModel {
1245 deleted: Mutex::new(vec![]),
1246 fail_on: None,
1247 };
1248 let pool = dummy_pool();
1249 let err = model
1250 .purge(&pool, 1)
1251 .await
1252 .expect_err("purge must error when supports_soft_delete() is false");
1253 assert!(
1254 matches!(err, AdminError::Other(_)),
1255 "purge on non-soft-delete model must return AdminError::Other: {err:?}"
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
1261 let model = DeletingModel {
1262 deleted: Mutex::new(vec![]),
1263 fail_on: None,
1264 };
1265 let pool = dummy_pool();
1266 let params = ListParams {
1267 page: 1,
1268 per_page: 25,
1269 ..Default::default()
1270 };
1271 let err = model
1272 .list_deleted(&pool, params)
1273 .await
1274 .expect_err("list_deleted must error when supports_soft_delete() is false");
1275 assert!(
1276 matches!(err, AdminError::Other(_)),
1277 "list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
1283 let model = DeletingModel {
1284 deleted: Mutex::new(vec![]),
1285 fail_on: None,
1286 };
1287 let acts = model.actions();
1288 assert_eq!(
1289 acts.len(),
1290 1,
1291 "default model must advertise exactly one action"
1292 );
1293 assert_eq!(acts[0].name, "delete");
1294 }
1295
1296 #[test]
1297 fn actions_includes_restore_and_purge_when_soft_delete_supported() {
1298 let model = SoftDeleteModel::default();
1299 let acts = model.actions();
1300 let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
1301 assert!(
1302 names.contains(&"restore"),
1303 "soft-delete model must advertise restore action; got: {names:?}"
1304 );
1305 assert!(
1306 names.contains(&"purge"),
1307 "soft-delete model must advertise purge action; got: {names:?}"
1308 );
1309 }
1310
1311 #[tokio::test]
1312 async fn execute_action_restore_dispatches_to_restore_method() {
1313 let model = SoftDeleteModel::default();
1314 let pool = dummy_pool();
1315 let count = model
1316 .execute_action(&pool, "restore", vec![10, 20])
1317 .await
1318 .expect("restore action should succeed on soft-delete model");
1319 assert_eq!(
1320 count, 2,
1321 "restore action must return count of restored records"
1322 );
1323 assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
1324 }
1325
1326 #[tokio::test]
1327 async fn execute_action_purge_dispatches_to_purge_method() {
1328 let model = SoftDeleteModel::default();
1329 let pool = dummy_pool();
1330 let count = model
1331 .execute_action(&pool, "purge", vec![5])
1332 .await
1333 .expect("purge action should succeed on soft-delete model");
1334 assert_eq!(count, 1, "purge action must return count of purged records");
1335 assert_eq!(*model.purged.lock().unwrap(), vec![5]);
1336 }
1337
1338 #[test]
1341 fn admin_model_has_history_defaults_to_false() {
1342 let model = DeletingModel {
1343 deleted: Mutex::new(vec![]),
1344 fail_on: None,
1345 };
1346 assert!(
1347 !model.has_history(),
1348 "AdminModel::has_history() must default to false"
1349 );
1350 }
1351
1352 #[tokio::test]
1353 async fn admin_model_get_history_returns_error_when_not_opted_in() {
1354 let model = DeletingModel {
1355 deleted: Mutex::new(vec![]),
1356 fail_on: None,
1357 };
1358 let pool = dummy_pool();
1359 let err = model
1360 .get_history(&pool, 42, 1, 25)
1361 .await
1362 .expect_err("get_history must error when has_history() is false");
1363 assert!(
1364 matches!(err, AdminError::Other(_)),
1365 "get_history on non-versioned model must return AdminError::Other: {err:?}"
1366 );
1367 }
1368
1369 #[test]
1370 fn admin_history_page_total_pages() {
1371 let page = AdminHistoryPage {
1372 entries: vec![],
1373 total: 51,
1374 page: 1,
1375 per_page: 25,
1376 };
1377 assert_eq!(page.total_pages(), 3);
1378 }
1379
1380 #[test]
1381 fn admin_history_page_has_next_page() {
1382 let page = AdminHistoryPage {
1383 entries: vec![],
1384 total: 50,
1385 page: 1,
1386 per_page: 25,
1387 };
1388 assert!(page.has_next_page());
1389 }
1390
1391 #[test]
1392 fn admin_history_page_no_next_on_last() {
1393 let page = AdminHistoryPage {
1394 entries: vec![],
1395 total: 50,
1396 page: 2,
1397 per_page: 25,
1398 };
1399 assert!(!page.has_next_page());
1400 }
1401
1402 #[test]
1403 fn admin_history_page_zero_per_page() {
1404 let page = AdminHistoryPage {
1405 entries: vec![],
1406 total: 10,
1407 page: 1,
1408 per_page: 0,
1409 };
1410 assert_eq!(page.total_pages(), 0);
1411 }
1412
1413 #[test]
1416 fn sort_direction_as_str_returns_correct_values() {
1417 assert_eq!(SortDirection::Asc.as_str(), "asc");
1418 assert_eq!(SortDirection::Desc.as_str(), "desc");
1419 }
1420
1421 #[test]
1422 fn sort_direction_flipped_returns_opposite() {
1423 assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
1424 assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
1425 }
1426
1427 #[test]
1428 fn admin_field_readonly_sets_editable_false() {
1429 let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
1430 assert!(!field.editable, "readonly() must set editable = false");
1431 }
1432
1433 #[test]
1434 fn admin_field_hide_from_list_sets_list_display_false() {
1435 let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
1436 assert!(
1437 !field.list_display,
1438 "hide_from_list() must set list_display = false"
1439 );
1440 }
1441
1442 #[test]
1443 fn admin_model_record_display_includes_display_name_and_id() {
1444 let model = DeletingModel {
1445 deleted: Mutex::new(vec![]),
1446 fail_on: None,
1447 };
1448 let record = serde_json::json!({"id": 7, "name": "foo"});
1449 assert_eq!(model.record_display(&record), "Tracked #7");
1450 }
1451
1452 #[test]
1453 fn admin_model_record_display_placeholder_when_no_id() {
1454 let model = DeletingModel {
1455 deleted: Mutex::new(vec![]),
1456 fail_on: None,
1457 };
1458 let record = serde_json::json!({"name": "bar"});
1459 assert_eq!(model.record_display(&record), "Tracked <no id>");
1460 }
1461
1462 #[test]
1463 fn admin_model_per_page_default_is_25() {
1464 let model = DeletingModel {
1465 deleted: Mutex::new(vec![]),
1466 fail_on: None,
1467 };
1468 assert_eq!(model.per_page(), 25);
1469 }
1470
1471 #[test]
1472 fn version_page_converts_to_admin_history_page() {
1473 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
1474 use chrono::Utc;
1475
1476 let entry = VersionEntry {
1477 id: 1,
1478 table_name: "posts".to_owned(),
1479 record_id: 42,
1480 op: VersionOp::Update,
1481 actor: "admin".to_owned(),
1482 request_id: Some("req-1".to_owned()),
1483 changes: vec![ColumnChange::new(
1484 "title",
1485 Some(serde_json::json!("old")),
1486 Some(serde_json::json!("new")),
1487 )],
1488 recorded_at: Utc::now(),
1489 };
1490 let vp = VersionPage {
1491 entries: vec![entry],
1492 total: 1,
1493 page: 1,
1494 per_page: 25,
1495 };
1496
1497 let ap = AdminHistoryPage::from(vp);
1498 assert_eq!(ap.total, 1);
1499 assert_eq!(ap.page, 1);
1500 assert_eq!(ap.per_page, 25);
1501 assert_eq!(ap.entries.len(), 1);
1502 let e = &ap.entries[0];
1503 assert_eq!(e.id, 1);
1504 assert_eq!(e.actor, "admin");
1505 assert_eq!(e.op, "update");
1506 assert_eq!(e.request_id.as_deref(), Some("req-1"));
1507 assert_eq!(e.changes.len(), 1);
1508 }
1509
1510 #[test]
1511 fn version_entry_converts_to_admin_history_entry() {
1512 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
1513 use chrono::Utc;
1514
1515 let entry = VersionEntry {
1516 id: 7,
1517 table_name: "users".to_owned(),
1518 record_id: 3,
1519 op: VersionOp::Delete,
1520 actor: "system".to_owned(),
1521 request_id: None,
1522 changes: vec![ColumnChange::sensitive("password_digest")],
1523 recorded_at: Utc::now(),
1524 };
1525
1526 let admin_entry = AdminHistoryEntry::from(entry);
1527 assert_eq!(admin_entry.id, 7);
1528 assert_eq!(admin_entry.actor, "system");
1529 assert_eq!(admin_entry.op, "delete");
1530 assert!(admin_entry.request_id.is_none());
1531 assert_eq!(admin_entry.changes.len(), 1);
1532 }
1533
1534 #[test]
1537 fn csv_import_mode_from_form_value_recognises_insert() {
1538 assert_eq!(
1539 CsvImportMode::from_form_value("insert"),
1540 Some(CsvImportMode::Insert)
1541 );
1542 assert_eq!(
1543 CsvImportMode::from_form_value("Insert"),
1544 Some(CsvImportMode::Insert)
1545 );
1546 }
1547
1548 #[test]
1549 fn csv_import_mode_from_form_value_recognises_dry_run() {
1550 assert_eq!(
1551 CsvImportMode::from_form_value("dry_run"),
1552 Some(CsvImportMode::DryRun)
1553 );
1554 assert_eq!(
1555 CsvImportMode::from_form_value("DryRun"),
1556 Some(CsvImportMode::DryRun)
1557 );
1558 assert_eq!(
1559 CsvImportMode::from_form_value("dry-run"),
1560 Some(CsvImportMode::DryRun)
1561 );
1562 }
1563
1564 #[test]
1565 fn csv_import_mode_from_form_value_rejects_unknown() {
1566 assert_eq!(CsvImportMode::from_form_value("upsert"), None);
1567 assert_eq!(CsvImportMode::from_form_value(""), None);
1568 assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
1569 assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
1570 }
1571
1572 #[test]
1573 fn csv_import_mode_default_is_insert() {
1574 assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
1575 }
1576
1577 #[test]
1580 fn escape_csv_formula_prefixes_equals_sign() {
1581 assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
1582 }
1583
1584 #[test]
1585 fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
1586 assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
1587 assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
1588 assert_eq!(escape_csv_formula("@A1"), "'@A1");
1589 }
1590
1591 #[test]
1592 fn escape_csv_formula_prefixes_tab_and_cr() {
1593 assert_eq!(escape_csv_formula("\thello"), "'\thello");
1594 assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
1595 }
1596
1597 #[test]
1598 fn escape_csv_formula_leaves_normal_strings_unchanged() {
1599 assert_eq!(escape_csv_formula("hello world"), "hello world");
1600 assert_eq!(escape_csv_formula("123"), "123");
1601 assert_eq!(escape_csv_formula(""), "");
1602 assert_eq!(escape_csv_formula("normal,value"), "normal,value");
1603 }
1604
1605 #[test]
1608 fn admin_model_supports_csv_export_defaults_to_false() {
1609 let model = DeletingModel {
1610 deleted: Mutex::new(vec![]),
1611 fail_on: None,
1612 };
1613 assert!(
1614 !model.supports_csv_export(),
1615 "supports_csv_export must default to false to require explicit opt-in"
1616 );
1617 }
1618
1619 #[test]
1620 fn admin_model_supports_csv_import_defaults_to_false() {
1621 let model = DeletingModel {
1622 deleted: Mutex::new(vec![]),
1623 fail_on: None,
1624 };
1625 assert!(
1626 !model.supports_csv_import(),
1627 "supports_csv_import must default to false"
1628 );
1629 }
1630
1631 #[test]
1632 fn csv_export_row_extracts_columns_and_escapes_formulas() {
1633 let model = DeletingModel {
1634 deleted: Mutex::new(vec![]),
1635 fail_on: None,
1636 };
1637 let record = serde_json::json!({
1638 "id": 1,
1639 "name": "Alice",
1640 "formula": "=EVIL()",
1641 "amount": 42.5,
1642 "active": true,
1643 "notes": null,
1644 });
1645 let columns = &[
1646 "id", "name", "formula", "amount", "active", "notes", "missing",
1647 ];
1648 let row = model.csv_export_row(columns, &record);
1649 assert_eq!(row[0], "1");
1650 assert_eq!(row[1], "Alice");
1651 assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
1652 assert_eq!(row[3], "42.5");
1653 assert_eq!(row[4], "true");
1654 assert_eq!(row[5], "", "null becomes empty string");
1655 assert_eq!(row[6], "", "missing column becomes empty string");
1656 }
1657}