1use serde_json::Value;
9
10use crate::{
11 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryPage, AdminModel, ListParams,
12 ListResult, SelectOption,
13};
14
15diesel::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 fn count_grouped_by_experiment() -> Vec<(String, i64)>;
54}
55
56#[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 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 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 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 drop(conn);
573
574 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
598fn 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
610fn 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#[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#[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#[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#[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}