1use serde_json::Value;
9
10use crate::{
11 AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
12 SelectOption,
13};
14
15#[derive(Debug, Default, Clone)]
41pub struct FeatureFlagAdminModel;
42
43impl AdminModel for FeatureFlagAdminModel {
44 fn slug(&self) -> &'static str {
45 "feature-flags"
46 }
47
48 fn display_name(&self) -> &'static str {
49 "Feature Flag"
50 }
51
52 fn display_name_plural(&self) -> &'static str {
53 "Feature Flags"
54 }
55
56 fn fields(&self) -> Vec<AdminField> {
57 vec![
58 AdminField::new("key", AdminFieldKind::Text)
59 .label("Flag Key")
60 .searchable(),
61 AdminField::new("description", AdminFieldKind::TextArea)
62 .label("Description")
63 .optional()
64 .searchable(),
65 AdminField::new("enabled", AdminFieldKind::Boolean).label("Globally Enabled"),
66 AdminField::new(
67 "rollout_pct",
68 AdminFieldKind::Select(vec![
69 SelectOption {
70 value: "0".into(),
71 label: "Off (0%)".into(),
72 },
73 SelectOption {
74 value: "10".into(),
75 label: "10%".into(),
76 },
77 SelectOption {
78 value: "25".into(),
79 label: "25%".into(),
80 },
81 SelectOption {
82 value: "50".into(),
83 label: "50%".into(),
84 },
85 SelectOption {
86 value: "75".into(),
87 label: "75%".into(),
88 },
89 SelectOption {
90 value: "100".into(),
91 label: "All (100%)".into(),
92 },
93 ]),
94 )
95 .label("Rollout %")
96 .optional(),
97 AdminField::new("actor_allowlist", AdminFieldKind::TextArea)
98 .label("Actor Allowlist (JSON array)")
99 .optional()
100 .hide_from_list(),
101 AdminField::new("group_allowlist", AdminFieldKind::TextArea)
102 .label("Group Allowlist (JSON array)")
103 .optional()
104 .hide_from_list(),
105 AdminField::new("updated_at", AdminFieldKind::DateTime)
106 .label("Last Updated")
107 .readonly()
108 .optional(),
109 ]
110 }
111
112 fn record_display(&self, record: &Value) -> String {
113 record
114 .get("key")
115 .and_then(|v| v.as_str())
116 .map_or_else(|| "Feature Flag".to_owned(), |k| format!("Flag: {k}"))
117 }
118
119 fn list(
120 &self,
121 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
122 params: ListParams,
123 ) -> AdminFuture<'_, ListResult> {
124 use diesel_async::RunQueryDsl;
125
126 let pool = pool.clone();
127 Box::pin(async move {
128 crate::traits::require_postgres("FeatureFlagAdminModel")?;
129 let mut conn = pool
130 .get()
131 .await
132 .map_err(|e| AdminError::Database(e.to_string()))?;
133
134 let per_page = params.per_page;
135 let (offset, limit) = params.sql_offset_limit();
136
137 let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
139
140 let total: i64 = diesel::sql_query(
141 "SELECT COUNT(*) FROM autumn_feature_flags \
142 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
143 )
144 .bind::<diesel::sql_types::Text, _>(&search_pattern)
145 .get_result::<CountRow>(&mut conn)
146 .await
147 .map_or(0, |r| r.count);
148
149 let records: Vec<Value> = diesel::sql_query(
150 "SELECT id, key, description, enabled, rollout_pct, \
151 actor_allowlist, group_allowlist, updated_at \
152 FROM autumn_feature_flags \
153 WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
154 ORDER BY key \
155 LIMIT $2 OFFSET $3",
156 )
157 .bind::<diesel::sql_types::Text, _>(&search_pattern)
158 .bind::<diesel::sql_types::BigInt, _>(limit)
159 .bind::<diesel::sql_types::BigInt, _>(offset)
160 .load::<FlagRow>(&mut conn)
161 .await
162 .map(|rows| rows.into_iter().map(FlagRow::into_json).collect())
163 .map_err(|e| AdminError::Database(e.to_string()))?;
164
165 Ok(ListResult {
166 total: u64::try_from(total).unwrap_or(0),
167 page: params.page,
168 per_page,
169 records,
170 })
171 })
172 }
173
174 fn get(
175 &self,
176 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
177 id: i64,
178 ) -> AdminFuture<'_, Option<Value>> {
179 use diesel::prelude::*;
180 use diesel_async::RunQueryDsl;
181
182 let pool = pool.clone();
183 Box::pin(async move {
184 crate::traits::require_postgres("FeatureFlagAdminModel")?;
185 let mut conn = pool
186 .get()
187 .await
188 .map_err(|e| AdminError::Database(e.to_string()))?;
189
190 diesel::sql_query(
191 "SELECT id, key, description, enabled, rollout_pct, \
192 actor_allowlist, group_allowlist, updated_at \
193 FROM autumn_feature_flags WHERE id = $1",
194 )
195 .bind::<diesel::sql_types::BigInt, _>(id)
196 .get_result::<FlagRow>(&mut conn)
197 .await
198 .optional()
199 .map(|r| r.map(FlagRow::into_json))
200 .map_err(|e| AdminError::Database(e.to_string()))
201 })
202 }
203
204 fn create(
205 &self,
206 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
207 data: Value,
208 ) -> AdminFuture<'_, Value> {
209 use diesel_async::RunQueryDsl;
210
211 let pool = pool.clone();
212 Box::pin(async move {
213 crate::traits::require_postgres("FeatureFlagAdminModel")?;
214 let mut conn = pool
215 .get()
216 .await
217 .map_err(|e| AdminError::Database(e.to_string()))?;
218
219 let key = data
220 .get("key")
221 .and_then(Value::as_str)
222 .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
223 let enabled = data
224 .get("enabled")
225 .and_then(Value::as_bool)
226 .unwrap_or(false);
227 let mut rollout_pct = data
229 .get("rollout_pct")
230 .and_then(|v| {
231 v.as_i64()
232 .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
233 })
234 .unwrap_or(0)
235 .clamp(0, 100);
236 let description = data.get("description").and_then(Value::as_str);
237 let actor_allowlist = validate_string_array(
238 data.get("actor_allowlist")
239 .and_then(Value::as_str)
240 .unwrap_or("[]"),
241 "actor_allowlist",
242 )?;
243 let group_allowlist = validate_string_array(
244 data.get("group_allowlist")
245 .and_then(Value::as_str)
246 .unwrap_or("[]"),
247 "group_allowlist",
248 )?;
249
250 let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
255 if enabled && rollout_pct == 0 && !has_allowlist {
256 rollout_pct = 100;
257 }
258
259 let mutation = if enabled { "enabled" } else { "disabled" };
260
261 let row = diesel::sql_query(
266 "WITH inserted AS ( \
267 INSERT INTO autumn_feature_flags \
268 (key, description, enabled, rollout_pct, \
269 actor_allowlist, group_allowlist) \
270 VALUES ($1, $2, $3, $4, $5, $6) \
271 RETURNING id, key, description, enabled, rollout_pct, \
272 actor_allowlist, group_allowlist, updated_at \
273 ), \
274 _audit AS ( \
275 INSERT INTO feature_flag_changes (key, mutation, actor) \
276 SELECT key, $7, NULL FROM inserted \
277 ) \
278 SELECT id, key, description, enabled, rollout_pct, \
279 actor_allowlist, group_allowlist, updated_at \
280 FROM inserted",
281 )
282 .bind::<diesel::sql_types::Text, _>(key)
283 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
284 description.map(str::to_owned),
285 )
286 .bind::<diesel::sql_types::Bool, _>(enabled)
287 .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
288 .bind::<diesel::sql_types::Text, _>(actor_allowlist)
289 .bind::<diesel::sql_types::Text, _>(group_allowlist)
290 .bind::<diesel::sql_types::Text, _>(mutation)
291 .get_result::<FlagRow>(&mut conn)
292 .await
293 .map_err(|e| {
294 if matches!(
295 e,
296 diesel::result::Error::DatabaseError(
297 diesel::result::DatabaseErrorKind::UniqueViolation,
298 _
299 )
300 ) {
301 AdminError::Validation(format!("a flag with key '{key}' already exists"))
302 } else {
303 AdminError::Database(e.to_string())
304 }
305 })?;
306
307 Ok(FlagRow::into_json(row))
308 })
309 }
310
311 fn update(
312 &self,
313 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
314 id: i64,
315 data: Value,
316 ) -> AdminFuture<'_, Value> {
317 use diesel_async::RunQueryDsl;
318
319 let pool = pool.clone();
320 Box::pin(async move {
321 crate::traits::require_postgres("FeatureFlagAdminModel")?;
322 let mut conn = pool
323 .get()
324 .await
325 .map_err(|e| AdminError::Database(e.to_string()))?;
326
327 let key = data
328 .get("key")
329 .and_then(Value::as_str)
330 .ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
331 let enabled = data
332 .get("enabled")
333 .and_then(Value::as_bool)
334 .unwrap_or(false);
335 let mut rollout_pct = data
336 .get("rollout_pct")
337 .and_then(|v| {
338 v.as_i64()
339 .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
340 })
341 .unwrap_or(0)
342 .clamp(0, 100);
343 let description = data.get("description").and_then(Value::as_str);
344 let actor_allowlist = validate_string_array(
345 data.get("actor_allowlist")
346 .and_then(Value::as_str)
347 .unwrap_or("[]"),
348 "actor_allowlist",
349 )?;
350 let group_allowlist = validate_string_array(
351 data.get("group_allowlist")
352 .and_then(Value::as_str)
353 .unwrap_or("[]"),
354 "group_allowlist",
355 )?;
356 let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
357 if enabled && rollout_pct == 0 && !has_allowlist {
358 rollout_pct = 100;
359 }
360
361 let mutation = if enabled { "enabled" } else { "disabled" };
362
363 let row = diesel::sql_query(
368 "WITH old_row AS ( \
369 SELECT key FROM autumn_feature_flags WHERE id = $1 \
370 ), \
371 updated AS ( \
372 UPDATE autumn_feature_flags \
373 SET key = $2, description = $3, enabled = $4, rollout_pct = $5, \
374 actor_allowlist = $6, group_allowlist = $7, updated_at = NOW() \
375 WHERE id = $1 \
376 RETURNING id, key, description, enabled, rollout_pct, \
377 actor_allowlist, group_allowlist, updated_at \
378 ), \
379 _audit_rename AS ( \
380 INSERT INTO feature_flag_changes (key, mutation, actor) \
381 SELECT old_row.key, 'deleted', NULL \
382 FROM old_row \
383 WHERE old_row.key != $2 \
384 ), \
385 _audit_rename_breadcrumb AS ( \
386 INSERT INTO feature_flag_changes (key, mutation, actor) \
387 SELECT $2, 'renamed_from=' || old_row.key, NULL \
388 FROM old_row \
389 WHERE old_row.key != $2 \
390 ), \
391 _audit AS ( \
392 INSERT INTO feature_flag_changes (key, mutation, actor) \
393 SELECT key, $8, NULL FROM updated \
394 ) \
395 SELECT id, key, description, enabled, rollout_pct, \
396 actor_allowlist, group_allowlist, updated_at \
397 FROM updated",
398 )
399 .bind::<diesel::sql_types::BigInt, _>(id)
400 .bind::<diesel::sql_types::Text, _>(key)
401 .bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
402 description.map(str::to_owned),
403 )
404 .bind::<diesel::sql_types::Bool, _>(enabled)
405 .bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
406 .bind::<diesel::sql_types::Text, _>(actor_allowlist)
407 .bind::<diesel::sql_types::Text, _>(group_allowlist)
408 .bind::<diesel::sql_types::Text, _>(mutation)
409 .get_result::<FlagRow>(&mut conn)
410 .await
411 .map_err(|e| AdminError::Database(e.to_string()))?;
412
413 Ok(FlagRow::into_json(row))
414 })
415 }
416
417 fn delete(
418 &self,
419 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
420 id: i64,
421 ) -> AdminFuture<'_, ()> {
422 use diesel_async::RunQueryDsl;
423
424 let pool = pool.clone();
425 Box::pin(async move {
426 crate::traits::require_postgres("FeatureFlagAdminModel")?;
427 let mut conn = pool
428 .get()
429 .await
430 .map_err(|e| AdminError::Database(e.to_string()))?;
431
432 diesel::sql_query(
436 "WITH deleted AS ( \
437 DELETE FROM autumn_feature_flags WHERE id = $1 RETURNING key \
438 ), \
439 _audit AS ( \
440 INSERT INTO feature_flag_changes (key, mutation, actor) \
441 SELECT key, 'deleted', NULL FROM deleted \
442 ) \
443 SELECT COUNT(*) AS count FROM deleted",
444 )
445 .bind::<diesel::sql_types::BigInt, _>(id)
446 .get_result::<CountRow>(&mut conn)
447 .await
448 .map_err(|e| AdminError::Database(e.to_string()))?;
449
450 Ok(())
451 })
452 }
453
454 fn execute_action(
455 &self,
456 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
457 action: &str,
458 ids: Vec<i64>,
459 ) -> AdminFuture<'_, u64> {
460 if action == "delete" {
471 let pool = pool.clone();
472 return Box::pin(async move {
473 ::autumn_web::backend_select! {
482 pg => {{
483 use diesel_async::RunQueryDsl;
484
485 let mut conn = pool
502 .get()
503 .await
504 .map_err(|e| AdminError::Database(e.to_string()))?;
505 diesel::sql_query(
506 "WITH deleted AS ( \
507 DELETE FROM autumn_feature_flags WHERE id = ANY($1) RETURNING key \
508 ), \
509 _audit AS ( \
510 INSERT INTO feature_flag_changes (key, mutation, actor) \
511 SELECT key, 'deleted', NULL FROM deleted \
512 ) \
513 SELECT COUNT(*) AS count FROM deleted",
514 )
515 .bind::<diesel::sql_types::Array<diesel::sql_types::BigInt>, _>(&ids)
516 .get_result::<CountRow>(&mut conn)
517 .await
518 .map_err(|e| AdminError::Database(e.to_string()))?;
519 Ok(u64::try_from(ids.len()).unwrap_or(u64::MAX))
520 }},
521 sqlite => {{
522 let _ = (&pool, &ids);
523 crate::traits::require_postgres("FeatureFlagAdminModel").map(|()| 0)
524 }},
525 }
526 });
527 }
528
529 crate::traits::dispatch_restore_purge_or_unhandled(self, pool, action, ids)
530 }
531
532 fn has_history(&self) -> bool {
533 true
534 }
535
536 fn get_history<'a>(
537 &'a self,
538 pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
539 record_id: i64,
540 page: u64,
541 per_page: u64,
542 ) -> crate::AdminFuture<'a, crate::AdminHistoryPage> {
543 use diesel::prelude::*;
544 use diesel_async::RunQueryDsl;
545
546 let pool = pool.clone();
547 Box::pin(async move {
548 crate::traits::require_postgres("FeatureFlagAdminModel")?;
549 let mut conn = pool
550 .get()
551 .await
552 .map_err(|e| AdminError::Database(e.to_string()))?;
553
554 let key: Option<String> =
556 diesel::sql_query("SELECT key FROM autumn_feature_flags WHERE id = $1")
557 .bind::<diesel::sql_types::BigInt, _>(record_id)
558 .get_result::<KeyRow>(&mut conn)
559 .await
560 .optional()
561 .unwrap_or(None)
562 .map(|r| r.key);
563
564 let Some(key) = key else {
565 return Ok(crate::AdminHistoryPage {
566 entries: vec![],
567 total: 0,
568 page,
569 per_page,
570 });
571 };
572
573 let ancestor_cte = "WITH RECURSIVE ancestors AS ( \
578 SELECT $1::text AS key \
579 UNION \
580 SELECT regexp_replace(ffc.mutation, '^renamed_from=', '') \
581 FROM feature_flag_changes ffc \
582 JOIN ancestors a ON ffc.key = a.key \
583 WHERE ffc.mutation LIKE 'renamed_from=%' \
584 )";
585
586 let count: i64 = diesel::sql_query(format!(
587 "{ancestor_cte} \
588 SELECT COUNT(*) FROM feature_flag_changes \
589 WHERE key IN (SELECT key FROM ancestors)",
590 ))
591 .bind::<diesel::sql_types::Text, _>(&key)
592 .get_result::<CountRow>(&mut conn)
593 .await
594 .map_or(0, |r| r.count);
595
596 let offset = (page.saturating_sub(1)) * per_page;
597 let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(format!(
598 "{ancestor_cte} \
599 SELECT id, mutation AS op, actor, changed_at \
600 FROM feature_flag_changes \
601 WHERE key IN (SELECT key FROM ancestors) \
602 ORDER BY changed_at DESC \
603 LIMIT $2 OFFSET $3",
604 ))
605 .bind::<diesel::sql_types::Text, _>(&key)
606 .bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
607 .bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
608 .load::<HistoryRow>(&mut conn)
609 .await
610 .unwrap_or_default()
611 .into_iter()
612 .map(|r| crate::AdminHistoryEntry {
613 id: r.id,
614 actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
615 op: r.op,
616 request_id: None,
617 changes: vec![],
618 recorded_at: r.changed_at.and_utc(),
619 })
620 .collect();
621
622 Ok(crate::AdminHistoryPage {
623 entries,
624 total: u64::try_from(count).unwrap_or(0),
625 page,
626 per_page,
627 })
628 })
629 }
630}
631
632fn validate_string_array(raw: &str, field: &str) -> Result<String, AdminError> {
641 let trimmed = raw.trim();
642 if trimmed.is_empty() {
643 return Ok("[]".to_owned());
644 }
645 match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
646 Ok(arr) if arr.iter().all(serde_json::Value::is_string) => {
647 Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
648 }
649 Ok(_) => Err(AdminError::Validation(format!(
650 "'{field}' must be a JSON array of strings (e.g. [\"user:42\"])"
651 ))),
652 Err(_) => Err(AdminError::Validation(format!(
653 "'{field}' must be valid JSON (e.g. [\"user:42\"]); check for trailing commas"
654 ))),
655 }
656}
657
658#[derive(diesel::QueryableByName)]
661struct CountRow {
662 #[diesel(sql_type = diesel::sql_types::BigInt)]
663 count: i64,
664}
665
666#[derive(diesel::QueryableByName)]
667struct KeyRow {
668 #[diesel(sql_type = diesel::sql_types::Text)]
669 key: String,
670}
671
672#[derive(diesel::QueryableByName)]
673struct FlagRow {
674 #[diesel(sql_type = diesel::sql_types::BigInt)]
675 id: i64,
676 #[diesel(sql_type = diesel::sql_types::Text)]
677 key: String,
678 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
679 description: Option<String>,
680 #[diesel(sql_type = diesel::sql_types::Bool)]
681 enabled: bool,
682 #[diesel(sql_type = diesel::sql_types::SmallInt)]
683 rollout_pct: i16,
684 #[diesel(sql_type = diesel::sql_types::Text)]
685 actor_allowlist: String,
686 #[diesel(sql_type = diesel::sql_types::Text)]
687 group_allowlist: String,
688 #[diesel(sql_type = diesel::sql_types::Timestamp)]
689 updated_at: chrono::NaiveDateTime,
690}
691
692impl FlagRow {
693 fn into_json(self) -> Value {
694 serde_json::json!({
695 "id": self.id,
696 "key": self.key,
697 "description": self.description,
698 "enabled": self.enabled,
699 "rollout_pct": self.rollout_pct,
700 "actor_allowlist": self.actor_allowlist,
701 "group_allowlist": self.group_allowlist,
702 "updated_at": self.updated_at.and_utc().to_rfc3339(),
703 })
704 }
705}
706
707#[derive(diesel::QueryableByName)]
708struct HistoryRow {
709 #[diesel(sql_type = diesel::sql_types::BigInt)]
710 id: i64,
711 #[diesel(sql_type = diesel::sql_types::Text)]
712 op: String,
713 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
714 actor: Option<String>,
715 #[diesel(sql_type = diesel::sql_types::Timestamp)]
716 changed_at: chrono::NaiveDateTime,
717}
718
719#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn feature_flag_admin_model_slug_is_feature_flags() {
727 let model = FeatureFlagAdminModel;
728 assert_eq!(model.slug(), "feature-flags");
729 }
730
731 #[test]
732 fn feature_flag_admin_model_has_correct_display_names() {
733 let model = FeatureFlagAdminModel;
734 assert_eq!(model.display_name(), "Feature Flag");
735 assert_eq!(model.display_name_plural(), "Feature Flags");
736 }
737
738 #[test]
739 fn feature_flag_admin_fields_include_required_columns() {
740 let model = FeatureFlagAdminModel;
741 let fields = model.fields();
742 let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
743 assert!(names.contains(&"key"), "must have key field");
744 assert!(names.contains(&"enabled"), "must have enabled field");
745 assert!(
746 names.contains(&"rollout_pct"),
747 "must have rollout_pct field"
748 );
749 assert!(
750 names.contains(&"actor_allowlist"),
751 "must have actor_allowlist field"
752 );
753 }
754
755 #[test]
756 fn feature_flag_admin_model_has_history() {
757 let model = FeatureFlagAdminModel;
758 assert!(
759 model.has_history(),
760 "feature flag admin must expose history"
761 );
762 }
763
764 #[test]
765 fn record_display_uses_flag_key() {
766 let model = FeatureFlagAdminModel;
767 let record = serde_json::json!({"key": "beta_inbox", "enabled": false});
768 assert_eq!(model.record_display(&record), "Flag: beta_inbox");
769 }
770
771 #[test]
772 fn record_display_fallback_when_no_key() {
773 let model = FeatureFlagAdminModel;
774 let record = serde_json::json!({});
775 assert_eq!(model.record_display(&record), "Feature Flag");
776 }
777
778 #[test]
779 fn globally_enabled_with_zero_rollout_promotes_to_100() {
780 let enabled = true;
787 let submitted_rollout: i64 = 0;
788 let mut rollout_pct = submitted_rollout.clamp(0, 100);
789 if enabled && rollout_pct == 0 {
790 rollout_pct = 100;
791 }
792 assert_eq!(
793 rollout_pct, 100,
794 "enabled=true + rollout=0 must be promoted to rollout=100"
795 );
796 }
797
798 #[test]
799 fn globally_enabled_with_explicit_rollout_is_preserved() {
800 let enabled = true;
803 let submitted_rollout: i64 = 25;
804 let mut rollout_pct = submitted_rollout.clamp(0, 100);
805 if enabled && rollout_pct == 0 {
806 rollout_pct = 100;
807 }
808 assert_eq!(
809 rollout_pct, 25,
810 "enabled=true + explicit rollout=25 must be preserved"
811 );
812 }
813
814 #[test]
815 fn disabled_with_zero_rollout_is_not_promoted() {
816 let enabled = false;
818 let submitted_rollout: i64 = 0;
819 let mut rollout_pct = submitted_rollout.clamp(0, 100);
820 let has_allowlist = false;
821 if enabled && rollout_pct == 0 && !has_allowlist {
822 rollout_pct = 100;
823 }
824 assert_eq!(rollout_pct, 0, "kill-switch must not promote rollout_pct");
825 }
826
827 #[test]
828 fn enabled_with_zero_rollout_and_non_empty_allowlist_is_not_promoted() {
829 let enabled = true;
833 let submitted_rollout: i64 = 0;
834 let actor_allowlist = r#"["user:42"]"#;
835 let group_allowlist = "[]";
836 let mut rollout_pct = submitted_rollout.clamp(0, 100);
837 let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
838 if enabled && rollout_pct == 0 && !has_allowlist {
839 rollout_pct = 100;
840 }
841 assert_eq!(
842 rollout_pct, 0,
843 "allowlist-only flag must not have rollout_pct promoted to 100"
844 );
845 }
846
847 #[test]
848 fn validate_string_array_accepts_valid_array() {
849 let result = validate_string_array(r#"["user:1","user:2"]"#, "actor_allowlist");
850 assert!(result.is_ok(), "valid array must be accepted: {result:?}");
851 }
852
853 #[test]
854 fn validate_string_array_accepts_empty_string_as_empty_array() {
855 let result = validate_string_array("", "actor_allowlist");
856 assert_eq!(result.unwrap(), "[]");
857 }
858
859 #[test]
860 fn validate_string_array_rejects_trailing_comma() {
861 let result = validate_string_array(r#"["user:42",]"#, "actor_allowlist");
862 assert!(result.is_err(), "trailing comma must be rejected");
863 }
864
865 #[test]
866 fn validate_string_array_rejects_non_array() {
867 let result = validate_string_array("user:42", "actor_allowlist");
868 assert!(result.is_err(), "bare string must be rejected");
869 }
870
871 #[test]
872 fn validate_string_array_rejects_array_with_non_string_elements() {
873 let result = validate_string_array("[1, 2, 3]", "actor_allowlist");
874 assert!(result.is_err(), "integer elements must be rejected");
875 }
876
877 #[test]
878 fn validate_string_array_normalises_output() {
879 let result = validate_string_array(r#"[ "user:1" , "user:2" ]"#, "actor_allowlist");
881 assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
882 }
883
884 fn dummy_pool()
892 -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
893 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
894 use diesel_async::pooled_connection::deadpool::Pool;
895 let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
896 "postgresql://test",
897 );
898 Pool::builder(mgr).build().expect("build pool")
899 }
900
901 #[tokio::test]
902 async fn execute_action_restore_errors_soft_delete_unsupported() {
903 let model = FeatureFlagAdminModel;
904 let pool = dummy_pool();
905 let err = model
906 .execute_action(&pool, "restore", vec![1, 2])
907 .await
908 .expect_err("restore must fail: model does not support soft delete");
909 assert!(
910 matches!(err, AdminError::Other(_)),
911 "must be AdminError::Other: {err:?}"
912 );
913 assert!(format!("{err:?}").contains("does not support soft delete"));
914 }
915
916 #[tokio::test]
917 async fn execute_action_purge_errors_soft_delete_unsupported() {
918 let model = FeatureFlagAdminModel;
919 let pool = dummy_pool();
920 let err = model
921 .execute_action(&pool, "purge", vec![1])
922 .await
923 .expect_err("purge must fail: model does not support soft delete");
924 assert!(
925 matches!(err, AdminError::Other(_)),
926 "must be AdminError::Other: {err:?}"
927 );
928 assert!(format!("{err:?}").contains("does not support soft delete"));
929 }
930
931 #[tokio::test]
932 async fn execute_action_unhandled_action_errors_with_action_name() {
933 let model = FeatureFlagAdminModel;
934 let pool = dummy_pool();
935 let err = model
936 .execute_action(&pool, "archive", vec![1])
937 .await
938 .expect_err("unknown action must error");
939 assert!(
940 matches!(err, AdminError::Other(_)),
941 "must be AdminError::Other: {err:?}"
942 );
943 assert!(format!("{err:?}").contains("unhandled bulk action 'archive'"));
944 }
945}