Skip to main content

autumn_admin_plugin/
experiments.rs

1//! Admin panel model for `autumn_experiments`.
2//!
3//! Registers an experiment management page at `/admin/experiments/` with:
4//! - List view: name, state, variants, winner
5//! - Edit view: update description, exclusion group, state, variants
6//! - History tab: per-experiment audit trail from `autumn_experiment_changes`
7
8use serde_json::Value;
9
10use crate::{
11    AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryPage, AdminModel, ListParams,
12    ListResult, SelectOption,
13};
14
15/// Admin panel model for A/B experiments.
16///
17/// Register this model with the admin plugin to get an experiment management UI
18/// at `/admin/experiments/`:
19///
20/// ```rust,ignore
21/// use autumn_admin_plugin::{prelude::*, AdminPlugin};
22/// use autumn_admin_plugin::experiments::ExperimentAdminModel;
23///
24/// autumn_web::app()
25///     .plugin(
26///         AdminPlugin::new()
27///             .register(ExperimentAdminModel::default()),
28///     )
29///     .run()
30///     .await;
31/// ```
32#[derive(Debug, Default, Clone)]
33pub struct ExperimentAdminModel;
34
35impl AdminModel for ExperimentAdminModel {
36    fn slug(&self) -> &'static str {
37        "experiments"
38    }
39
40    fn display_name(&self) -> &'static str {
41        "Experiment"
42    }
43
44    fn display_name_plural(&self) -> &'static str {
45        "Experiments"
46    }
47
48    fn record_display(&self, record: &Value) -> String {
49        record
50            .get("name")
51            .and_then(|v| v.as_str())
52            .map_or_else(|| "Experiment".to_owned(), |n| format!("Experiment: {n}"))
53    }
54
55    fn fields(&self) -> Vec<AdminField> {
56        vec![
57            AdminField::new("name", AdminFieldKind::Text)
58                .label("Experiment Name")
59                .searchable(),
60            AdminField::new("description", AdminFieldKind::TextArea)
61                .label("Description")
62                .optional()
63                .searchable(),
64            AdminField::new(
65                "state",
66                AdminFieldKind::Select(vec![
67                    SelectOption {
68                        value: "draft".into(),
69                        label: "Draft".into(),
70                    },
71                    SelectOption {
72                        value: "running".into(),
73                        label: "Running".into(),
74                    },
75                    SelectOption {
76                        value: "concluded".into(),
77                        label: "Concluded".into(),
78                    },
79                    SelectOption {
80                        value: "archived".into(),
81                        label: "Archived".into(),
82                    },
83                ]),
84            )
85            .label("State"),
86            AdminField::new("variants", AdminFieldKind::Json)
87                .label("Variants (JSON)")
88                .optional()
89                .hide_from_list(),
90            AdminField::new("winner", AdminFieldKind::Text)
91                .label("Winner")
92                .optional(),
93            AdminField::new("exclusion_group", AdminFieldKind::Text)
94                .label("Exclusion Group")
95                .optional()
96                .hide_from_list(),
97            AdminField::new("updated_at", AdminFieldKind::DateTime)
98                .label("Last Updated")
99                .readonly()
100                .optional(),
101        ]
102    }
103
104    fn has_history(&self) -> bool {
105        true
106    }
107
108    fn list(
109        &self,
110        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
111        params: ListParams,
112    ) -> AdminFuture<'_, ListResult> {
113        use diesel_async::RunQueryDsl;
114
115        let pool = pool.clone();
116        Box::pin(async move {
117            let mut conn = pool
118                .get()
119                .await
120                .map_err(|e| AdminError::Database(e.to_string()))?;
121
122            let per_page = params.per_page;
123            let offset = if per_page == 0 {
124                0
125            } else {
126                params.page.saturating_sub(1) * per_page
127            };
128            let limit = if per_page == 0 {
129                i64::MAX
130            } else {
131                i64::try_from(per_page).unwrap_or(i64::MAX)
132            };
133            let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
134
135            let total: i64 = diesel::sql_query(
136                "SELECT COUNT(*) FROM autumn_experiments \
137                 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
138            )
139            .bind::<diesel::sql_types::Text, _>(&search_pattern)
140            .get_result::<CountRow>(&mut conn)
141            .await
142            .map_or(0, |r| r.count);
143
144            let records: Vec<Value> = diesel::sql_query(
145                "SELECT id, name, description, state::text AS state, \
146                        variants::text AS variants, winner, updated_at \
147                 FROM autumn_experiments \
148                 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
149                 ORDER BY name \
150                 LIMIT $2 OFFSET $3",
151            )
152            .bind::<diesel::sql_types::Text, _>(&search_pattern)
153            .bind::<diesel::sql_types::BigInt, _>(limit)
154            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
155            .load::<ExperimentRow>(&mut conn)
156            .await
157            .map(|rows| rows.into_iter().map(ExperimentRow::into_json).collect())
158            .map_err(|e| AdminError::Database(e.to_string()))?;
159
160            Ok(ListResult {
161                total: u64::try_from(total).unwrap_or(0),
162                page: params.page,
163                per_page,
164                records,
165            })
166        })
167    }
168
169    fn get(
170        &self,
171        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
172        id: i64,
173    ) -> AdminFuture<'_, Option<Value>> {
174        use diesel::prelude::*;
175        use diesel_async::RunQueryDsl;
176
177        let pool = pool.clone();
178        Box::pin(async move {
179            let mut conn = pool
180                .get()
181                .await
182                .map_err(|e| AdminError::Database(e.to_string()))?;
183
184            diesel::sql_query(
185                "SELECT id, name, description, state::text AS state, \
186                        variants::text AS variants, winner, exclusion_group, updated_at \
187                 FROM autumn_experiments WHERE id = $1",
188            )
189            .bind::<diesel::sql_types::BigInt, _>(id)
190            .get_result::<ExperimentDetailRow>(&mut conn)
191            .await
192            .optional()
193            .map(|r| r.map(ExperimentDetailRow::into_json))
194            .map_err(|e| AdminError::Database(e.to_string()))
195        })
196    }
197
198    fn create(
199        &self,
200        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
201        data: Value,
202    ) -> AdminFuture<'_, Value> {
203        use diesel_async::RunQueryDsl;
204
205        let pool = pool.clone();
206        Box::pin(async move {
207            let mut conn = pool
208                .get()
209                .await
210                .map_err(|e| AdminError::Database(e.to_string()))?;
211
212            let name = data
213                .get("name")
214                .and_then(Value::as_str)
215                .ok_or_else(|| AdminError::Validation("'name' is required".into()))?;
216            let description = data.get("description").and_then(Value::as_str);
217            let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
218            let variants = validate_variants_json(&extract_variants_str(&data))?;
219            let winner = data.get("winner").and_then(Value::as_str);
220            let exclusion_group = data
221                .get("exclusion_group")
222                .and_then(Value::as_str)
223                .filter(|s| !s.trim().is_empty());
224
225            // Concluding requires a non-empty winner that is a configured variant.
226            if state == "concluded" {
227                let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
228                    AdminError::Validation(
229                        "a concluded experiment requires a non-empty winner".into(),
230                    )
231                })?;
232                let arr: Vec<serde_json::Value> =
233                    serde_json::from_str(&variants).unwrap_or_default();
234                if !arr
235                    .iter()
236                    .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
237                {
238                    return Err(AdminError::Validation(format!(
239                        "winner '{w}' is not a configured variant"
240                    )));
241                }
242            }
243
244            let row = diesel::sql_query(
245                "WITH inserted AS ( \
246                     INSERT INTO autumn_experiments \
247                         (name, description, state, variants, winner, exclusion_group) \
248                     VALUES ($1, $2, $3::autumn_experiment_state, $4::jsonb, $5, $6) \
249                     RETURNING id, name, description, state::text AS state, \
250                               variants::text AS variants, winner, updated_at \
251                 ), \
252                 _audit AS ( \
253                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
254                     SELECT name, 'created', NULL FROM inserted \
255                 ) \
256                 SELECT id, name, description, state, variants, winner, updated_at \
257                 FROM inserted",
258            )
259            .bind::<diesel::sql_types::Text, _>(name)
260            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
261                description.map(str::to_owned),
262            )
263            .bind::<diesel::sql_types::Text, _>(state)
264            .bind::<diesel::sql_types::Text, _>(variants)
265            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
266                winner.map(str::to_owned),
267            )
268            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
269                exclusion_group.map(str::to_owned),
270            )
271            .get_result::<ExperimentRow>(&mut conn)
272            .await
273            .map_err(|e| {
274                if matches!(
275                    e,
276                    diesel::result::Error::DatabaseError(
277                        diesel::result::DatabaseErrorKind::UniqueViolation,
278                        _
279                    )
280                ) {
281                    AdminError::Validation(format!("an experiment named '{name}' already exists"))
282                } else {
283                    AdminError::Database(e.to_string())
284                }
285            })?;
286
287            Ok(ExperimentRow::into_json(row))
288        })
289    }
290
291    #[allow(clippy::too_many_lines)]
292    fn update(
293        &self,
294        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
295        id: i64,
296        data: Value,
297    ) -> AdminFuture<'_, Value> {
298        use diesel::prelude::*;
299        use diesel_async::RunQueryDsl;
300
301        let pool = pool.clone();
302        Box::pin(async move {
303            let mut conn = pool
304                .get()
305                .await
306                .map_err(|e| AdminError::Database(e.to_string()))?;
307
308            let current = diesel::sql_query(
309                "SELECT name, state::text AS state FROM autumn_experiments WHERE id = $1",
310            )
311            .bind::<diesel::sql_types::BigInt, _>(id)
312            .get_result::<NameStateRow>(&mut conn)
313            .await
314            .optional()
315            .map_err(|e| AdminError::Database(e.to_string()))?;
316
317            let Some(NameStateRow {
318                name,
319                state: state_str,
320            }) = current
321            else {
322                return Err(AdminError::Validation("experiment not found".into()));
323            };
324
325            if state_str == "archived" {
326                return Err(AdminError::Validation(
327                    "archived experiments cannot be edited".into(),
328                ));
329            }
330
331            // name is read-only after creation; we read it only to satisfy field validation
332            // and for display — the SQL does not allow renaming an experiment.
333            let description = data.get("description").and_then(Value::as_str);
334            let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
335            let variants = validate_variants_json(&extract_variants_str(&data))?;
336            let winner = data.get("winner").and_then(Value::as_str);
337
338            let parsed_new_variants: Vec<serde_json::Value> = serde_json::from_str(&variants)
339                .map_err(|e| AdminError::Validation(format!("invalid variants JSON: {e}")))?;
340            let new_variant_names: std::collections::HashSet<&str> = parsed_new_variants
341                .iter()
342                .filter_map(|v| v.get("name").and_then(Value::as_str))
343                .collect();
344
345            let active_variants = diesel::sql_query(
346                "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
347            )
348            .bind::<diesel::sql_types::Text, _>(&name)
349            .load::<VariantNameRow>(&mut conn)
350            .await
351            .map_err(|e| AdminError::Database(e.to_string()))?;
352
353            for row in active_variants {
354                if !new_variant_names.contains(row.variant.as_str()) {
355                    return Err(AdminError::Validation(format!(
356                        "cannot delete variant '{}' because it has active assignments",
357                        row.variant
358                    )));
359                }
360            }
361
362            // Concluding requires a non-empty winner that is a configured variant.
363            if state == "concluded" {
364                let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
365                    AdminError::Validation(
366                        "a concluded experiment requires a non-empty winner".into(),
367                    )
368                })?;
369                let arr: Vec<serde_json::Value> =
370                    serde_json::from_str(&variants).unwrap_or_default();
371                if !arr
372                    .iter()
373                    .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
374                {
375                    return Err(AdminError::Validation(format!(
376                        "winner '{w}' is not a configured variant"
377                    )));
378                }
379            }
380            let exclusion_group = data
381                .get("exclusion_group")
382                .and_then(Value::as_str)
383                .filter(|s| !s.trim().is_empty());
384
385            let row = diesel::sql_query(
386                "WITH updated AS ( \
387                     UPDATE autumn_experiments \
388                     SET description = $2, \
389                         state = $3::autumn_experiment_state, \
390                         variants = $4::jsonb, winner = $5, \
391                         exclusion_group = $6, updated_at = NOW() \
392                     WHERE id = $1 \
393                     RETURNING id, name, description, state::text AS state, \
394                               variants::text AS variants, winner, updated_at \
395                 ), \
396                 _audit AS ( \
397                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
398                     SELECT name, 'updated', NULL FROM updated \
399                 ) \
400                 SELECT id, name, description, state, variants, winner, updated_at \
401                 FROM updated",
402            )
403            .bind::<diesel::sql_types::BigInt, _>(id)
404            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
405                description.map(str::to_owned),
406            )
407            .bind::<diesel::sql_types::Text, _>(state)
408            .bind::<diesel::sql_types::Text, _>(variants)
409            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
410                winner.map(str::to_owned),
411            )
412            .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
413                exclusion_group.map(str::to_owned),
414            )
415            .get_result::<ExperimentRow>(&mut conn)
416            .await
417            .map_err(|e| AdminError::Database(e.to_string()))?;
418
419            Ok(ExperimentRow::into_json(row))
420        })
421    }
422
423    fn delete(
424        &self,
425        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
426        id: i64,
427    ) -> AdminFuture<'_, ()> {
428        use diesel_async::RunQueryDsl;
429
430        let pool = pool.clone();
431        Box::pin(async move {
432            let mut conn = pool
433                .get()
434                .await
435                .map_err(|e| AdminError::Database(e.to_string()))?;
436
437            diesel::sql_query(
438                "WITH deleted AS ( \
439                     DELETE FROM autumn_experiments WHERE id = $1 RETURNING name \
440                 ), \
441                 _del_assignments AS ( \
442                     DELETE FROM autumn_experiment_assignments \
443                     WHERE experiment IN (SELECT name FROM deleted) \
444                 ), \
445                 _del_overrides AS ( \
446                     DELETE FROM autumn_experiment_overrides \
447                     WHERE experiment IN (SELECT name FROM deleted) \
448                 ), \
449                 _audit AS ( \
450                     INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
451                     SELECT name, 'deleted', NULL FROM deleted \
452                 ) \
453                 SELECT COUNT(*) AS count FROM deleted",
454            )
455            .bind::<diesel::sql_types::BigInt, _>(id)
456            .get_result::<CountRow>(&mut conn)
457            .await
458            .map_err(|e| AdminError::Database(e.to_string()))?;
459
460            Ok(())
461        })
462    }
463
464    fn get_history<'a>(
465        &'a self,
466        pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
467        record_id: i64,
468        page: u64,
469        per_page: u64,
470    ) -> AdminFuture<'a, AdminHistoryPage> {
471        use diesel::prelude::*;
472        use diesel_async::RunQueryDsl;
473
474        let pool = pool.clone();
475        Box::pin(async move {
476            let mut conn = pool
477                .get()
478                .await
479                .map_err(|e| AdminError::Database(e.to_string()))?;
480
481            let name: Option<String> =
482                diesel::sql_query("SELECT name FROM autumn_experiments WHERE id = $1")
483                    .bind::<diesel::sql_types::BigInt, _>(record_id)
484                    .get_result::<NameRow>(&mut conn)
485                    .await
486                    .optional()
487                    .unwrap_or(None)
488                    .map(|r| r.name);
489
490            let Some(name) = name else {
491                return Ok(AdminHistoryPage {
492                    entries: vec![],
493                    total: 0,
494                    page,
495                    per_page,
496                });
497            };
498
499            let count: i64 = diesel::sql_query(
500                "SELECT COUNT(*) FROM autumn_experiment_changes WHERE experiment = $1",
501            )
502            .bind::<diesel::sql_types::Text, _>(&name)
503            .get_result::<CountRow>(&mut conn)
504            .await
505            .map_or(0, |r| r.count);
506
507            let offset = (page.saturating_sub(1)) * per_page;
508            let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(
509                "SELECT id, mutation AS op, actor, changed_at \
510                 FROM autumn_experiment_changes \
511                 WHERE experiment = $1 \
512                 ORDER BY changed_at DESC \
513                 LIMIT $2 OFFSET $3",
514            )
515            .bind::<diesel::sql_types::Text, _>(&name)
516            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
517            .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
518            .load::<HistoryRow>(&mut conn)
519            .await
520            .unwrap_or_default()
521            .into_iter()
522            .map(|r| crate::AdminHistoryEntry {
523                id: r.id,
524                actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
525                op: r.op,
526                request_id: None,
527                changes: vec![],
528                recorded_at: r.changed_at,
529            })
530            .collect();
531
532            Ok(AdminHistoryPage {
533                entries,
534                total: u64::try_from(count).unwrap_or(0),
535                page,
536                per_page,
537            })
538        })
539    }
540}
541
542// ── Helpers ───────────────────────────────────────────────────────────────────
543
544/// Extract `variants` from admin form data, handling both pre-parsed JSON arrays
545/// (normalized by the admin route) and raw JSON strings submitted by the form.
546fn extract_variants_str(data: &Value) -> String {
547    match data.get("variants") {
548        Some(Value::String(s)) => s.clone(),
549        Some(v) if !v.is_null() => serde_json::to_string(v).unwrap_or_else(|_| "[]".to_owned()),
550        _ => "[]".to_owned(),
551    }
552}
553
554/// Validate `raw` as a JSON array of `{"name": string, "weight": integer}` objects.
555fn validate_variants_json(raw: &str) -> Result<String, AdminError> {
556    let trimmed = raw.trim();
557    if trimmed.is_empty() {
558        return Ok("[]".to_owned());
559    }
560    match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
561        Ok(arr) => {
562            let mut seen_names = std::collections::HashSet::new();
563            for (i, v) in arr.iter().enumerate() {
564                let name_str = match v.get("name").and_then(|n| n.as_str()) {
565                    None => {
566                        return Err(AdminError::Validation(format!(
567                            "variants[{i}].name must be a string"
568                        )));
569                    }
570                    Some(n) if n.trim().is_empty() => {
571                        return Err(AdminError::Validation(format!(
572                            "variants[{i}].name must not be empty"
573                        )));
574                    }
575                    Some(n) => n,
576                };
577                if !seen_names.insert(name_str) {
578                    return Err(AdminError::Validation(format!(
579                        "duplicate variant name '{name_str}' at variants[{i}]"
580                    )));
581                }
582                match v.get("weight").and_then(Value::as_u64) {
583                    None => {
584                        return Err(AdminError::Validation(format!(
585                            "variants[{i}].weight must be a non-negative integer"
586                        )));
587                    }
588                    Some(w) if w > u64::from(u32::MAX) => {
589                        return Err(AdminError::Validation(format!(
590                            "variants[{i}].weight must not exceed {} (u32::MAX)",
591                            u32::MAX
592                        )));
593                    }
594                    _ => {}
595                }
596            }
597            Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
598        }
599        Err(_) => Err(AdminError::Validation(
600            "'variants' must be a valid JSON array".into(),
601        )),
602    }
603}
604
605// ── Row types ─────────────────────────────────────────────────────────────────
606
607#[derive(diesel::QueryableByName)]
608struct CountRow {
609    #[diesel(sql_type = diesel::sql_types::BigInt)]
610    count: i64,
611}
612
613#[derive(diesel::QueryableByName)]
614struct NameRow {
615    #[diesel(sql_type = diesel::sql_types::Text)]
616    name: String,
617}
618
619#[derive(diesel::QueryableByName)]
620struct NameStateRow {
621    #[diesel(sql_type = diesel::sql_types::Text)]
622    name: String,
623    #[diesel(sql_type = diesel::sql_types::Text)]
624    state: String,
625}
626
627#[derive(diesel::QueryableByName)]
628struct VariantNameRow {
629    #[diesel(sql_type = diesel::sql_types::Text)]
630    variant: String,
631}
632
633/// Row returned from list queries (no `exclusion_group` for brevity in list view).
634#[derive(diesel::QueryableByName)]
635struct ExperimentRow {
636    #[diesel(sql_type = diesel::sql_types::BigInt)]
637    id: i64,
638    #[diesel(sql_type = diesel::sql_types::Text)]
639    name: String,
640    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
641    description: Option<String>,
642    #[diesel(sql_type = diesel::sql_types::Text)]
643    state: String,
644    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
645    variants: Option<String>,
646    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
647    winner: Option<String>,
648    #[diesel(sql_type = diesel::sql_types::Timestamptz)]
649    updated_at: chrono::DateTime<chrono::Utc>,
650}
651
652impl ExperimentRow {
653    fn into_json(self) -> Value {
654        serde_json::json!({
655            "id": self.id,
656            "name": self.name,
657            "description": self.description,
658            "state": self.state,
659            "variants": self.variants,
660            "winner": self.winner,
661            "updated_at": self.updated_at.to_rfc3339(),
662        })
663    }
664}
665
666/// Row returned from detail (get) queries — includes `exclusion_group`.
667#[derive(diesel::QueryableByName)]
668struct ExperimentDetailRow {
669    #[diesel(sql_type = diesel::sql_types::BigInt)]
670    id: i64,
671    #[diesel(sql_type = diesel::sql_types::Text)]
672    name: String,
673    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
674    description: Option<String>,
675    #[diesel(sql_type = diesel::sql_types::Text)]
676    state: String,
677    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
678    variants: Option<String>,
679    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
680    winner: Option<String>,
681    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
682    exclusion_group: Option<String>,
683    #[diesel(sql_type = diesel::sql_types::Timestamptz)]
684    updated_at: chrono::DateTime<chrono::Utc>,
685}
686
687impl ExperimentDetailRow {
688    fn into_json(self) -> Value {
689        serde_json::json!({
690            "id": self.id,
691            "name": self.name,
692            "description": self.description,
693            "state": self.state,
694            "variants": self.variants,
695            "winner": self.winner,
696            "exclusion_group": self.exclusion_group,
697            "updated_at": self.updated_at.to_rfc3339(),
698        })
699    }
700}
701
702#[derive(diesel::QueryableByName)]
703struct HistoryRow {
704    #[diesel(sql_type = diesel::sql_types::BigInt)]
705    id: i64,
706    #[diesel(sql_type = diesel::sql_types::Text)]
707    op: String,
708    #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
709    actor: Option<String>,
710    #[diesel(sql_type = diesel::sql_types::Timestamptz)]
711    changed_at: chrono::DateTime<chrono::Utc>,
712}
713
714// ── Tests ─────────────────────────────────────────────────────────────────────
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn experiment_admin_model_slug() {
722        let model = ExperimentAdminModel;
723        assert_eq!(model.slug(), "experiments");
724    }
725
726    #[test]
727    fn experiment_admin_model_display_names() {
728        let model = ExperimentAdminModel;
729        assert_eq!(model.display_name(), "Experiment");
730        assert_eq!(model.display_name_plural(), "Experiments");
731    }
732
733    #[test]
734    fn experiment_admin_model_has_history() {
735        let model = ExperimentAdminModel;
736        assert!(model.has_history(), "experiment admin must expose history");
737    }
738
739    #[test]
740    fn experiment_admin_model_has_expected_fields() {
741        let model = ExperimentAdminModel;
742        let fields = model.fields();
743        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
744        assert!(names.contains(&"name"), "must have 'name' field");
745        assert!(names.contains(&"state"), "must have 'state' field");
746        assert!(names.contains(&"variants"), "must have 'variants' field");
747        assert!(names.contains(&"winner"), "must have 'winner' field");
748        assert!(
749            names.contains(&"exclusion_group"),
750            "must have 'exclusion_group' field"
751        );
752    }
753
754    #[test]
755    fn experiment_admin_model_state_field_has_all_lifecycle_states() {
756        let model = ExperimentAdminModel;
757        let state_field = model
758            .fields()
759            .into_iter()
760            .find(|f| f.name == "state")
761            .expect("state field must exist");
762        let AdminFieldKind::Select(options) = state_field.kind else {
763            panic!("state field must be Select");
764        };
765        let values: Vec<&str> = options.iter().map(|o| o.value.as_str()).collect();
766        assert!(values.contains(&"draft"));
767        assert!(values.contains(&"running"));
768        assert!(values.contains(&"concluded"));
769        assert!(values.contains(&"archived"));
770    }
771
772    #[test]
773    fn record_display_uses_experiment_name() {
774        let model = ExperimentAdminModel;
775        let record = serde_json::json!({"name": "checkout_v2", "state": "running"});
776        assert_eq!(model.record_display(&record), "Experiment: checkout_v2");
777    }
778
779    #[test]
780    fn record_display_fallback_when_no_name() {
781        let model = ExperimentAdminModel;
782        let record = serde_json::json!({});
783        assert_eq!(model.record_display(&record), "Experiment");
784    }
785
786    #[test]
787    fn validate_variants_json_accepts_valid_array() {
788        let json = validate_variants_json(
789            r#"[{"name":"control","weight":50},{"name":"treatment","weight":50}]"#,
790        )
791        .unwrap();
792        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
793        assert_eq!(v.as_array().unwrap().len(), 2);
794    }
795
796    #[test]
797    fn validate_variants_json_accepts_empty_string() {
798        assert_eq!(validate_variants_json("").unwrap(), "[]");
799    }
800
801    #[test]
802    fn validate_variants_json_rejects_missing_name() {
803        let err = validate_variants_json(r#"[{"weight":50}]"#).unwrap_err();
804        assert!(err.to_string().contains("name"));
805    }
806
807    #[test]
808    fn validate_variants_json_rejects_missing_weight() {
809        let err = validate_variants_json(r#"[{"name":"control"}]"#).unwrap_err();
810        assert!(err.to_string().contains("weight"));
811    }
812
813    #[test]
814    fn validate_variants_json_rejects_invalid_json() {
815        let err = validate_variants_json("{not json}").unwrap_err();
816        assert!(err.to_string().contains("JSON"));
817    }
818
819    #[test]
820    fn validate_variants_json_rejects_empty_name() {
821        let err = validate_variants_json(r#"[{"name":"","weight":100}]"#).unwrap_err();
822        assert!(
823            err.to_string().contains("empty"),
824            "expected empty-name error, got: {err}"
825        );
826    }
827
828    #[test]
829    fn validate_variants_json_rejects_whitespace_only_name() {
830        let err = validate_variants_json(r#"[{"name":"   ","weight":100}]"#).unwrap_err();
831        assert!(
832            err.to_string().contains("empty"),
833            "expected empty-name error, got: {err}"
834        );
835    }
836}