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(history_row_to_entry)
613 .collect();
614
615 Ok(crate::AdminHistoryPage {
616 entries,
617 total: u64::try_from(count).unwrap_or(0),
618 page,
619 per_page,
620 })
621 })
622 }
623}
624
625fn validate_string_array(raw: &str, field: &str) -> Result<String, AdminError> {
634 let trimmed = raw.trim();
635 if trimmed.is_empty() {
636 return Ok("[]".to_owned());
637 }
638 match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
639 Ok(arr) if arr.iter().all(serde_json::Value::is_string) => {
640 Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
641 }
642 Ok(_) => Err(AdminError::Validation(format!(
643 "'{field}' must be a JSON array of strings (e.g. [\"user:42\"])"
644 ))),
645 Err(_) => Err(AdminError::Validation(format!(
646 "'{field}' must be valid JSON (e.g. [\"user:42\"]); check for trailing commas"
647 ))),
648 }
649}
650
651#[derive(diesel::QueryableByName)]
654struct CountRow {
655 #[diesel(sql_type = diesel::sql_types::BigInt)]
656 count: i64,
657}
658
659#[derive(diesel::QueryableByName)]
660struct KeyRow {
661 #[diesel(sql_type = diesel::sql_types::Text)]
662 key: String,
663}
664
665#[derive(diesel::QueryableByName)]
666struct FlagRow {
667 #[diesel(sql_type = diesel::sql_types::BigInt)]
668 id: i64,
669 #[diesel(sql_type = diesel::sql_types::Text)]
670 key: String,
671 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
672 description: Option<String>,
673 #[diesel(sql_type = diesel::sql_types::Bool)]
674 enabled: bool,
675 #[diesel(sql_type = diesel::sql_types::SmallInt)]
676 rollout_pct: i16,
677 #[diesel(sql_type = diesel::sql_types::Text)]
678 actor_allowlist: String,
679 #[diesel(sql_type = diesel::sql_types::Text)]
680 group_allowlist: String,
681 #[diesel(sql_type = diesel::sql_types::Timestamp)]
682 updated_at: chrono::NaiveDateTime,
683}
684
685impl FlagRow {
686 fn into_json(self) -> Value {
687 serde_json::json!({
688 "id": self.id,
689 "key": self.key,
690 "description": self.description,
691 "enabled": self.enabled,
692 "rollout_pct": self.rollout_pct,
693 "actor_allowlist": self.actor_allowlist,
694 "group_allowlist": self.group_allowlist,
695 "updated_at": self.updated_at.and_utc().to_rfc3339(),
696 })
697 }
698}
699
700#[derive(diesel::QueryableByName)]
714struct HistoryRow {
715 #[diesel(sql_type = diesel::sql_types::BigInt)]
716 id: i64,
717 #[diesel(sql_type = diesel::sql_types::Text)]
718 op: String,
719 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
720 actor: Option<String>,
721 #[diesel(sql_type = diesel::sql_types::Timestamp)]
722 changed_at: chrono::NaiveDateTime,
723}
724
725fn history_row_to_entry(r: HistoryRow) -> crate::AdminHistoryEntry {
726 crate::AdminHistoryEntry {
727 id: r.id,
728 actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
729 op: r.op,
730 request_id: None,
731 changes: vec![],
732 recorded_at: r.changed_at.and_utc(),
733 }
734}
735
736#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn feature_flag_admin_model_slug_is_feature_flags() {
744 let model = FeatureFlagAdminModel;
745 assert_eq!(model.slug(), "feature-flags");
746 }
747
748 #[test]
755 fn history_row_to_entry_defaults_actor_to_cli() {
756 let row = HistoryRow {
757 id: 1,
758 op: "update".to_owned(),
759 actor: None,
760 changed_at: chrono::Utc::now().naive_utc(),
761 };
762 assert_eq!(history_row_to_entry(row).actor, "cli");
763 }
764
765 #[test]
766 fn feature_flag_admin_model_has_correct_display_names() {
767 let model = FeatureFlagAdminModel;
768 assert_eq!(model.display_name(), "Feature Flag");
769 assert_eq!(model.display_name_plural(), "Feature Flags");
770 }
771
772 #[test]
773 fn feature_flag_admin_fields_include_required_columns() {
774 let model = FeatureFlagAdminModel;
775 let fields = model.fields();
776 let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
777 assert!(names.contains(&"key"), "must have key field");
778 assert!(names.contains(&"enabled"), "must have enabled field");
779 assert!(
780 names.contains(&"rollout_pct"),
781 "must have rollout_pct field"
782 );
783 assert!(
784 names.contains(&"actor_allowlist"),
785 "must have actor_allowlist field"
786 );
787 }
788
789 #[test]
790 fn feature_flag_admin_model_has_history() {
791 let model = FeatureFlagAdminModel;
792 assert!(
793 model.has_history(),
794 "feature flag admin must expose history"
795 );
796 }
797
798 #[test]
799 fn record_display_uses_flag_key() {
800 let model = FeatureFlagAdminModel;
801 let record = serde_json::json!({"key": "beta_inbox", "enabled": false});
802 assert_eq!(model.record_display(&record), "Flag: beta_inbox");
803 }
804
805 #[test]
806 fn record_display_fallback_when_no_key() {
807 let model = FeatureFlagAdminModel;
808 let record = serde_json::json!({});
809 assert_eq!(model.record_display(&record), "Feature Flag");
810 }
811
812 #[test]
813 fn globally_enabled_with_zero_rollout_promotes_to_100() {
814 let enabled = true;
821 let submitted_rollout: i64 = 0;
822 let mut rollout_pct = submitted_rollout.clamp(0, 100);
823 if enabled && rollout_pct == 0 {
824 rollout_pct = 100;
825 }
826 assert_eq!(
827 rollout_pct, 100,
828 "enabled=true + rollout=0 must be promoted to rollout=100"
829 );
830 }
831
832 #[test]
833 fn globally_enabled_with_explicit_rollout_is_preserved() {
834 let enabled = true;
837 let submitted_rollout: i64 = 25;
838 let mut rollout_pct = submitted_rollout.clamp(0, 100);
839 if enabled && rollout_pct == 0 {
840 rollout_pct = 100;
841 }
842 assert_eq!(
843 rollout_pct, 25,
844 "enabled=true + explicit rollout=25 must be preserved"
845 );
846 }
847
848 #[test]
849 fn disabled_with_zero_rollout_is_not_promoted() {
850 let enabled = false;
852 let submitted_rollout: i64 = 0;
853 let mut rollout_pct = submitted_rollout.clamp(0, 100);
854 let has_allowlist = false;
855 if enabled && rollout_pct == 0 && !has_allowlist {
856 rollout_pct = 100;
857 }
858 assert_eq!(rollout_pct, 0, "kill-switch must not promote rollout_pct");
859 }
860
861 #[test]
862 fn enabled_with_zero_rollout_and_non_empty_allowlist_is_not_promoted() {
863 let enabled = true;
867 let submitted_rollout: i64 = 0;
868 let actor_allowlist = r#"["user:42"]"#;
869 let group_allowlist = "[]";
870 let mut rollout_pct = submitted_rollout.clamp(0, 100);
871 let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
872 if enabled && rollout_pct == 0 && !has_allowlist {
873 rollout_pct = 100;
874 }
875 assert_eq!(
876 rollout_pct, 0,
877 "allowlist-only flag must not have rollout_pct promoted to 100"
878 );
879 }
880
881 #[test]
882 fn validate_string_array_accepts_valid_array() {
883 let result = validate_string_array(r#"["user:1","user:2"]"#, "actor_allowlist");
884 assert!(result.is_ok(), "valid array must be accepted: {result:?}");
885 }
886
887 #[test]
888 fn validate_string_array_accepts_empty_string_as_empty_array() {
889 let result = validate_string_array("", "actor_allowlist");
890 assert_eq!(result.unwrap(), "[]");
891 }
892
893 #[test]
894 fn validate_string_array_rejects_trailing_comma() {
895 let result = validate_string_array(r#"["user:42",]"#, "actor_allowlist");
896 assert!(result.is_err(), "trailing comma must be rejected");
897 }
898
899 #[test]
900 fn validate_string_array_rejects_non_array() {
901 let result = validate_string_array("user:42", "actor_allowlist");
902 assert!(result.is_err(), "bare string must be rejected");
903 }
904
905 #[test]
906 fn validate_string_array_rejects_array_with_non_string_elements() {
907 let result = validate_string_array("[1, 2, 3]", "actor_allowlist");
908 assert!(result.is_err(), "integer elements must be rejected");
909 }
910
911 #[test]
912 fn validate_string_array_normalises_output() {
913 let result = validate_string_array(r#"[ "user:1" , "user:2" ]"#, "actor_allowlist");
915 assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
916 }
917
918 fn dummy_pool()
926 -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
927 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
928 use diesel_async::pooled_connection::deadpool::Pool;
929 let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
930 "postgresql://test",
931 );
932 Pool::builder(mgr).build().expect("build pool")
933 }
934
935 #[tokio::test]
936 async fn execute_action_restore_errors_soft_delete_unsupported() {
937 let model = FeatureFlagAdminModel;
938 let pool = dummy_pool();
939 let err = model
940 .execute_action(&pool, "restore", vec![1, 2])
941 .await
942 .expect_err("restore must fail: model does not support soft delete");
943 assert!(
944 matches!(err, AdminError::Other(_)),
945 "must be AdminError::Other: {err:?}"
946 );
947 assert!(format!("{err:?}").contains("does not support soft delete"));
948 }
949
950 #[tokio::test]
951 async fn execute_action_purge_errors_soft_delete_unsupported() {
952 let model = FeatureFlagAdminModel;
953 let pool = dummy_pool();
954 let err = model
955 .execute_action(&pool, "purge", vec![1])
956 .await
957 .expect_err("purge must fail: model does not support soft delete");
958 assert!(
959 matches!(err, AdminError::Other(_)),
960 "must be AdminError::Other: {err:?}"
961 );
962 assert!(format!("{err:?}").contains("does not support soft delete"));
963 }
964
965 #[tokio::test]
966 async fn execute_action_unhandled_action_errors_with_action_name() {
967 let model = FeatureFlagAdminModel;
968 let pool = dummy_pool();
969 let err = model
970 .execute_action(&pool, "archive", vec![1])
971 .await
972 .expect_err("unknown action must error");
973 assert!(
974 matches!(err, AdminError::Other(_)),
975 "must be AdminError::Other: {err:?}"
976 );
977 assert!(format!("{err:?}").contains("unhandled bulk action 'archive'"));
978 }
979}