1import * as AWS from "@/AWS";
2import { ScalableTarget, ScheduledAction } from "@/AWS/ApplicationAutoScaling";
3import { Table } from "@/AWS/DynamoDB";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as aas from "@distilled.cloud/aws/application-auto-scaling";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const describeAction = (scheduledActionName: string) =>
14 aas
15 .describeScheduledActions({
16 ServiceNamespace: "dynamodb",
17 ScheduledActionNames: [scheduledActionName],
18 })
19 .pipe(
20 Effect.map((res) =>
21 res.ScheduledActions?.find(
22 (a) => a.ScheduledActionName === scheduledActionName,
23 ),
24 ),
25 );
26
27const waitUntilActionGone = (scheduledActionName: string) =>
28 describeAction(scheduledActionName).pipe(
29 Effect.repeat({
30 schedule: Schedule.spaced("2 seconds"),
31 until: (action) => action === undefined,
32 times: 10,
33 }),
34 );
35
36// Scheduled-action lifecycle against a provisioned DynamoDB table. The
37// schedule is a far-future one-time `at(...)` so the action never actually
38// fires during the test.
39test.provider(
40 "scheduled action lifecycle (dynamodb)",
41 (stack) =>
42 Effect.gen(function* () {
43 yield* stack.destroy();
44
45 const deploy = (props: { schedule: string; minCapacity: number }) =>
46 stack.deploy(
47 Effect.gen(function* () {
48 const table = yield* Table("AasScheduleTable", {
49 partitionKey: "id",
50 attributes: { id: "S" },
51 billingMode: "PROVISIONED",
52 provisionedThroughput: {
53 ReadCapacityUnits: 1,
54 WriteCapacityUnits: 1,
55 },
56 });
57 const target = yield* ScalableTarget("AasScheduleTarget", {
58 serviceNamespace: "dynamodb",
59 resourceId: Output.interpolate`table/${table.tableName}`,
60 scalableDimension: "dynamodb:table:ReadCapacityUnits",
61 minCapacity: 1,
62 maxCapacity: 10,
63 });
64 const action = yield* ScheduledAction("AasScheduledAction", {
65 serviceNamespace: target.serviceNamespace,
66 resourceId: target.resourceId,
67 scalableDimension: target.scalableDimension,
68 schedule: props.schedule,
69 timezone: "UTC",
70 scalableTargetAction: { MinCapacity: props.minCapacity },
71 });
72 return {
73 resourceId: target.resourceId.as<string>(),
74 scheduledActionName: action.scheduledActionName.as<string>(),
75 scheduledActionArn: action.scheduledActionArn.as<string>(),
76 schedule: action.schedule.as<string>(),
77 };
78 }),
79 );
80
81 const created = yield* deploy({
82 schedule: "at(2030-01-01T00:00:00)",
83 minCapacity: 2,
84 });
85 expect(created.scheduledActionArn).toContain("scheduledAction");
86 expect(created.schedule).toBe("at(2030-01-01T00:00:00)");
87
88 // Out-of-band: the action is attached to the target with the requested
89 // capacity adjustment.
90 const observed = yield* describeAction(created.scheduledActionName);
91 expect(observed?.Schedule).toBe("at(2030-01-01T00:00:00)");
92 expect(observed?.ScalableTargetAction?.MinCapacity).toBe(2);
93 expect(observed?.ResourceId).toBe(created.resourceId);
94
95 // Update in place — `putScheduledAction` upserts by (name, target).
96 const updated = yield* deploy({
97 schedule: "at(2030-06-01T00:00:00)",
98 minCapacity: 3,
99 });
100 expect(updated.scheduledActionName).toEqual(created.scheduledActionName);
101 expect(updated.scheduledActionArn).toEqual(created.scheduledActionArn);
102 const observedAfterUpdate = yield* describeAction(
103 created.scheduledActionName,
104 );
105 expect(observedAfterUpdate?.Schedule).toBe("at(2030-06-01T00:00:00)");
106 expect(observedAfterUpdate?.ScalableTargetAction?.MinCapacity).toBe(3);
107
108 yield* stack.destroy();
109 const gone = yield* waitUntilActionGone(created.scheduledActionName);
110 expect(gone).toBeUndefined();
111 }),
112 {
113 tags: [
114 "provider:aws",
115 "provider:aws:applicationautoscaling",
116 "provider:aws:dynamodb",
117 "live",
118 ],
119 timeout: 240_000,
120 },
121);