1use serde_json::Value;
9
10use crate::{
11 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryPage, AdminModel, ListParams,
12 ListResult, SelectOption,
13};
14
15#[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 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 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 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
542fn 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
554fn 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#[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#[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#[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#[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}