Skip to main content

fakecloud_application_autoscaling/
service.rs

1//! Application Auto Scaling JSON 1.1 service.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Duration, Utc};
7use http::StatusCode;
8use parking_lot::RwLock;
9use serde_json::{json, Value};
10use uuid::Uuid;
11
12use fakecloud_aws::arn::{partition_for, Arn};
13use fakecloud_core::pagination::paginate;
14use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
15
16use crate::state::{
17    AccountState, ApplicationAutoScalingAccounts, NotScaledReason, ScalableTarget,
18    ScalableTargetAction, ScalingActivity, ScalingPolicy, ScheduledAction,
19    SharedApplicationAutoScalingState, SuspendedState,
20};
21
22const SUPPORTED_ACTIONS: &[&str] = &[
23    "RegisterScalableTarget",
24    "DescribeScalableTargets",
25    "DeregisterScalableTarget",
26    "PutScalingPolicy",
27    "DescribeScalingPolicies",
28    "DeleteScalingPolicy",
29    "PutScheduledAction",
30    "DescribeScheduledActions",
31    "DeleteScheduledAction",
32    "DescribeScalingActivities",
33    "GetPredictiveScalingForecast",
34    "ListTagsForResource",
35    "TagResource",
36    "UntagResource",
37];
38
39pub struct ApplicationAutoScalingService {
40    state: SharedApplicationAutoScalingState,
41}
42
43impl ApplicationAutoScalingService {
44    pub fn new(state: SharedApplicationAutoScalingState) -> Self {
45        Self { state }
46    }
47
48    pub fn shared_state(&self) -> SharedApplicationAutoScalingState {
49        Arc::clone(&self.state)
50    }
51}
52
53impl Default for ApplicationAutoScalingService {
54    fn default() -> Self {
55        Self::new(Arc::new(RwLock::new(ApplicationAutoScalingAccounts::new())))
56    }
57}
58
59#[async_trait]
60impl AwsService for ApplicationAutoScalingService {
61    fn service_name(&self) -> &str {
62        "application-autoscaling"
63    }
64
65    fn supported_actions(&self) -> &[&str] {
66        SUPPORTED_ACTIONS
67    }
68
69    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
70        match req.action.as_str() {
71            "RegisterScalableTarget" => self.register_scalable_target(&req),
72            "DescribeScalableTargets" => self.describe_scalable_targets(&req),
73            "DeregisterScalableTarget" => self.deregister_scalable_target(&req),
74            "PutScalingPolicy" => self.put_scaling_policy(&req),
75            "DescribeScalingPolicies" => self.describe_scaling_policies(&req),
76            "DeleteScalingPolicy" => self.delete_scaling_policy(&req),
77            "PutScheduledAction" => self.put_scheduled_action(&req),
78            "DescribeScheduledActions" => self.describe_scheduled_actions(&req),
79            "DeleteScheduledAction" => self.delete_scheduled_action(&req),
80            "DescribeScalingActivities" => self.describe_scaling_activities(&req),
81            "GetPredictiveScalingForecast" => self.get_predictive_scaling_forecast(&req),
82            "ListTagsForResource" => self.list_tags_for_resource(&req),
83            "TagResource" => self.tag_resource(&req),
84            "UntagResource" => self.untag_resource(&req),
85            other => Err(AwsServiceError::action_not_implemented(
86                "application-autoscaling",
87                other,
88            )),
89        }
90    }
91}
92
93impl ApplicationAutoScalingService {
94    fn register_scalable_target(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
95        let body = req.json_body();
96        let service_namespace = require_str(&body, "ServiceNamespace")?;
97        validate_service_namespace(&service_namespace)?;
98        let resource_id = require_str(&body, "ResourceId")?;
99        validate_resource_id_len("resourceId", &resource_id)?;
100        let scalable_dimension = require_str(&body, "ScalableDimension")?;
101        validate_scalable_dimension(&scalable_dimension)?;
102        let min_capacity = body
103            .get("MinCapacity")
104            .and_then(Value::as_i64)
105            .map(|n| n as i32);
106        let max_capacity = body
107            .get("MaxCapacity")
108            .and_then(Value::as_i64)
109            .map(|n| n as i32);
110        let role_arn = body
111            .get("RoleARN")
112            .and_then(Value::as_str)
113            .map(|s| s.to_string());
114        if let Some(role) = role_arn.as_deref() {
115            validate_resource_id_len("roleARN", role)?;
116        }
117        let suspended_state = body.get("SuspendedState").map(parse_suspended_state);
118
119        let key = (
120            service_namespace.clone(),
121            resource_id.clone(),
122            scalable_dimension.clone(),
123        );
124
125        let mut state = self.state.write();
126        let account = account_mut(&mut state, &req.account_id);
127        let now = Utc::now();
128        let (arn, prev_min, prev_max, was_existing) = if let Some(existing) =
129            account.scalable_targets.get_mut(&key)
130        {
131            let prev_min = existing.min_capacity;
132            let prev_max = existing.max_capacity;
133            let new_min = min_capacity.unwrap_or(existing.min_capacity);
134            let new_max = max_capacity.unwrap_or(existing.max_capacity);
135            if new_min > new_max {
136                return Err(invalid_param("MinCapacity must be <= MaxCapacity"));
137            }
138            existing.min_capacity = new_min;
139            existing.max_capacity = new_max;
140            if let Some(role) = role_arn {
141                existing.role_arn = role;
142            }
143            if let Some(sus) = suspended_state {
144                existing.suspended_state = Some(sus);
145            }
146            (existing.arn.clone(), Some(prev_min), Some(prev_max), true)
147        } else {
148            let min = min_capacity
149                .ok_or_else(|| invalid_param("MinCapacity is required for new scalable targets"))?;
150            let max = max_capacity
151                .ok_or_else(|| invalid_param("MaxCapacity is required for new scalable targets"))?;
152            if min > max {
153                return Err(invalid_param("MinCapacity must be <= MaxCapacity"));
154            }
155            let arn = synth_scalable_target_arn(&req.account_id, &req.region);
156            let role = role_arn.unwrap_or_else(|| {
157                default_service_linked_role(&req.account_id, &service_namespace)
158            });
159            let target = ScalableTarget {
160                arn: arn.clone(),
161                service_namespace: service_namespace.clone(),
162                resource_id: resource_id.clone(),
163                scalable_dimension: scalable_dimension.clone(),
164                min_capacity: min,
165                max_capacity: max,
166                role_arn: role,
167                creation_time: now,
168                suspended_state,
169                predicted_capacity: None,
170            };
171            account.scalable_targets.insert(key, target);
172            (arn, None, None, false)
173        };
174
175        // Real Application Auto Scaling appends a ScalingActivity row
176        // whenever bounds change so DescribeScalingActivities surfaces a
177        // history. Synthesize matching rows here for both the initial
178        // register and any subsequent bound update.
179        let cur_min = min_capacity.or(prev_min).unwrap_or(0);
180        let cur_max = max_capacity.or(prev_max).unwrap_or(0);
181        let bounds_changed =
182            !was_existing || prev_min != Some(cur_min) || prev_max != Some(cur_max);
183        if bounds_changed {
184            let description = if was_existing {
185                format!(
186                    "Updated min capacity to {cur_min} and max capacity to {cur_max} for {resource_id}"
187                )
188            } else {
189                format!(
190                    "Setting min capacity to {cur_min} and max capacity to {cur_max} for {resource_id}"
191                )
192            };
193            let activity = ScalingActivity {
194                activity_id: synth_activity_id(),
195                service_namespace: service_namespace.clone(),
196                resource_id: resource_id.clone(),
197                scalable_dimension: scalable_dimension.clone(),
198                description,
199                cause: "User initiated capacity change via RegisterScalableTarget".to_string(),
200                start_time: now,
201                end_time: Some(now),
202                status_code: "Successful".to_string(),
203                status_message: Some("Successfully set scaling target.".to_string()),
204                details: None,
205                not_scaled_reasons: Vec::new(),
206            };
207            account.scaling_activities.push(activity);
208        }
209        Ok(AwsResponse::ok_json(json!({
210            "ScalableTargetARN": arn,
211        })))
212    }
213
214    fn describe_scalable_targets(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
215        let body = req.json_body();
216        let namespace = require_str(&body, "ServiceNamespace")?;
217        validate_service_namespace(&namespace)?;
218        let resource_ids: Vec<String> = body
219            .get("ResourceIds")
220            .and_then(Value::as_array)
221            .map(|v| {
222                v.iter()
223                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
224                    .collect()
225            })
226            .unwrap_or_default();
227        for rid in &resource_ids {
228            validate_resource_id_len("resourceIds", rid)?;
229        }
230        let dimension = body
231            .get("ScalableDimension")
232            .and_then(Value::as_str)
233            .map(|s| s.to_string());
234        if let Some(d) = dimension.as_deref() {
235            validate_scalable_dimension(d)?;
236        }
237        let max_results = body
238            .get("MaxResults")
239            .and_then(Value::as_u64)
240            .map(|n| n as usize)
241            .unwrap_or(50);
242        let next_token = body
243            .get("NextToken")
244            .and_then(Value::as_str)
245            .map(|s| s.to_string());
246
247        let state = self.state.read();
248        let mut all: Vec<ScalableTarget> = state
249            .accounts
250            .get(&req.account_id)
251            .map(|a| {
252                a.scalable_targets
253                    .values()
254                    .filter(|t| t.service_namespace == namespace)
255                    .filter(|t| resource_ids.is_empty() || resource_ids.contains(&t.resource_id))
256                    .filter(|t| {
257                        dimension
258                            .as_deref()
259                            .is_none_or(|d| t.scalable_dimension == d)
260                    })
261                    .cloned()
262                    .collect()
263            })
264            .unwrap_or_default();
265        drop(state);
266        all.sort_by(|a, b| a.arn.cmp(&b.arn));
267        let (page, next) = paginate(&all, next_token.as_deref(), max_results);
268        let mut response = json!({
269            "ScalableTargets": page.iter().map(scalable_target_json).collect::<Vec<_>>(),
270        });
271        if let Some(t) = next {
272            response
273                .as_object_mut()
274                .unwrap()
275                .insert("NextToken".to_string(), Value::String(t));
276        }
277        Ok(AwsResponse::ok_json(response))
278    }
279
280    fn deregister_scalable_target(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
281        let body = req.json_body();
282        let namespace = require_str(&body, "ServiceNamespace")?;
283        let resource_id = require_str(&body, "ResourceId")?;
284        let dimension = require_str(&body, "ScalableDimension")?;
285        let key = (namespace, resource_id, dimension);
286
287        let mut state = self.state.write();
288        let account = account_mut(&mut state, &req.account_id);
289        if account.scalable_targets.remove(&key).is_none() {
290            return Err(object_not_found(format!(
291                "No scalable target registered for ServiceNamespace={} ResourceId={} ScalableDimension={}",
292                key.0, key.1, key.2
293            )));
294        }
295        // Cascade: real AWS keeps the policies/scheduled actions on the
296        // target; cleaning up here keeps state coherent for tests.
297        account
298            .scaling_policies
299            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
300        account
301            .scheduled_actions
302            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
303        Ok(AwsResponse::ok_json(json!({})))
304    }
305
306    fn put_scaling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
307        let body = req.json_body();
308        let policy_name = require_str(&body, "PolicyName")?;
309        let namespace = require_str(&body, "ServiceNamespace")?;
310        let resource_id = require_str(&body, "ResourceId")?;
311        let dimension = require_str(&body, "ScalableDimension")?;
312        let policy_type = body
313            .get("PolicyType")
314            .and_then(Value::as_str)
315            .unwrap_or("StepScaling")
316            .to_string();
317        let step_cfg = body.get("StepScalingPolicyConfiguration").cloned();
318        let tt_cfg = body
319            .get("TargetTrackingScalingPolicyConfiguration")
320            .cloned();
321        let pred_cfg = body.get("PredictiveScalingPolicyConfiguration").cloned();
322
323        let target_key = (namespace.clone(), resource_id.clone(), dimension.clone());
324        let policy_key = (
325            namespace.clone(),
326            resource_id.clone(),
327            dimension.clone(),
328            policy_name.clone(),
329        );
330
331        let mut state = self.state.write();
332        let account = account_mut(&mut state, &req.account_id);
333        if !account.scalable_targets.contains_key(&target_key) {
334            return Err(object_not_found(format!(
335                "No scalable target registered for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
336            )));
337        }
338        let arn = if let Some(existing) = account.scaling_policies.get_mut(&policy_key) {
339            existing.policy_type = policy_type.clone();
340            existing.step_scaling_policy_configuration = step_cfg;
341            existing.target_tracking_scaling_policy_configuration = tt_cfg;
342            existing.predictive_scaling_policy_configuration = pred_cfg;
343            existing.arn.clone()
344        } else {
345            let arn = synth_policy_arn(
346                &req.account_id,
347                &req.region,
348                &namespace,
349                &resource_id,
350                &policy_name,
351            );
352            let policy = ScalingPolicy {
353                arn: arn.clone(),
354                policy_name: policy_name.clone(),
355                service_namespace: namespace.clone(),
356                resource_id: resource_id.clone(),
357                scalable_dimension: dimension.clone(),
358                policy_type: policy_type.clone(),
359                creation_time: Utc::now(),
360                step_scaling_policy_configuration: step_cfg,
361                target_tracking_scaling_policy_configuration: tt_cfg,
362                predictive_scaling_policy_configuration: pred_cfg,
363                alarms: Vec::new(),
364                last_applied_at: None,
365            };
366            account.scaling_policies.insert(policy_key, policy);
367            arn
368        };
369        Ok(AwsResponse::ok_json(json!({
370            "PolicyARN": arn,
371            "Alarms": [],
372        })))
373    }
374
375    fn describe_scaling_policies(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
376        let body = req.json_body();
377        let namespace = require_str(&body, "ServiceNamespace")?;
378        validate_service_namespace(&namespace)?;
379        let policy_names: Vec<String> = body
380            .get("PolicyNames")
381            .and_then(Value::as_array)
382            .map(|v| {
383                v.iter()
384                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
385                    .collect()
386            })
387            .unwrap_or_default();
388        let resource_id = body
389            .get("ResourceId")
390            .and_then(Value::as_str)
391            .map(|s| s.to_string());
392        if let Some(r) = resource_id.as_deref() {
393            validate_resource_id_len("resourceId", r)?;
394        }
395        let dimension = body
396            .get("ScalableDimension")
397            .and_then(Value::as_str)
398            .map(|s| s.to_string());
399        if let Some(d) = dimension.as_deref() {
400            validate_scalable_dimension(d)?;
401        }
402        let max_results = body
403            .get("MaxResults")
404            .and_then(Value::as_u64)
405            .map(|n| n as usize)
406            .unwrap_or(50);
407        let next_token = body
408            .get("NextToken")
409            .and_then(Value::as_str)
410            .map(|s| s.to_string());
411
412        let state = self.state.read();
413        let mut all: Vec<ScalingPolicy> = state
414            .accounts
415            .get(&req.account_id)
416            .map(|a| {
417                a.scaling_policies
418                    .values()
419                    .filter(|p| p.service_namespace == namespace)
420                    .filter(|p| policy_names.is_empty() || policy_names.contains(&p.policy_name))
421                    .filter(|p| resource_id.as_deref().is_none_or(|r| p.resource_id == r))
422                    .filter(|p| {
423                        dimension
424                            .as_deref()
425                            .is_none_or(|d| p.scalable_dimension == d)
426                    })
427                    .cloned()
428                    .collect()
429            })
430            .unwrap_or_default();
431        drop(state);
432        all.sort_by(|a, b| a.arn.cmp(&b.arn));
433        let (page, next) = paginate(&all, next_token.as_deref(), max_results);
434        let mut response = json!({
435            "ScalingPolicies": page.iter().map(scaling_policy_json).collect::<Vec<_>>(),
436        });
437        if let Some(t) = next {
438            response
439                .as_object_mut()
440                .unwrap()
441                .insert("NextToken".to_string(), Value::String(t));
442        }
443        Ok(AwsResponse::ok_json(response))
444    }
445
446    fn delete_scaling_policy(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
447        let body = req.json_body();
448        let policy_name = require_str(&body, "PolicyName")?;
449        let namespace = require_str(&body, "ServiceNamespace")?;
450        let resource_id = require_str(&body, "ResourceId")?;
451        let dimension = require_str(&body, "ScalableDimension")?;
452        let key = (namespace, resource_id, dimension, policy_name);
453
454        let mut state = self.state.write();
455        let account = account_mut(&mut state, &req.account_id);
456        if account.scaling_policies.remove(&key).is_none() {
457            return Err(object_not_found(format!(
458                "No scaling policy named {} found for ServiceNamespace={} ResourceId={} ScalableDimension={}",
459                key.3, key.0, key.1, key.2
460            )));
461        }
462        Ok(AwsResponse::ok_json(json!({})))
463    }
464
465    fn put_scheduled_action(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
466        let body = req.json_body();
467        let action_name = require_str(&body, "ScheduledActionName")?;
468        let namespace = require_str(&body, "ServiceNamespace")?;
469        let resource_id = require_str(&body, "ResourceId")?;
470        let dimension = require_str(&body, "ScalableDimension")?;
471        let schedule = body
472            .get("Schedule")
473            .and_then(Value::as_str)
474            .map(|s| s.to_string());
475        let timezone = body
476            .get("Timezone")
477            .and_then(Value::as_str)
478            .map(|s| s.to_string());
479        let start_time = parse_epoch_time(body.get("StartTime"));
480        let end_time = parse_epoch_time(body.get("EndTime"));
481        let action = body
482            .get("ScalableTargetAction")
483            .map(parse_scalable_target_action);
484
485        let target_key = (namespace.clone(), resource_id.clone(), dimension.clone());
486        let action_key = (
487            namespace.clone(),
488            resource_id.clone(),
489            dimension.clone(),
490            action_name.clone(),
491        );
492
493        let mut state = self.state.write();
494        let account = account_mut(&mut state, &req.account_id);
495        if !account.scalable_targets.contains_key(&target_key) {
496            return Err(object_not_found(format!(
497                "No scalable target registered for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
498            )));
499        }
500        if let Some(existing) = account.scheduled_actions.get_mut(&action_key) {
501            if let Some(s) = schedule {
502                existing.schedule = s;
503            }
504            if timezone.is_some() {
505                existing.timezone = timezone;
506            }
507            if start_time.is_some() {
508                existing.start_time = start_time;
509            }
510            if end_time.is_some() {
511                existing.end_time = end_time;
512            }
513            if action.is_some() {
514                existing.scalable_target_action = action;
515            }
516        } else {
517            let schedule = schedule
518                .ok_or_else(|| invalid_param("Schedule is required for new scheduled actions"))?;
519            let arn = synth_scheduled_action_arn(
520                &req.account_id,
521                &req.region,
522                &namespace,
523                &resource_id,
524                &action_name,
525            );
526            let scheduled = ScheduledAction {
527                arn,
528                scheduled_action_name: action_name.clone(),
529                service_namespace: namespace.clone(),
530                resource_id: resource_id.clone(),
531                scalable_dimension: Some(dimension.clone()),
532                schedule,
533                timezone,
534                start_time,
535                end_time,
536                scalable_target_action: action,
537                creation_time: Utc::now(),
538                last_fired_at: None,
539            };
540            account.scheduled_actions.insert(action_key, scheduled);
541        }
542        Ok(AwsResponse::ok_json(json!({})))
543    }
544
545    fn describe_scheduled_actions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
546        let body = req.json_body();
547        let namespace = require_str(&body, "ServiceNamespace")?;
548        validate_service_namespace(&namespace)?;
549        let names: Vec<String> = body
550            .get("ScheduledActionNames")
551            .and_then(Value::as_array)
552            .map(|v| {
553                v.iter()
554                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
555                    .collect()
556            })
557            .unwrap_or_default();
558        let resource_id = body
559            .get("ResourceId")
560            .and_then(Value::as_str)
561            .map(|s| s.to_string());
562        if let Some(r) = resource_id.as_deref() {
563            validate_resource_id_len("resourceId", r)?;
564        }
565        let dimension = body
566            .get("ScalableDimension")
567            .and_then(Value::as_str)
568            .map(|s| s.to_string());
569        if let Some(d) = dimension.as_deref() {
570            validate_scalable_dimension(d)?;
571        }
572        let max_results = body
573            .get("MaxResults")
574            .and_then(Value::as_u64)
575            .map(|n| n as usize)
576            .unwrap_or(50);
577        let next_token = body
578            .get("NextToken")
579            .and_then(Value::as_str)
580            .map(|s| s.to_string());
581
582        let state = self.state.read();
583        let mut all: Vec<ScheduledAction> = state
584            .accounts
585            .get(&req.account_id)
586            .map(|a| {
587                a.scheduled_actions
588                    .values()
589                    .filter(|s| s.service_namespace == namespace)
590                    .filter(|s| names.is_empty() || names.contains(&s.scheduled_action_name))
591                    .filter(|s| resource_id.as_deref().is_none_or(|r| s.resource_id == r))
592                    .filter(|s| {
593                        dimension
594                            .as_deref()
595                            .is_none_or(|d| s.scalable_dimension.as_deref() == Some(d))
596                    })
597                    .cloned()
598                    .collect()
599            })
600            .unwrap_or_default();
601        drop(state);
602        all.sort_by(|a, b| a.arn.cmp(&b.arn));
603        let (page, next) = paginate(&all, next_token.as_deref(), max_results);
604        let mut response = json!({
605            "ScheduledActions": page.iter().map(scheduled_action_json).collect::<Vec<_>>(),
606        });
607        if let Some(t) = next {
608            response
609                .as_object_mut()
610                .unwrap()
611                .insert("NextToken".to_string(), Value::String(t));
612        }
613        Ok(AwsResponse::ok_json(response))
614    }
615
616    fn delete_scheduled_action(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
617        let body = req.json_body();
618        let namespace = require_str(&body, "ServiceNamespace")?;
619        let action_name = require_str(&body, "ScheduledActionName")?;
620        let resource_id = require_str(&body, "ResourceId")?;
621        let dimension = require_str(&body, "ScalableDimension")?;
622        let key = (namespace, resource_id, dimension, action_name);
623        let mut state = self.state.write();
624        let account = account_mut(&mut state, &req.account_id);
625        if account.scheduled_actions.remove(&key).is_none() {
626            return Err(object_not_found(format!(
627                "No scheduled action named {} found for ServiceNamespace={} ResourceId={} ScalableDimension={}",
628                key.3, key.0, key.1, key.2
629            )));
630        }
631        Ok(AwsResponse::ok_json(json!({})))
632    }
633
634    fn describe_scaling_activities(
635        &self,
636        req: &AwsRequest,
637    ) -> Result<AwsResponse, AwsServiceError> {
638        let body = req.json_body();
639        let namespace = require_str(&body, "ServiceNamespace")?;
640        validate_service_namespace(&namespace)?;
641        let resource_id = body
642            .get("ResourceId")
643            .and_then(Value::as_str)
644            .map(|s| s.to_string());
645        if let Some(r) = resource_id.as_deref() {
646            validate_resource_id_len("resourceId", r)?;
647        }
648        let dimension = body
649            .get("ScalableDimension")
650            .and_then(Value::as_str)
651            .map(|s| s.to_string());
652        if let Some(d) = dimension.as_deref() {
653            validate_scalable_dimension(d)?;
654        }
655        let include_not_scaled = body
656            .get("IncludeNotScaledActivities")
657            .and_then(Value::as_bool)
658            .unwrap_or(false);
659        let max_results = body
660            .get("MaxResults")
661            .and_then(Value::as_u64)
662            .map(|n| n as usize)
663            .unwrap_or(50);
664        let next_token = body
665            .get("NextToken")
666            .and_then(Value::as_str)
667            .map(|s| s.to_string());
668
669        let state = self.state.read();
670        let mut all: Vec<ScalingActivity> = state
671            .accounts
672            .get(&req.account_id)
673            .map(|a| {
674                a.scaling_activities
675                    .iter()
676                    .filter(|act| act.service_namespace == namespace)
677                    .filter(|act| resource_id.as_deref().is_none_or(|r| act.resource_id == r))
678                    .filter(|act| {
679                        dimension
680                            .as_deref()
681                            .is_none_or(|d| act.scalable_dimension == d)
682                    })
683                    .filter(|act| include_not_scaled || act.status_code != "Failed")
684                    .cloned()
685                    .collect()
686            })
687            .unwrap_or_default();
688        drop(state);
689        all.sort_by_key(|a| std::cmp::Reverse(a.start_time));
690        let (page, next) = paginate(&all, next_token.as_deref(), max_results);
691        let mut response = json!({
692            "ScalingActivities": page.iter().map(scaling_activity_json).collect::<Vec<_>>(),
693        });
694        if let Some(t) = next {
695            response
696                .as_object_mut()
697                .unwrap()
698                .insert("NextToken".to_string(), Value::String(t));
699        }
700        Ok(AwsResponse::ok_json(response))
701    }
702
703    fn get_predictive_scaling_forecast(
704        &self,
705        req: &AwsRequest,
706    ) -> Result<AwsResponse, AwsServiceError> {
707        let body = req.json_body();
708        let namespace = require_str(&body, "ServiceNamespace")?;
709        let resource_id = require_str(&body, "ResourceId")?;
710        let dimension = require_str(&body, "ScalableDimension")?;
711        let policy_name = require_str(&body, "PolicyName")?;
712        let start = parse_epoch_time(body.get("StartTime"))
713            .ok_or_else(|| invalid_param("StartTime is required"))?;
714        let end = parse_epoch_time(body.get("EndTime"))
715            .ok_or_else(|| invalid_param("EndTime is required"))?;
716
717        let policy_key = (
718            namespace.clone(),
719            resource_id.clone(),
720            dimension.clone(),
721            policy_name.clone(),
722        );
723        let state = self.state.read();
724        let policy = state
725            .accounts
726            .get(&req.account_id)
727            .and_then(|a| a.scaling_policies.get(&policy_key))
728            .ok_or_else(|| {
729                // GetPredictiveScalingForecast's Smithy model only declares
730                // InternalServiceException and ValidationException. Missing
731                // policies surface as ValidationException, not the
732                // ObjectNotFoundException used by the Delete/Put ops.
733                invalid_param(format!(
734                    "No predictive scaling policy named {policy_name} found for ServiceNamespace={namespace} ResourceId={resource_id} ScalableDimension={dimension}"
735                ))
736            })?;
737        if policy.policy_type != "PredictiveScaling" {
738            return Err(invalid_param(
739                "Policy is not a PredictiveScaling policy; cannot return a forecast",
740            ));
741        }
742        let buckets = synth_forecast(start, end);
743        Ok(AwsResponse::ok_json(json!({
744            "LoadForecast": [{
745                "Timestamps": buckets.iter().map(|(t, _)| t.timestamp() as f64).collect::<Vec<_>>(),
746                "Values": buckets.iter().map(|(_, v)| *v as f64).collect::<Vec<_>>(),
747                "MetricSpecification": {
748                    "TargetValue": 70.0,
749                    "PredefinedMetricPairSpecification": {
750                        "PredefinedMetricType": "ECSServiceCPUUtilization"
751                    }
752                },
753            }],
754            "CapacityForecast": {
755                "Timestamps": buckets.iter().map(|(t, _)| t.timestamp() as f64).collect::<Vec<_>>(),
756                "Values": buckets
757                    .iter()
758                    .map(|(_, v)| ((*v as f64) / 100.0).ceil().max(1.0))
759                    .collect::<Vec<_>>(),
760            },
761            "UpdateTime": Utc::now().timestamp() as f64,
762        })))
763    }
764
765    fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
766        let body = req.json_body();
767        let arn = require_str(&body, "ResourceARN")?;
768        let state = self.state.read();
769        let account = state.accounts.get(&req.account_id);
770        // Real AWS rejects unknown ARNs with ResourceNotFoundException rather
771        // than returning an empty tag set — match that so callers can tell
772        // a missing target apart from a target with no tags. The Smithy
773        // model for the tag-* ops declares ResourceNotFoundException, not the
774        // ObjectNotFoundException used by the Delete/Put scaling-target ops.
775        let exists = account.is_some_and(|a| resource_exists(a, &arn));
776        if !exists {
777            return Err(resource_not_found(format!("Resource {arn} not found")));
778        }
779        let tags = account
780            .and_then(|a| a.tags.get(&arn))
781            .cloned()
782            .unwrap_or_default();
783        Ok(AwsResponse::ok_json(json!({ "Tags": tags })))
784    }
785
786    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
787        let body = req.json_body();
788        let arn = require_str(&body, "ResourceARN")?;
789        let tags_in = body
790            .get("Tags")
791            .and_then(Value::as_object)
792            .ok_or_else(|| invalid_param("Tags is required"))?;
793        let mut state = self.state.write();
794        let account = account_mut(&mut state, &req.account_id);
795        if !resource_exists(account, &arn) {
796            return Err(resource_not_found(format!("Resource {arn} not found")));
797        }
798        let entry = account.tags.entry(arn).or_default();
799        for (k, v) in tags_in {
800            if let Some(s) = v.as_str() {
801                entry.insert(k.clone(), s.to_string());
802            }
803        }
804        Ok(AwsResponse::ok_json(json!({})))
805    }
806
807    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
808        let body = req.json_body();
809        let arn = require_str(&body, "ResourceARN")?;
810        let keys: Vec<String> = body
811            .get("TagKeys")
812            .and_then(Value::as_array)
813            .map(|v| {
814                v.iter()
815                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
816                    .collect()
817            })
818            .unwrap_or_default();
819        let mut state = self.state.write();
820        let account = account_mut(&mut state, &req.account_id);
821        if !resource_exists(account, &arn) {
822            return Err(resource_not_found(format!("Resource {arn} not found")));
823        }
824        if let Some(tags) = account.tags.get_mut(&arn) {
825            for k in keys {
826                tags.remove(&k);
827            }
828        }
829        Ok(AwsResponse::ok_json(json!({})))
830    }
831}
832
833fn account_mut<'a>(
834    state: &'a mut ApplicationAutoScalingAccounts,
835    account_id: &str,
836) -> &'a mut AccountState {
837    state.accounts.entry(account_id.to_string()).or_default()
838}
839
840fn require_str(body: &Value, field: &str) -> Result<String, AwsServiceError> {
841    body.get(field)
842        .and_then(Value::as_str)
843        .map(|s| s.to_string())
844        .ok_or_else(|| invalid_param(format!("{field} is required")))
845}
846
847fn invalid_param(msg: impl Into<String>) -> AwsServiceError {
848    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
849}
850
851// Smithy: com.amazonaws.applicationautoscaling#ServiceNamespace
852const VALID_SERVICE_NAMESPACES: &[&str] = &[
853    "ecs",
854    "elasticmapreduce",
855    "ec2",
856    "appstream",
857    "dynamodb",
858    "rds",
859    "sagemaker",
860    "custom-resource",
861    "comprehend",
862    "lambda",
863    "cassandra",
864    "kafka",
865    "elasticache",
866    "neptune",
867    "workspaces",
868];
869
870// Smithy: com.amazonaws.applicationautoscaling#ScalableDimension
871const VALID_SCALABLE_DIMENSIONS: &[&str] = &[
872    "ecs:service:DesiredCount",
873    "ec2:spot-fleet-request:TargetCapacity",
874    "elasticmapreduce:instancegroup:InstanceCount",
875    "appstream:fleet:DesiredCapacity",
876    "dynamodb:table:ReadCapacityUnits",
877    "dynamodb:table:WriteCapacityUnits",
878    "dynamodb:index:ReadCapacityUnits",
879    "dynamodb:index:WriteCapacityUnits",
880    "rds:cluster:ReadReplicaCount",
881    "sagemaker:variant:DesiredInstanceCount",
882    "custom-resource:ResourceType:Property",
883    "comprehend:document-classifier-endpoint:DesiredInferenceUnits",
884    "comprehend:entity-recognizer-endpoint:DesiredInferenceUnits",
885    "lambda:function:ProvisionedConcurrency",
886    "cassandra:table:ReadCapacityUnits",
887    "cassandra:table:WriteCapacityUnits",
888    "kafka:broker-storage:VolumeSize",
889    "elasticache:cache-cluster:Nodes",
890    "elasticache:replication-group:NodeGroups",
891    "elasticache:replication-group:Replicas",
892    "neptune:cluster:ReadReplicaCount",
893    "sagemaker:variant:DesiredProvisionedConcurrency",
894    "sagemaker:inference-component:DesiredCopyCount",
895    "workspaces:workspacespool:DesiredUserSessions",
896];
897
898fn validate_service_namespace(value: &str) -> Result<(), AwsServiceError> {
899    if VALID_SERVICE_NAMESPACES.contains(&value) {
900        Ok(())
901    } else {
902        Err(invalid_param(format!(
903            "Value '{value}' at 'serviceNamespace' failed to satisfy constraint: Member must satisfy enum value set: {VALID_SERVICE_NAMESPACES:?}"
904        )))
905    }
906}
907
908fn validate_scalable_dimension(value: &str) -> Result<(), AwsServiceError> {
909    if VALID_SCALABLE_DIMENSIONS.contains(&value) {
910        Ok(())
911    } else {
912        Err(invalid_param(format!(
913            "Value '{value}' at 'scalableDimension' failed to satisfy constraint: Member must satisfy enum value set: {VALID_SCALABLE_DIMENSIONS:?}"
914        )))
915    }
916}
917
918// Smithy: com.amazonaws.applicationautoscaling#ResourceIdMaxLen1600 (length 1..=1600).
919// Same shape is also used for RoleARN, so we validate both via the same range.
920fn validate_resource_id_len(field: &str, value: &str) -> Result<(), AwsServiceError> {
921    let len = value.chars().count();
922    if !(1..=1600).contains(&len) {
923        return Err(invalid_param(format!(
924            "Value at '{field}' failed to satisfy constraint: Member must have length between 1 and 1600, inclusive"
925        )));
926    }
927    Ok(())
928}
929
930fn object_not_found(msg: impl Into<String>) -> AwsServiceError {
931    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ObjectNotFoundException", msg)
932}
933
934fn resource_not_found(msg: impl Into<String>) -> AwsServiceError {
935    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ResourceNotFoundException", msg)
936}
937
938fn parse_suspended_state(value: &Value) -> SuspendedState {
939    SuspendedState {
940        dynamic_scaling_in_suspended: value
941            .get("DynamicScalingInSuspended")
942            .and_then(Value::as_bool),
943        dynamic_scaling_out_suspended: value
944            .get("DynamicScalingOutSuspended")
945            .and_then(Value::as_bool),
946        scheduled_scaling_suspended: value
947            .get("ScheduledScalingSuspended")
948            .and_then(Value::as_bool),
949    }
950}
951
952fn parse_scalable_target_action(value: &Value) -> ScalableTargetAction {
953    ScalableTargetAction {
954        min_capacity: value
955            .get("MinCapacity")
956            .and_then(Value::as_i64)
957            .map(|n| n as i32),
958        max_capacity: value
959            .get("MaxCapacity")
960            .and_then(Value::as_i64)
961            .map(|n| n as i32),
962    }
963}
964
965fn parse_epoch_time(value: Option<&Value>) -> Option<DateTime<Utc>> {
966    let v = value?;
967    if let Some(n) = v.as_f64() {
968        return DateTime::<Utc>::from_timestamp(
969            n.trunc() as i64,
970            ((n.fract() * 1e9) as u32).min(999_999_999),
971        );
972    }
973    if let Some(s) = v.as_str() {
974        return DateTime::parse_from_rfc3339(s)
975            .ok()
976            .map(|dt| dt.with_timezone(&Utc));
977    }
978    None
979}
980
981fn resource_exists(account: &AccountState, arn: &str) -> bool {
982    account.scalable_targets.values().any(|t| t.arn == arn)
983        || account.scaling_policies.values().any(|p| p.arn == arn)
984}
985
986fn synth_activity_id() -> String {
987    Uuid::new_v4().to_string()
988}
989
990fn synth_scalable_target_arn(account_id: &str, region: &str) -> String {
991    let region = if region.is_empty() {
992        "us-east-1"
993    } else {
994        region
995    };
996    let id = Uuid::new_v4().simple().to_string();
997    let id = &id[..10];
998    Arn::new(
999        "application-autoscaling",
1000        region,
1001        account_id,
1002        &format!("scalable-target/{id}"),
1003    )
1004    .with_partition(partition_for(region))
1005    .to_string()
1006}
1007
1008fn synth_policy_arn(
1009    account_id: &str,
1010    region: &str,
1011    namespace: &str,
1012    resource_id: &str,
1013    name: &str,
1014) -> String {
1015    let region = if region.is_empty() {
1016        "us-east-1"
1017    } else {
1018        region
1019    };
1020    let id = Uuid::new_v4();
1021    format!(
1022        "arn:aws:autoscaling:{region}:{account_id}:scalingPolicy:{id}:resource/{namespace}/{resource_id}:policyName/{name}"
1023    )
1024}
1025
1026fn synth_scheduled_action_arn(
1027    account_id: &str,
1028    region: &str,
1029    namespace: &str,
1030    resource_id: &str,
1031    name: &str,
1032) -> String {
1033    let region = if region.is_empty() {
1034        "us-east-1"
1035    } else {
1036        region
1037    };
1038    let id = Uuid::new_v4();
1039    format!(
1040        "arn:aws:autoscaling:{region}:{account_id}:scheduledAction:{id}:resource/{namespace}/{resource_id}:scheduledActionName/{name}"
1041    )
1042}
1043
1044fn default_service_linked_role(account_id: &str, namespace: &str) -> String {
1045    let suffix = match namespace {
1046        "ecs" => "ECSService",
1047        "elasticmapreduce" => "EMRContainerService",
1048        "ec2" => "EC2SpotFleetRequest",
1049        "appstream" => "ApplicationAutoScaling_AppStreamFleet",
1050        "dynamodb" => "DynamoDBTable",
1051        "rds" => "RDSCluster",
1052        "sagemaker" => "SageMakerEndpoint",
1053        "lambda" => "LambdaConcurrency",
1054        "elasticache" => "ElastiCacheRG",
1055        "cassandra" => "CassandraTable",
1056        "kafka" => "KafkaCluster",
1057        _ => "ApplicationAutoScaling_Default",
1058    };
1059    Arn::global(
1060        "iam",
1061        account_id,
1062        &format!("role/aws-service-role/applicationautoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_{suffix}"),
1063    )
1064    .to_string()
1065}
1066
1067fn synth_forecast(start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<(DateTime<Utc>, i32)> {
1068    let mut out = Vec::new();
1069    if end <= start {
1070        return out;
1071    }
1072    let mut cursor = start;
1073    let step = Duration::hours(1);
1074    while cursor < end {
1075        // Sine-ish curve scaled into 30..=90 percent CPU range, deterministic
1076        // by hour-of-day so tests can pin specific values.
1077        let h = cursor.timestamp().rem_euclid(86_400) / 3600;
1078        let v = 30 + ((h * 5) as i32 % 60).abs();
1079        out.push((cursor, v));
1080        cursor += step;
1081        if out.len() >= 168 {
1082            break; // cap at one week of hourly buckets
1083        }
1084    }
1085    out
1086}
1087
1088// ─── JSON shaping ────────────────────────────────────────────────────
1089
1090fn scalable_target_json(t: &ScalableTarget) -> Value {
1091    let mut obj = json!({
1092        "ScalableTargetARN": t.arn,
1093        "ServiceNamespace": t.service_namespace,
1094        "ResourceId": t.resource_id,
1095        "ScalableDimension": t.scalable_dimension,
1096        "MinCapacity": t.min_capacity,
1097        "MaxCapacity": t.max_capacity,
1098        "RoleARN": t.role_arn,
1099        "CreationTime": t.creation_time.timestamp() as f64,
1100    });
1101    if let Some(s) = &t.suspended_state {
1102        obj.as_object_mut().unwrap().insert(
1103            "SuspendedState".to_string(),
1104            json!({
1105                "DynamicScalingInSuspended": s.dynamic_scaling_in_suspended,
1106                "DynamicScalingOutSuspended": s.dynamic_scaling_out_suspended,
1107                "ScheduledScalingSuspended": s.scheduled_scaling_suspended,
1108            }),
1109        );
1110    }
1111    if let Some(c) = t.predicted_capacity {
1112        obj.as_object_mut()
1113            .unwrap()
1114            .insert("PredictedCapacity".to_string(), json!(c));
1115    }
1116    obj
1117}
1118
1119fn scaling_policy_json(p: &ScalingPolicy) -> Value {
1120    let mut obj = json!({
1121        "PolicyARN": p.arn,
1122        "PolicyName": p.policy_name,
1123        "ServiceNamespace": p.service_namespace,
1124        "ResourceId": p.resource_id,
1125        "ScalableDimension": p.scalable_dimension,
1126        "PolicyType": p.policy_type,
1127        "CreationTime": p.creation_time.timestamp() as f64,
1128        "Alarms": p.alarms.iter().map(|a| json!({
1129            "AlarmName": a.alarm_name,
1130            "AlarmARN": a.alarm_arn,
1131        })).collect::<Vec<_>>(),
1132    });
1133    if let Some(c) = &p.step_scaling_policy_configuration {
1134        obj.as_object_mut()
1135            .unwrap()
1136            .insert("StepScalingPolicyConfiguration".to_string(), c.clone());
1137    }
1138    if let Some(c) = &p.target_tracking_scaling_policy_configuration {
1139        obj.as_object_mut().unwrap().insert(
1140            "TargetTrackingScalingPolicyConfiguration".to_string(),
1141            c.clone(),
1142        );
1143    }
1144    if let Some(c) = &p.predictive_scaling_policy_configuration {
1145        obj.as_object_mut().unwrap().insert(
1146            "PredictiveScalingPolicyConfiguration".to_string(),
1147            c.clone(),
1148        );
1149    }
1150    obj
1151}
1152
1153fn scheduled_action_json(s: &ScheduledAction) -> Value {
1154    let mut obj = json!({
1155        "ScheduledActionARN": s.arn,
1156        "ScheduledActionName": s.scheduled_action_name,
1157        "ServiceNamespace": s.service_namespace,
1158        "ResourceId": s.resource_id,
1159        "Schedule": s.schedule,
1160        "CreationTime": s.creation_time.timestamp() as f64,
1161    });
1162    if let Some(d) = &s.scalable_dimension {
1163        obj.as_object_mut()
1164            .unwrap()
1165            .insert("ScalableDimension".to_string(), Value::String(d.clone()));
1166    }
1167    if let Some(t) = &s.timezone {
1168        obj.as_object_mut()
1169            .unwrap()
1170            .insert("Timezone".to_string(), Value::String(t.clone()));
1171    }
1172    if let Some(t) = s.start_time {
1173        obj.as_object_mut()
1174            .unwrap()
1175            .insert("StartTime".to_string(), json!(t.timestamp() as f64));
1176    }
1177    if let Some(t) = s.end_time {
1178        obj.as_object_mut()
1179            .unwrap()
1180            .insert("EndTime".to_string(), json!(t.timestamp() as f64));
1181    }
1182    if let Some(a) = &s.scalable_target_action {
1183        let mut action = serde_json::Map::new();
1184        if let Some(min) = a.min_capacity {
1185            action.insert("MinCapacity".to_string(), json!(min));
1186        }
1187        if let Some(max) = a.max_capacity {
1188            action.insert("MaxCapacity".to_string(), json!(max));
1189        }
1190        obj.as_object_mut()
1191            .unwrap()
1192            .insert("ScalableTargetAction".to_string(), Value::Object(action));
1193    }
1194    obj
1195}
1196
1197fn scaling_activity_json(a: &ScalingActivity) -> Value {
1198    let mut obj = json!({
1199        "ActivityId": a.activity_id,
1200        "ServiceNamespace": a.service_namespace,
1201        "ResourceId": a.resource_id,
1202        "ScalableDimension": a.scalable_dimension,
1203        "Description": a.description,
1204        "Cause": a.cause,
1205        "StartTime": a.start_time.timestamp() as f64,
1206        "StatusCode": a.status_code,
1207    });
1208    if let Some(t) = a.end_time {
1209        obj.as_object_mut()
1210            .unwrap()
1211            .insert("EndTime".to_string(), json!(t.timestamp() as f64));
1212    }
1213    if let Some(m) = &a.status_message {
1214        obj.as_object_mut()
1215            .unwrap()
1216            .insert("StatusMessage".to_string(), Value::String(m.clone()));
1217    }
1218    if let Some(d) = &a.details {
1219        obj.as_object_mut()
1220            .unwrap()
1221            .insert("Details".to_string(), Value::String(d.clone()));
1222    }
1223    if !a.not_scaled_reasons.is_empty() {
1224        let arr: Vec<Value> = a
1225            .not_scaled_reasons
1226            .iter()
1227            .map(not_scaled_reason_json)
1228            .collect();
1229        obj.as_object_mut()
1230            .unwrap()
1231            .insert("NotScaledReasons".to_string(), Value::Array(arr));
1232    }
1233    obj
1234}
1235
1236fn not_scaled_reason_json(r: &NotScaledReason) -> Value {
1237    let mut obj = json!({ "Code": r.code });
1238    if let Some(v) = r.max_capacity {
1239        obj.as_object_mut()
1240            .unwrap()
1241            .insert("MaxCapacity".to_string(), json!(v));
1242    }
1243    if let Some(v) = r.min_capacity {
1244        obj.as_object_mut()
1245            .unwrap()
1246            .insert("MinCapacity".to_string(), json!(v));
1247    }
1248    if let Some(v) = r.current_capacity {
1249        obj.as_object_mut()
1250            .unwrap()
1251            .insert("CurrentCapacity".to_string(), json!(v));
1252    }
1253    obj
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259    use http::Method;
1260    use std::collections::HashMap;
1261
1262    fn make_req(action: &str, body: Value) -> AwsRequest {
1263        AwsRequest {
1264            service: "application-autoscaling".to_string(),
1265            action: action.to_string(),
1266            region: "us-east-1".to_string(),
1267            account_id: "123456789012".to_string(),
1268            request_id: "rid".to_string(),
1269            headers: http::HeaderMap::new(),
1270            query_params: HashMap::new(),
1271            body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
1272            body_stream: parking_lot::Mutex::new(None),
1273            path_segments: vec![],
1274            raw_path: "/".to_string(),
1275            raw_query: String::new(),
1276            method: Method::POST,
1277            is_query_protocol: false,
1278            access_key_id: None,
1279            principal: None,
1280        }
1281    }
1282
1283    #[test]
1284    fn register_scalable_target_emits_scaling_activity() {
1285        let svc = ApplicationAutoScalingService::default();
1286        let req = make_req(
1287            "RegisterScalableTarget",
1288            json!({
1289                "ServiceNamespace": "ecs",
1290                "ResourceId": "service/cluster1/svc1",
1291                "ScalableDimension": "ecs:service:DesiredCount",
1292                "MinCapacity": 1,
1293                "MaxCapacity": 5,
1294            }),
1295        );
1296        svc.register_scalable_target(&req).unwrap();
1297        let state = svc.state.read();
1298        let activities = &state
1299            .accounts
1300            .get("123456789012")
1301            .unwrap()
1302            .scaling_activities;
1303        assert_eq!(
1304            activities.len(),
1305            1,
1306            "expected 1 activity, got {activities:?}"
1307        );
1308        let a = &activities[0];
1309        assert_eq!(a.service_namespace, "ecs");
1310        assert_eq!(a.status_code, "Successful");
1311        assert!(a.description.contains("min capacity to 1"));
1312        assert!(a.description.contains("max capacity to 5"));
1313    }
1314
1315    #[test]
1316    fn updating_bounds_appends_another_activity() {
1317        let svc = ApplicationAutoScalingService::default();
1318        let initial = make_req(
1319            "RegisterScalableTarget",
1320            json!({
1321                "ServiceNamespace": "ecs",
1322                "ResourceId": "service/cluster1/svc1",
1323                "ScalableDimension": "ecs:service:DesiredCount",
1324                "MinCapacity": 1,
1325                "MaxCapacity": 5,
1326            }),
1327        );
1328        svc.register_scalable_target(&initial).unwrap();
1329        let update = make_req(
1330            "RegisterScalableTarget",
1331            json!({
1332                "ServiceNamespace": "ecs",
1333                "ResourceId": "service/cluster1/svc1",
1334                "ScalableDimension": "ecs:service:DesiredCount",
1335                "MinCapacity": 2,
1336                "MaxCapacity": 10,
1337            }),
1338        );
1339        svc.register_scalable_target(&update).unwrap();
1340        let state = svc.state.read();
1341        let activities = &state
1342            .accounts
1343            .get("123456789012")
1344            .unwrap()
1345            .scaling_activities;
1346        assert_eq!(activities.len(), 2);
1347        assert!(activities[1].description.contains("Updated"));
1348    }
1349
1350    #[test]
1351    fn re_register_with_same_bounds_does_not_log_activity() {
1352        let svc = ApplicationAutoScalingService::default();
1353        let req = make_req(
1354            "RegisterScalableTarget",
1355            json!({
1356                "ServiceNamespace": "ecs",
1357                "ResourceId": "service/cluster1/svc1",
1358                "ScalableDimension": "ecs:service:DesiredCount",
1359                "MinCapacity": 1,
1360                "MaxCapacity": 5,
1361            }),
1362        );
1363        svc.register_scalable_target(&req).unwrap();
1364        // Same bounds again -> no new activity row.
1365        svc.register_scalable_target(&req).unwrap();
1366        let state = svc.state.read();
1367        let activities = &state
1368            .accounts
1369            .get("123456789012")
1370            .unwrap()
1371            .scaling_activities;
1372        assert_eq!(activities.len(), 1);
1373    }
1374
1375    // bug-audit 2026-05-28, 1.7: list ops reject a malformed NextToken
1376    // (paginate_checked -> ValidationException) instead of silently restarting
1377    // at page 0. The rejection primitive is exercised here; the wiring lives in
1378    // the Describe* paginated handlers above.
1379    #[test]
1380    fn paginate_checked_rejects_invalid_token() {
1381        use fakecloud_core::pagination::paginate_checked;
1382        let items: Vec<i32> = (0..5).collect();
1383        assert!(paginate_checked(&items, Some("not-a-valid-token"), 3).is_err());
1384        assert!(paginate_checked(&items, Some("2"), 3).is_ok());
1385        assert!(paginate_checked(&items, None, 3).is_ok());
1386    }
1387}