1use serde_json::Value;
9
10use crate::{
11 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminHistoryPage, AdminModel, ListParams,
12 ListResult, SelectOption,
13};
14
15diesel::table! {
49 autumn_experiment_changes (id) {
50 id -> diesel::sql_types::Int8,
51 experiment -> diesel::sql_types::Text,
52 mutation -> diesel::sql_types::Text,
53 actor -> diesel::sql_types::Nullable<diesel::sql_types::Text>,
54 changed_at -> diesel::sql_types::Timestamp,
55 }
56}
57
58#[autumn_web::model(table = "autumn_experiment_changes")]
59pub struct ExperimentChange {
60 #[id]
61 pub id: i64,
62 #[indexed]
63 pub experiment: String,
64 pub mutation: String,
65 pub actor: Option<String>,
66 #[default]
67 pub changed_at: chrono::NaiveDateTime,
68}
69
70#[autumn_web::repository(ExperimentChange, table = "autumn_experiment_changes")]
71pub trait ExperimentChangeRepository {
72 fn count_grouped_by_experiment() -> Vec<(String, i64)>;
76}
77
78#[derive(Debug, Default, Clone)]
104pub struct ExperimentAdminModel;
105
106impl AdminModel for ExperimentAdminModel {
107 fn slug(&self) -> &'static str {
108 "experiments"
109 }
110
111 fn display_name(&self) -> &'static str {
112 "Experiment"
113 }
114
115 fn display_name_plural(&self) -> &'static str {
116 "Experiments"
117 }
118
119 fn record_display(&self, record: &Value) -> String {
120 record
121 .get("name")
122 .and_then(|v| v.as_str())
123 .map_or_else(|| "Experiment".to_owned(), |n| format!("Experiment: {n}"))
124 }
125
126 fn fields(&self) -> Vec<AdminField> {
127 vec![
128 AdminField::new("name", AdminFieldKind::Text)
129 .label("Experiment Name")
130 .searchable(),
131 AdminField::new("description", AdminFieldKind::TextArea)
132 .label("Description")
133 .optional()
134 .searchable(),
135 AdminField::new(
136 "state",
137 AdminFieldKind::Select(vec![
138 SelectOption {
139 value: "draft".into(),
140 label: "Draft".into(),
141 },
142 SelectOption {
143 value: "running".into(),
144 label: "Running".into(),
145 },
146 SelectOption {
147 value: "concluded".into(),
148 label: "Concluded".into(),
149 },
150 SelectOption {
151 value: "archived".into(),
152 label: "Archived".into(),
153 },
154 ]),
155 )
156 .label("State"),
157 AdminField::new("variants", AdminFieldKind::Json)
158 .label("Variants (JSON)")
159 .optional()
160 .hide_from_list(),
161 AdminField::new("winner", AdminFieldKind::Text)
162 .label("Winner")
163 .optional(),
164 AdminField::new("exclusion_group", AdminFieldKind::Text)
165 .label("Exclusion Group")
166 .optional()
167 .hide_from_list(),
168 AdminField::new("updated_at", AdminFieldKind::DateTime)
169 .label("Last Updated")
170 .readonly()
171 .optional(),
172 ]
173 }
174
175 fn has_history(&self) -> bool {
176 true
177 }
178
179 fn list(
180 &self,
181 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
182 params: ListParams,
183 ) -> AdminFuture<'_, ListResult> {
184 use diesel_async::RunQueryDsl;
185
186 let pool = pool.clone();
187 Box::pin(async move {
188 crate::traits::require_postgres("ExperimentAdminModel")?;
189 let mut conn = pool
190 .get()
191 .await
192 .map_err(|e| AdminError::Database(e.to_string()))?;
193
194 let per_page = params.per_page;
195 let (offset, limit) = params.sql_offset_limit();
196 let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
197
198 let total: i64 = diesel::sql_query(
199 "SELECT COUNT(*) FROM autumn_experiments \
200 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
201 )
202 .bind::<diesel::sql_types::Text, _>(&search_pattern)
203 .get_result::<CountRow>(&mut conn)
204 .await
205 .map_or(0, |r| r.count);
206
207 let records: Vec<Value> = diesel::sql_query(
208 "SELECT id, name, description, state::text AS state, \
209 variants::text AS variants, winner, updated_at \
210 FROM autumn_experiments \
211 WHERE (name ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
212 ORDER BY name \
213 LIMIT $2 OFFSET $3",
214 )
215 .bind::<diesel::sql_types::Text, _>(&search_pattern)
216 .bind::<diesel::sql_types::BigInt, _>(limit)
217 .bind::<diesel::sql_types::BigInt, _>(offset)
218 .load::<ExperimentRow>(&mut conn)
219 .await
220 .map(|rows| rows.into_iter().map(ExperimentRow::into_json).collect())
221 .map_err(|e| AdminError::Database(e.to_string()))?;
222
223 Ok(ListResult {
224 total: u64::try_from(total).unwrap_or(0),
225 page: params.page,
226 per_page,
227 records,
228 })
229 })
230 }
231
232 fn get(
233 &self,
234 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
235 id: i64,
236 ) -> AdminFuture<'_, Option<Value>> {
237 use diesel::prelude::*;
238 use diesel_async::RunQueryDsl;
239
240 let pool = pool.clone();
241 Box::pin(async move {
242 crate::traits::require_postgres("ExperimentAdminModel")?;
243 let mut conn = pool
244 .get()
245 .await
246 .map_err(|e| AdminError::Database(e.to_string()))?;
247
248 diesel::sql_query(
249 "SELECT id, name, description, state::text AS state, \
250 variants::text AS variants, winner, exclusion_group, updated_at \
251 FROM autumn_experiments WHERE id = $1",
252 )
253 .bind::<diesel::sql_types::BigInt, _>(id)
254 .get_result::<ExperimentDetailRow>(&mut conn)
255 .await
256 .optional()
257 .map(|r| r.map(ExperimentDetailRow::into_json))
258 .map_err(|e| AdminError::Database(e.to_string()))
259 })
260 }
261
262 fn create(
263 &self,
264 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
265 data: Value,
266 ) -> AdminFuture<'_, Value> {
267 use diesel_async::RunQueryDsl;
268
269 let pool = pool.clone();
270 Box::pin(async move {
271 crate::traits::require_postgres("ExperimentAdminModel")?;
272 let mut conn = pool
273 .get()
274 .await
275 .map_err(|e| AdminError::Database(e.to_string()))?;
276
277 let name = data
278 .get("name")
279 .and_then(Value::as_str)
280 .ok_or_else(|| AdminError::Validation("'name' is required".into()))?;
281 let description = data.get("description").and_then(Value::as_str);
282 let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
283 let variants = validate_variants_json(&extract_variants_str(&data))?;
284 let winner = data.get("winner").and_then(Value::as_str);
285 let exclusion_group = data
286 .get("exclusion_group")
287 .and_then(Value::as_str)
288 .filter(|s| !s.trim().is_empty());
289
290 if state == "concluded" {
292 let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
293 AdminError::Validation(
294 "a concluded experiment requires a non-empty winner".into(),
295 )
296 })?;
297 let arr: Vec<serde_json::Value> =
298 serde_json::from_str(&variants).unwrap_or_default();
299 if !arr
300 .iter()
301 .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
302 {
303 return Err(AdminError::Validation(format!(
304 "winner '{w}' is not a configured variant"
305 )));
306 }
307 }
308
309 let row = diesel::sql_query(
310 "WITH inserted AS ( \
311 INSERT INTO autumn_experiments \
312 (name, description, state, variants, winner, exclusion_group) \
313 VALUES ($1, $2, $3::autumn_experiment_state, $4::jsonb, $5, $6) \
314 RETURNING id, name, description, state::text AS state, \
315 variants::text AS variants, winner, updated_at \
316 ), \
317 _audit AS ( \
318 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
319 SELECT name, 'created', NULL FROM inserted \
320 ) \
321 SELECT id, name, description, state, variants, winner, updated_at \
322 FROM inserted",
323 )
324 .bind::<diesel::sql_types::Text, _>(name)
325 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
326 description.map(str::to_owned),
327 )
328 .bind::<diesel::sql_types::Text, _>(state)
329 .bind::<diesel::sql_types::Text, _>(variants)
330 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
331 winner.map(str::to_owned),
332 )
333 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
334 exclusion_group.map(str::to_owned),
335 )
336 .get_result::<ExperimentRow>(&mut conn)
337 .await
338 .map_err(|e| {
339 if matches!(
340 e,
341 diesel::result::Error::DatabaseError(
342 diesel::result::DatabaseErrorKind::UniqueViolation,
343 _
344 )
345 ) {
346 AdminError::Validation(format!("an experiment named '{name}' already exists"))
347 } else {
348 AdminError::Database(e.to_string())
349 }
350 })?;
351
352 Ok(ExperimentRow::into_json(row))
353 })
354 }
355
356 #[allow(clippy::too_many_lines)]
357 fn update(
358 &self,
359 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
360 id: i64,
361 data: Value,
362 ) -> AdminFuture<'_, Value> {
363 use diesel::prelude::*;
364 use diesel_async::RunQueryDsl;
365
366 let pool = pool.clone();
367 Box::pin(async move {
368 crate::traits::require_postgres("ExperimentAdminModel")?;
369 let mut conn = pool
370 .get()
371 .await
372 .map_err(|e| AdminError::Database(e.to_string()))?;
373
374 let current = diesel::sql_query(
375 "SELECT name, state::text AS state FROM autumn_experiments WHERE id = $1",
376 )
377 .bind::<diesel::sql_types::BigInt, _>(id)
378 .get_result::<NameStateRow>(&mut conn)
379 .await
380 .optional()
381 .map_err(|e| AdminError::Database(e.to_string()))?;
382
383 let Some(NameStateRow {
384 name,
385 state: state_str,
386 }) = current
387 else {
388 return Err(AdminError::Validation("experiment not found".into()));
389 };
390
391 if state_str == "archived" {
392 return Err(AdminError::Validation(
393 "archived experiments cannot be edited".into(),
394 ));
395 }
396
397 let description = data.get("description").and_then(Value::as_str);
400 let state = data.get("state").and_then(Value::as_str).unwrap_or("draft");
401 let variants = validate_variants_json(&extract_variants_str(&data))?;
402 let winner = data.get("winner").and_then(Value::as_str);
403
404 let parsed_new_variants: Vec<serde_json::Value> = serde_json::from_str(&variants)
405 .map_err(|e| AdminError::Validation(format!("invalid variants JSON: {e}")))?;
406 let new_variant_names: std::collections::HashSet<&str> = parsed_new_variants
407 .iter()
408 .filter_map(|v| v.get("name").and_then(Value::as_str))
409 .collect();
410
411 let active_variants = diesel::sql_query(
412 "SELECT DISTINCT variant FROM autumn_experiment_assignments WHERE experiment = $1",
413 )
414 .bind::<diesel::sql_types::Text, _>(&name)
415 .load::<VariantNameRow>(&mut conn)
416 .await
417 .map_err(|e| AdminError::Database(e.to_string()))?;
418
419 for row in active_variants {
420 if !new_variant_names.contains(row.variant.as_str()) {
421 return Err(AdminError::Validation(format!(
422 "cannot delete variant '{}' because it has active assignments",
423 row.variant
424 )));
425 }
426 }
427
428 if state == "concluded" {
430 let w = winner.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
431 AdminError::Validation(
432 "a concluded experiment requires a non-empty winner".into(),
433 )
434 })?;
435 let arr: Vec<serde_json::Value> =
436 serde_json::from_str(&variants).unwrap_or_default();
437 if !arr
438 .iter()
439 .any(|v| v.get("name").and_then(Value::as_str) == Some(w))
440 {
441 return Err(AdminError::Validation(format!(
442 "winner '{w}' is not a configured variant"
443 )));
444 }
445 }
446 let exclusion_group = data
447 .get("exclusion_group")
448 .and_then(Value::as_str)
449 .filter(|s| !s.trim().is_empty());
450
451 let row = diesel::sql_query(
452 "WITH updated AS ( \
453 UPDATE autumn_experiments \
454 SET description = $2, \
455 state = $3::autumn_experiment_state, \
456 variants = $4::jsonb, winner = $5, \
457 exclusion_group = $6, updated_at = NOW() \
458 WHERE id = $1 \
459 RETURNING id, name, description, state::text AS state, \
460 variants::text AS variants, winner, updated_at \
461 ), \
462 _audit AS ( \
463 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
464 SELECT name, 'updated', NULL FROM updated \
465 ) \
466 SELECT id, name, description, state, variants, winner, updated_at \
467 FROM updated",
468 )
469 .bind::<diesel::sql_types::BigInt, _>(id)
470 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
471 description.map(str::to_owned),
472 )
473 .bind::<diesel::sql_types::Text, _>(state)
474 .bind::<diesel::sql_types::Text, _>(variants)
475 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
476 winner.map(str::to_owned),
477 )
478 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
479 exclusion_group.map(str::to_owned),
480 )
481 .get_result::<ExperimentRow>(&mut conn)
482 .await
483 .map_err(|e| AdminError::Database(e.to_string()))?;
484
485 Ok(ExperimentRow::into_json(row))
486 })
487 }
488
489 fn delete(
490 &self,
491 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
492 id: i64,
493 ) -> AdminFuture<'_, ()> {
494 use diesel_async::RunQueryDsl;
495
496 let pool = pool.clone();
497 Box::pin(async move {
498 crate::traits::require_postgres("ExperimentAdminModel")?;
499 let mut conn = pool
500 .get()
501 .await
502 .map_err(|e| AdminError::Database(e.to_string()))?;
503
504 diesel::sql_query(
505 "WITH deleted AS ( \
506 DELETE FROM autumn_experiments WHERE id = $1 RETURNING name \
507 ), \
508 _del_assignments AS ( \
509 DELETE FROM autumn_experiment_assignments \
510 WHERE experiment IN (SELECT name FROM deleted) \
511 ), \
512 _del_overrides AS ( \
513 DELETE FROM autumn_experiment_overrides \
514 WHERE experiment IN (SELECT name FROM deleted) \
515 ), \
516 _audit AS ( \
517 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
518 SELECT name, 'deleted', NULL FROM deleted \
519 ) \
520 SELECT COUNT(*) AS count FROM deleted",
521 )
522 .bind::<diesel::sql_types::BigInt, _>(id)
523 .get_result::<CountRow>(&mut conn)
524 .await
525 .map_err(|e| AdminError::Database(e.to_string()))?;
526
527 Ok(())
528 })
529 }
530
531 fn execute_action(
532 &self,
533 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
534 action: &str,
535 ids: Vec<i64>,
536 ) -> AdminFuture<'_, u64> {
537 if action == "delete" {
549 let pool = pool.clone();
550 return Box::pin(async move {
551 ::autumn_web::backend_select! {
560 pg => {{
561 use diesel_async::RunQueryDsl;
562
563 let mut conn = pool
582 .get()
583 .await
584 .map_err(|e| AdminError::Database(e.to_string()))?;
585 diesel::sql_query(
586 "WITH deleted AS ( \
587 DELETE FROM autumn_experiments WHERE id = ANY($1) RETURNING name \
588 ), \
589 _del_assignments AS ( \
590 DELETE FROM autumn_experiment_assignments \
591 WHERE experiment IN (SELECT name FROM deleted) \
592 ), \
593 _del_overrides AS ( \
594 DELETE FROM autumn_experiment_overrides \
595 WHERE experiment IN (SELECT name FROM deleted) \
596 ), \
597 _audit AS ( \
598 INSERT INTO autumn_experiment_changes (experiment, mutation, actor) \
599 SELECT name, 'deleted', NULL FROM deleted \
600 ) \
601 SELECT COUNT(*) AS count FROM deleted",
602 )
603 .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
604 .get_result::<CountRow>(&mut conn)
605 .await
606 .map_err(|e| AdminError::Database(e.to_string()))?;
607 Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
608 }},
609 sqlite => {{
610 let _ = (&pool, &ids);
611 crate::traits::require_postgres("ExperimentAdminModel").map(|()| 0)
612 }},
613 }
614 });
615 }
616
617 let action = action.to_owned();
618 let pool = pool.clone();
619 Box::pin(async move {
620 crate::traits::require_postgres("ExperimentAdminModel")?;
621 match action.as_str() {
622 "restore" => {
623 let mut count: u64 = 0;
624 for id in ids {
625 self.restore(&pool, id).await?;
626 count += 1;
627 }
628 Ok(count)
629 }
630 "purge" => {
631 let mut count: u64 = 0;
632 for id in ids {
633 self.purge(&pool, id).await?;
634 count += 1;
635 }
636 Ok(count)
637 }
638 other => Err(AdminError::Other(format!(
639 "unhandled bulk action '{other}'; \
640 override AdminModel::execute_action to support it"
641 ))),
642 }
643 })
644 }
645
646 fn get_history<'a>(
647 &'a self,
648 pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
649 record_id: i64,
650 page: u64,
651 per_page: u64,
652 ) -> AdminFuture<'a, AdminHistoryPage> {
653 use diesel::prelude::*;
654 use diesel_async::RunQueryDsl;
655
656 let pool = pool.clone();
657 Box::pin(async move {
658 crate::traits::require_postgres("ExperimentAdminModel")?;
659 let mut conn = pool
660 .get()
661 .await
662 .map_err(|e| AdminError::Database(e.to_string()))?;
663
664 let name: Option<String> =
665 diesel::sql_query("SELECT name FROM autumn_experiments WHERE id = $1")
666 .bind::<diesel::sql_types::BigInt, _>(record_id)
667 .get_result::<NameRow>(&mut conn)
668 .await
669 .optional()
670 .unwrap_or(None)
671 .map(|r| r.name);
672
673 let Some(name) = name else {
674 return Ok(AdminHistoryPage {
675 entries: vec![],
676 total: 0,
677 page,
678 per_page,
679 });
680 };
681
682 let offset = (page.saturating_sub(1)) * per_page;
683 let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(
684 "SELECT id, mutation AS op, actor, changed_at \
685 FROM autumn_experiment_changes \
686 WHERE experiment = $1 \
687 ORDER BY changed_at DESC \
688 LIMIT $2 OFFSET $3",
689 )
690 .bind::<diesel::sql_types::Text, _>(&name)
691 .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
692 .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
693 .load::<HistoryRow>(&mut conn)
694 .await
695 .unwrap_or_default()
696 .into_iter()
697 .map(history_row_to_entry)
698 .collect();
699
700 drop(conn);
708
709 let count: i64 = PgExperimentChangeRepository::with_pool_untracked(pool.clone())
715 .count_grouped_by_experiment()
716 .filter_eq(name.clone())
717 .load()
718 .await
719 .ok()
720 .and_then(|rows| rows.into_iter().next())
721 .map_or(0, |(_, c)| c);
722
723 Ok(AdminHistoryPage {
724 entries,
725 total: u64::try_from(count).unwrap_or(0),
726 page,
727 per_page,
728 })
729 })
730 }
731}
732
733fn extract_variants_str(data: &Value) -> String {
738 match data.get("variants") {
739 Some(Value::String(s)) => s.clone(),
740 Some(v) if !v.is_null() => serde_json::to_string(v).unwrap_or_else(|_| "[]".to_owned()),
741 _ => "[]".to_owned(),
742 }
743}
744
745fn validate_variants_json(raw: &str) -> Result<String, AdminError> {
747 let trimmed = raw.trim();
748 if trimmed.is_empty() {
749 return Ok("[]".to_owned());
750 }
751 match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
752 Ok(arr) => {
753 let mut seen_names = std::collections::HashSet::new();
754 for (i, v) in arr.iter().enumerate() {
755 let name_str = match v.get("name").and_then(|n| n.as_str()) {
756 None => {
757 return Err(AdminError::Validation(format!(
758 "variants[{i}].name must be a string"
759 )));
760 }
761 Some(n) if n.trim().is_empty() => {
762 return Err(AdminError::Validation(format!(
763 "variants[{i}].name must not be empty"
764 )));
765 }
766 Some(n) => n,
767 };
768 if !seen_names.insert(name_str) {
769 return Err(AdminError::Validation(format!(
770 "duplicate variant name '{name_str}' at variants[{i}]"
771 )));
772 }
773 match v.get("weight").and_then(Value::as_u64) {
774 None => {
775 return Err(AdminError::Validation(format!(
776 "variants[{i}].weight must be a non-negative integer"
777 )));
778 }
779 Some(w) if w > u64::from(u32::MAX) => {
780 return Err(AdminError::Validation(format!(
781 "variants[{i}].weight must not exceed {} (u32::MAX)",
782 u32::MAX
783 )));
784 }
785 _ => {}
786 }
787 }
788 Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
789 }
790 Err(_) => Err(AdminError::Validation(
791 "'variants' must be a valid JSON array".into(),
792 )),
793 }
794}
795
796#[derive(diesel::QueryableByName)]
799struct CountRow {
800 #[diesel(sql_type = diesel::sql_types::BigInt)]
801 count: i64,
802}
803
804#[derive(diesel::QueryableByName)]
805struct NameRow {
806 #[diesel(sql_type = diesel::sql_types::Text)]
807 name: String,
808}
809
810#[derive(diesel::QueryableByName)]
811struct NameStateRow {
812 #[diesel(sql_type = diesel::sql_types::Text)]
813 name: String,
814 #[diesel(sql_type = diesel::sql_types::Text)]
815 state: String,
816}
817
818#[derive(diesel::QueryableByName)]
819struct VariantNameRow {
820 #[diesel(sql_type = diesel::sql_types::Text)]
821 variant: String,
822}
823
824#[derive(diesel::QueryableByName)]
826struct ExperimentRow {
827 #[diesel(sql_type = diesel::sql_types::BigInt)]
828 id: i64,
829 #[diesel(sql_type = diesel::sql_types::Text)]
830 name: String,
831 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
832 description: Option<String>,
833 #[diesel(sql_type = diesel::sql_types::Text)]
834 state: String,
835 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
836 variants: Option<String>,
837 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
838 winner: Option<String>,
839 #[diesel(sql_type = diesel::sql_types::Timestamp)]
840 updated_at: chrono::NaiveDateTime,
841}
842
843impl ExperimentRow {
844 fn into_json(self) -> Value {
845 serde_json::json!({
846 "id": self.id,
847 "name": self.name,
848 "description": self.description,
849 "state": self.state,
850 "variants": self.variants.map(decode_variants),
851 "winner": self.winner,
852 "updated_at": self.updated_at.and_utc().to_rfc3339(),
853 })
854 }
855}
856
857fn decode_variants(raw: String) -> Value {
868 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
869}
870
871#[derive(diesel::QueryableByName)]
873struct ExperimentDetailRow {
874 #[diesel(sql_type = diesel::sql_types::BigInt)]
875 id: i64,
876 #[diesel(sql_type = diesel::sql_types::Text)]
877 name: String,
878 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
879 description: Option<String>,
880 #[diesel(sql_type = diesel::sql_types::Text)]
881 state: String,
882 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
883 variants: Option<String>,
884 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
885 winner: Option<String>,
886 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
887 exclusion_group: Option<String>,
888 #[diesel(sql_type = diesel::sql_types::Timestamp)]
889 updated_at: chrono::NaiveDateTime,
890}
891
892impl ExperimentDetailRow {
893 fn into_json(self) -> Value {
894 serde_json::json!({
895 "id": self.id,
896 "name": self.name,
897 "description": self.description,
898 "state": self.state,
899 "variants": self.variants.map(decode_variants),
900 "winner": self.winner,
901 "exclusion_group": self.exclusion_group,
902 "updated_at": self.updated_at.and_utc().to_rfc3339(),
903 })
904 }
905}
906
907#[derive(diesel::QueryableByName)]
920struct HistoryRow {
921 #[diesel(sql_type = diesel::sql_types::BigInt)]
922 id: i64,
923 #[diesel(sql_type = diesel::sql_types::Text)]
924 op: String,
925 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
926 actor: Option<String>,
927 #[diesel(sql_type = diesel::sql_types::Timestamp)]
928 changed_at: chrono::NaiveDateTime,
929}
930
931fn history_row_to_entry(r: HistoryRow) -> crate::AdminHistoryEntry {
932 crate::AdminHistoryEntry {
933 id: r.id,
934 actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
935 op: r.op,
936 request_id: None,
937 changes: vec![],
938 recorded_at: r.changed_at.and_utc(),
939 }
940}
941
942#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn experiment_admin_model_slug() {
950 let model = ExperimentAdminModel;
951 assert_eq!(model.slug(), "experiments");
952 }
953
954 #[test]
961 fn history_row_to_entry_defaults_actor_to_cli() {
962 let row = HistoryRow {
963 id: 1,
964 op: "update".to_owned(),
965 actor: None,
966 changed_at: chrono::Utc::now().naive_utc(),
967 };
968 assert_eq!(history_row_to_entry(row).actor, "cli");
969 }
970
971 #[test]
972 fn experiment_admin_model_display_names() {
973 let model = ExperimentAdminModel;
974 assert_eq!(model.display_name(), "Experiment");
975 assert_eq!(model.display_name_plural(), "Experiments");
976 }
977
978 #[test]
979 fn experiment_admin_model_has_history() {
980 let model = ExperimentAdminModel;
981 assert!(model.has_history(), "experiment admin must expose history");
982 }
983
984 #[test]
985 fn experiment_admin_model_has_expected_fields() {
986 let model = ExperimentAdminModel;
987 let fields = model.fields();
988 let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
989 assert!(names.contains(&"name"), "must have 'name' field");
990 assert!(names.contains(&"state"), "must have 'state' field");
991 assert!(names.contains(&"variants"), "must have 'variants' field");
992 assert!(names.contains(&"winner"), "must have 'winner' field");
993 assert!(
994 names.contains(&"exclusion_group"),
995 "must have 'exclusion_group' field"
996 );
997 }
998
999 #[test]
1000 fn experiment_admin_model_state_field_has_all_lifecycle_states() {
1001 let model = ExperimentAdminModel;
1002 let state_field = model
1003 .fields()
1004 .into_iter()
1005 .find(|f| f.name == "state")
1006 .expect("state field must exist");
1007 let AdminFieldKind::Select(options) = state_field.kind else {
1008 panic!("state field must be Select");
1009 };
1010 let values: Vec<&str> = options.iter().map(|o| o.value.as_str()).collect();
1011 assert!(values.contains(&"draft"));
1012 assert!(values.contains(&"running"));
1013 assert!(values.contains(&"concluded"));
1014 assert!(values.contains(&"archived"));
1015 }
1016
1017 #[test]
1018 fn record_display_uses_experiment_name() {
1019 let model = ExperimentAdminModel;
1020 let record = serde_json::json!({"name": "checkout_v2", "state": "running"});
1021 assert_eq!(model.record_display(&record), "Experiment: checkout_v2");
1022 }
1023
1024 #[test]
1025 fn record_display_fallback_when_no_name() {
1026 let model = ExperimentAdminModel;
1027 let record = serde_json::json!({});
1028 assert_eq!(model.record_display(&record), "Experiment");
1029 }
1030
1031 #[test]
1032 fn validate_variants_json_accepts_valid_array() {
1033 let json = validate_variants_json(
1034 r#"[{"name":"control","weight":50},{"name":"treatment","weight":50}]"#,
1035 )
1036 .unwrap();
1037 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1038 assert_eq!(v.as_array().unwrap().len(), 2);
1039 }
1040
1041 #[test]
1042 fn validate_variants_json_accepts_empty_string() {
1043 assert_eq!(validate_variants_json("").unwrap(), "[]");
1044 }
1045
1046 #[test]
1047 fn validate_variants_json_rejects_missing_name() {
1048 let err = validate_variants_json(r#"[{"weight":50}]"#).unwrap_err();
1049 assert!(err.to_string().contains("name"));
1050 }
1051
1052 #[test]
1053 fn validate_variants_json_rejects_missing_weight() {
1054 let err = validate_variants_json(r#"[{"name":"control"}]"#).unwrap_err();
1055 assert!(err.to_string().contains("weight"));
1056 }
1057
1058 #[test]
1059 fn validate_variants_json_rejects_invalid_json() {
1060 let err = validate_variants_json("{not json}").unwrap_err();
1061 assert!(err.to_string().contains("JSON"));
1062 }
1063
1064 #[test]
1065 fn validate_variants_json_rejects_empty_name() {
1066 let err = validate_variants_json(r#"[{"name":"","weight":100}]"#).unwrap_err();
1067 assert!(
1068 err.to_string().contains("empty"),
1069 "expected empty-name error, got: {err}"
1070 );
1071 }
1072
1073 #[test]
1074 fn validate_variants_json_rejects_whitespace_only_name() {
1075 let err = validate_variants_json(r#"[{"name":" ","weight":100}]"#).unwrap_err();
1076 assert!(
1077 err.to_string().contains("empty"),
1078 "expected empty-name error, got: {err}"
1079 );
1080 }
1081
1082 #[test]
1083 fn decode_variants_parses_stored_json_text_into_a_real_value() {
1084 let decoded = decode_variants(r#"[{"name":"control","weight":50}]"#.to_owned());
1092 assert_eq!(
1093 decoded,
1094 serde_json::json!([{"name": "control", "weight": 50}])
1095 );
1096 assert!(matches!(decoded, Value::Array(_)));
1097 }
1098
1099 #[test]
1100 fn decode_variants_falls_back_to_raw_text_on_unparseable_input() {
1101 let decoded = decode_variants("not json".to_owned());
1105 assert_eq!(decoded, Value::String("not json".to_owned()));
1106 }
1107}