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