puls-dev 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -11
  2. package/dist/bin/install-shell.js +5 -6
  3. package/dist/bin/puls.js +10 -3
  4. package/dist/core/config.d.ts +4 -0
  5. package/dist/core/decorators.d.ts +4 -0
  6. package/dist/core/decorators.js +2 -0
  7. package/dist/core/parallel.test.js +4 -3
  8. package/dist/core/resource.d.ts +2 -1
  9. package/dist/core/resource.js +4 -2
  10. package/dist/core/stack.d.ts +4 -0
  11. package/dist/core/stack.js +8 -8
  12. package/dist/providers/aws/acm.test.d.ts +1 -0
  13. package/dist/providers/aws/acm.test.js +167 -0
  14. package/dist/providers/aws/cloudfront.test.d.ts +1 -0
  15. package/dist/providers/aws/cloudfront.test.js +170 -0
  16. package/dist/providers/aws/fargate.test.d.ts +1 -0
  17. package/dist/providers/aws/fargate.test.js +244 -0
  18. package/dist/providers/aws/rds.test.d.ts +1 -0
  19. package/dist/providers/aws/rds.test.js +219 -0
  20. package/dist/providers/aws/sqs.test.d.ts +1 -0
  21. package/dist/providers/aws/sqs.test.js +181 -0
  22. package/dist/providers/cloudflare/api.d.ts +15 -0
  23. package/dist/providers/cloudflare/api.js +199 -0
  24. package/dist/providers/cloudflare/index.d.ts +14 -0
  25. package/dist/providers/cloudflare/index.js +19 -0
  26. package/dist/providers/cloudflare/kv.d.ts +20 -0
  27. package/dist/providers/cloudflare/kv.js +69 -0
  28. package/dist/providers/cloudflare/kv.test.d.ts +1 -0
  29. package/dist/providers/cloudflare/kv.test.js +134 -0
  30. package/dist/providers/cloudflare/r2.d.ts +14 -0
  31. package/dist/providers/cloudflare/r2.js +57 -0
  32. package/dist/providers/cloudflare/r2.test.d.ts +1 -0
  33. package/dist/providers/cloudflare/r2.test.js +132 -0
  34. package/dist/providers/cloudflare/worker.d.ts +28 -0
  35. package/dist/providers/cloudflare/worker.js +172 -0
  36. package/dist/providers/cloudflare/worker.test.d.ts +1 -0
  37. package/dist/providers/cloudflare/worker.test.js +220 -0
  38. package/dist/providers/cloudflare/zone.d.ts +42 -0
  39. package/dist/providers/cloudflare/zone.js +280 -0
  40. package/dist/providers/cloudflare/zone.test.d.ts +1 -0
  41. package/dist/providers/cloudflare/zone.test.js +284 -0
  42. package/dist/providers/firebase/auth.test.d.ts +1 -0
  43. package/dist/providers/firebase/auth.test.js +145 -0
  44. package/dist/providers/firebase/hosting.test.js +7 -6
  45. package/dist/providers/firebase/storage.test.d.ts +1 -0
  46. package/dist/providers/firebase/storage.test.js +148 -0
  47. package/package.json +6 -2
@@ -0,0 +1,170 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { CloudFrontClient } from '@aws-sdk/client-cloudfront';
4
+ import { CloudFrontBuilder } from './cloudfront.js';
5
+ import { Config } from '../../core/config.js';
6
+ describe('CloudFrontBuilder Unit Tests', () => {
7
+ let originalSend;
8
+ let calls = [];
9
+ let mockResponses = {};
10
+ beforeEach(() => {
11
+ Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
12
+ calls = [];
13
+ mockResponses = {};
14
+ originalSend = CloudFrontClient.prototype.send;
15
+ CloudFrontClient.prototype.send = async function (command) {
16
+ const commandName = command.constructor.name;
17
+ calls.push({ commandName, input: command.input });
18
+ const handler = mockResponses[commandName];
19
+ if (handler) {
20
+ if (typeof handler === 'function')
21
+ return handler(command.input);
22
+ if (handler instanceof Error)
23
+ throw handler;
24
+ return handler;
25
+ }
26
+ return {};
27
+ };
28
+ });
29
+ afterEach(() => {
30
+ CloudFrontClient.prototype.send = originalSend;
31
+ });
32
+ test('returns null when distribution is not found during discovery', async () => {
33
+ mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
34
+ const builder = new CloudFrontBuilder('my-cdn');
35
+ const result = await builder.discoveryPromise;
36
+ assert.strictEqual(result, null);
37
+ assert.strictEqual(calls[0].commandName, 'ListDistributionsCommand');
38
+ });
39
+ test('discovers existing distribution by comment name', async () => {
40
+ mockResponses['ListDistributionsCommand'] = {
41
+ DistributionList: {
42
+ Items: [
43
+ { Comment: 'my-cdn', Id: 'd-abc123', ARN: 'arn:aws:cloudfront::123:distribution/d-abc123' },
44
+ ],
45
+ },
46
+ };
47
+ const builder = new CloudFrontBuilder('my-cdn');
48
+ const result = await builder.discoveryPromise;
49
+ assert.ok(result);
50
+ assert.strictEqual(result.Comment, 'my-cdn');
51
+ assert.strictEqual(builder.resolvedId, 'd-abc123');
52
+ assert.strictEqual(builder.resolvedArn, 'arn:aws:cloudfront::123:distribution/d-abc123');
53
+ });
54
+ test('performs dry-run without creating the distribution', async () => {
55
+ Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
56
+ mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
57
+ const builder = new CloudFrontBuilder('my-cdn');
58
+ builder.origin('origin.example.com').dns('cdn.example.com');
59
+ const result = await builder.deploy();
60
+ assert.ok(result);
61
+ assert.strictEqual(result.name, 'my-cdn');
62
+ assert.strictEqual(result.id, 'PENDING');
63
+ const writeCalls = calls.filter((c) => c.commandName !== 'ListDistributionsCommand');
64
+ assert.strictEqual(writeCalls.length, 0);
65
+ });
66
+ test('creates new distribution from scratch when not found', async () => {
67
+ mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
68
+ mockResponses['CreateDistributionCommand'] = {
69
+ Distribution: {
70
+ Id: 'd-new123',
71
+ ARN: 'arn:aws:cloudfront::123:distribution/d-new123',
72
+ DomainName: 'abcdef.cloudfront.net',
73
+ Status: 'Deployed',
74
+ },
75
+ };
76
+ mockResponses['GetDistributionCommand'] = {
77
+ Distribution: { Status: 'Deployed' },
78
+ };
79
+ const builder = new CloudFrontBuilder('my-cdn');
80
+ builder.origin('origin.example.com');
81
+ const result = await builder.deploy();
82
+ assert.ok(result);
83
+ assert.strictEqual(result.id, 'd-new123');
84
+ const createCall = calls.find((c) => c.commandName === 'CreateDistributionCommand');
85
+ assert.ok(createCall);
86
+ assert.strictEqual(createCall.input.DistributionConfig.Comment, 'my-cdn');
87
+ });
88
+ test('returns existing distribution without re-creating it', async () => {
89
+ mockResponses['ListDistributionsCommand'] = {
90
+ DistributionList: {
91
+ Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
92
+ },
93
+ };
94
+ const builder = new CloudFrontBuilder('my-cdn');
95
+ const result = await builder.deploy();
96
+ assert.ok(result);
97
+ assert.strictEqual(result.id, 'd-abc');
98
+ assert.ok(!calls.some((c) => c.commandName === 'CreateDistributionCommand'));
99
+ });
100
+ test('runs cache invalidation on an existing distribution', async () => {
101
+ mockResponses['ListDistributionsCommand'] = {
102
+ DistributionList: {
103
+ Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
104
+ },
105
+ };
106
+ mockResponses['CreateInvalidationCommand'] = { Invalidation: { Id: 'inv-1' } };
107
+ const builder = new CloudFrontBuilder('my-cdn');
108
+ builder.invalidate(['/*', '/index.html']);
109
+ await builder.deploy();
110
+ const invCall = calls.find((c) => c.commandName === 'CreateInvalidationCommand');
111
+ assert.ok(invCall);
112
+ assert.deepStrictEqual(invCall.input.InvalidationBatch.Paths.Items, ['/*', '/index.html']);
113
+ assert.strictEqual(invCall.input.InvalidationBatch.Paths.Quantity, 2);
114
+ });
115
+ test('dry-run prints invalidation plan without executing it', async () => {
116
+ Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
117
+ mockResponses['ListDistributionsCommand'] = {
118
+ DistributionList: {
119
+ Items: [{ Comment: 'my-cdn', Id: 'd-abc', ARN: 'arn:aws:cloudfront::123:distribution/d-abc' }],
120
+ },
121
+ };
122
+ const builder = new CloudFrontBuilder('my-cdn');
123
+ builder.invalidate(['/assets/*']);
124
+ await builder.deploy();
125
+ assert.ok(!calls.some((c) => c.commandName === 'CreateInvalidationCommand'));
126
+ });
127
+ test('clones config from a reference distribution', async () => {
128
+ mockResponses['ListDistributionsCommand'] = { DistributionList: { Items: [] } };
129
+ mockResponses['GetDistributionConfigCommand'] = {
130
+ DistributionConfig: {
131
+ Comment: 'source-cdn',
132
+ Enabled: true,
133
+ Origins: { Quantity: 1, Items: [{ Id: 'orig-1', DomainName: 'source.example.com' }] },
134
+ DefaultCacheBehavior: {
135
+ TargetOriginId: 'orig-1',
136
+ ViewerProtocolPolicy: 'redirect-to-https',
137
+ CachePolicyId: 'cache-policy-id',
138
+ ForwardedValues: { QueryString: false, Cookies: { Forward: 'none' } },
139
+ MinTTL: 0,
140
+ },
141
+ CallerReference: 'old-ref',
142
+ PriceClass: 'PriceClass_All',
143
+ HttpVersion: 'http2',
144
+ },
145
+ };
146
+ mockResponses['CreateDistributionCommand'] = {
147
+ Distribution: {
148
+ Id: 'd-cloned',
149
+ ARN: 'arn:aws:cloudfront::123:distribution/d-cloned',
150
+ DomainName: 'cloned.cloudfront.net',
151
+ Status: 'Deployed',
152
+ },
153
+ };
154
+ mockResponses['GetDistributionCommand'] = { Distribution: { Status: 'Deployed' } };
155
+ const builder = new CloudFrontBuilder('my-cdn');
156
+ builder.copyFrom('d-source');
157
+ const result = await builder.deploy();
158
+ assert.ok(result);
159
+ assert.strictEqual(result.id, 'd-cloned');
160
+ const getConfigCall = calls.find((c) => c.commandName === 'GetDistributionConfigCommand');
161
+ assert.ok(getConfigCall);
162
+ assert.strictEqual(getConfigCall.input.Id, 'd-source');
163
+ const createCall = calls.find((c) => c.commandName === 'CreateDistributionCommand');
164
+ assert.ok(createCall);
165
+ // CallerReference from the original clone is deleted; puls inserts a fresh one
166
+ const newRef = createCall.input.DistributionConfig.CallerReference;
167
+ assert.ok(newRef.startsWith('puls-my-cdn-'));
168
+ assert.notStrictEqual(newRef, 'old-ref');
169
+ });
170
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,244 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import { ECSClient } from "@aws-sdk/client-ecs";
4
+ import { EC2Client } from "@aws-sdk/client-ec2";
5
+ import { IAMClient } from "@aws-sdk/client-iam";
6
+ import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
7
+ import { FargateBuilder } from "./fargate.js";
8
+ import { Config } from "../../core/config.js";
9
+ describe("FargateBuilder Unit Tests", () => {
10
+ let originalEcsSend;
11
+ let originalEc2Send;
12
+ let originalIamSend;
13
+ let originalCwSend;
14
+ let ecsCalls = [];
15
+ let ec2Calls = [];
16
+ let iamCalls = [];
17
+ let cwCalls = [];
18
+ let mockEcsResponses = {};
19
+ let mockEc2Responses = {};
20
+ let mockIamResponses = {};
21
+ let mockCwResponses = {};
22
+ function stubSend(calls, responses) {
23
+ return async function (command) {
24
+ const commandName = command.constructor.name;
25
+ calls.push({ commandName, input: command.input });
26
+ const handler = responses[commandName];
27
+ if (handler) {
28
+ if (typeof handler === "function")
29
+ return handler(command.input);
30
+ if (handler instanceof Error)
31
+ throw handler;
32
+ return handler;
33
+ }
34
+ return {};
35
+ };
36
+ }
37
+ beforeEach(() => {
38
+ Config.set({ dryRun: false, providers: { aws: { region: "us-east-1" } } });
39
+ ecsCalls = [];
40
+ ec2Calls = [];
41
+ iamCalls = [];
42
+ cwCalls = [];
43
+ mockEcsResponses = {};
44
+ mockEc2Responses = {};
45
+ mockIamResponses = {};
46
+ mockCwResponses = {};
47
+ originalEcsSend = ECSClient.prototype.send;
48
+ originalEc2Send = EC2Client.prototype.send;
49
+ originalIamSend = IAMClient.prototype.send;
50
+ originalCwSend = CloudWatchLogsClient.prototype.send;
51
+ ECSClient.prototype.send = stubSend(ecsCalls, mockEcsResponses);
52
+ EC2Client.prototype.send = stubSend(ec2Calls, mockEc2Responses);
53
+ IAMClient.prototype.send = stubSend(iamCalls, mockIamResponses);
54
+ CloudWatchLogsClient.prototype.send = stubSend(cwCalls, mockCwResponses);
55
+ });
56
+ afterEach(() => {
57
+ ECSClient.prototype.send = originalEcsSend;
58
+ EC2Client.prototype.send = originalEc2Send;
59
+ IAMClient.prototype.send = originalIamSend;
60
+ CloudWatchLogsClient.prototype.send = originalCwSend;
61
+ mock.restoreAll();
62
+ });
63
+ test("returns null when cluster does not exist during discovery", async () => {
64
+ mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
65
+ const builder = new FargateBuilder("my-svc");
66
+ const result = await builder.discoveryPromise;
67
+ assert.strictEqual(result, null);
68
+ assert.strictEqual(ecsCalls[0].commandName, "DescribeClustersCommand");
69
+ });
70
+ test("discovers existing service when cluster and service are active", async () => {
71
+ mockEcsResponses["DescribeClustersCommand"] = {
72
+ clusters: [
73
+ {
74
+ status: "ACTIVE",
75
+ clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls",
76
+ },
77
+ ],
78
+ };
79
+ mockEcsResponses["DescribeServicesCommand"] = {
80
+ services: [
81
+ {
82
+ status: "ACTIVE",
83
+ serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
84
+ },
85
+ ],
86
+ };
87
+ const builder = new FargateBuilder("my-svc");
88
+ const result = await builder.discoveryPromise;
89
+ assert.ok(result);
90
+ assert.strictEqual(result.status, "ACTIVE");
91
+ assert.strictEqual(builder.resolvedArn, "arn:aws:ecs:us-east-1:123:service/puls/my-svc");
92
+ });
93
+ test("performs dry-run without any write API calls", async () => {
94
+ Config.set({ dryRun: true, providers: { aws: { region: "us-east-1" } } });
95
+ mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
96
+ const builder = new FargateBuilder("my-svc");
97
+ builder
98
+ .image("my-org/api:latest")
99
+ .cpu(512)
100
+ .memory(1024)
101
+ .port(3000)
102
+ .replicas(2);
103
+ const result = await builder.deploy();
104
+ assert.ok(result);
105
+ assert.strictEqual(result.name, "my-svc");
106
+ assert.ok(result.arn.includes("DRYRUN"));
107
+ const writeCalls = [
108
+ ...ecsCalls,
109
+ ...ec2Calls,
110
+ ...iamCalls,
111
+ ...cwCalls,
112
+ ].filter((c) => !c.commandName.startsWith("Describe"));
113
+ assert.strictEqual(writeCalls.length, 0);
114
+ });
115
+ test("creates new service- registers task def, creates cluster, role, log group, and service", async () => {
116
+ // Discovery: no cluster
117
+ mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
118
+ // ensureCluster
119
+ mockEcsResponses["CreateClusterCommand"] = {
120
+ cluster: { clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls" },
121
+ };
122
+ // ensureExecutionRole
123
+ const noRole = new Error("no such entity");
124
+ noRole.name = "NoSuchEntityException";
125
+ mockIamResponses["GetRoleCommand"] = noRole;
126
+ mockIamResponses["CreateRoleCommand"] = {
127
+ Role: { Arn: "arn:aws:iam::123:role/puls-fargate-my-svc-exec-role" },
128
+ };
129
+ // ensureLogGroup
130
+ mockCwResponses["DescribeLogGroupsCommand"] = { logGroups: [] };
131
+ // discoverDefaultVpc
132
+ mockEc2Responses["DescribeVpcsCommand"] = { Vpcs: [{ VpcId: "vpc-abc" }] };
133
+ mockEc2Responses["DescribeSubnetsCommand"] = {
134
+ Subnets: [{ SubnetId: "subnet-1" }, { SubnetId: "subnet-2" }],
135
+ };
136
+ // ensureSecurityGroup
137
+ mockEc2Responses["DescribeSecurityGroupsCommand"] = { SecurityGroups: [] };
138
+ mockEc2Responses["CreateSecurityGroupCommand"] = { GroupId: "sg-xyz" };
139
+ // RegisterTaskDefinition + CreateService
140
+ mockEcsResponses["RegisterTaskDefinitionCommand"] = {
141
+ taskDefinition: {
142
+ taskDefinitionArn: "arn:aws:ecs:us-east-1:123:task-def/my-svc:1",
143
+ revision: 1,
144
+ },
145
+ };
146
+ mockEcsResponses["CreateServiceCommand"] = {
147
+ service: { serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc" },
148
+ };
149
+ const builder = new FargateBuilder("my-svc");
150
+ builder
151
+ .image("my-org/api:latest")
152
+ .cpu(512)
153
+ .memory(1024)
154
+ .port(3000)
155
+ .replicas(2);
156
+ const result = await builder.deploy();
157
+ assert.ok(result);
158
+ assert.strictEqual(result.name, "my-svc");
159
+ assert.strictEqual(result.arn, "arn:aws:ecs:us-east-1:123:service/puls/my-svc");
160
+ const createSvc = ecsCalls.find((c) => c.commandName === "CreateServiceCommand");
161
+ assert.ok(createSvc);
162
+ assert.strictEqual(createSvc.input.desiredCount, 2);
163
+ const registerDef = ecsCalls.find((c) => c.commandName === "RegisterTaskDefinitionCommand");
164
+ assert.ok(registerDef);
165
+ assert.strictEqual(registerDef.input.cpu, "512");
166
+ assert.strictEqual(registerDef.input.memory, "1024");
167
+ });
168
+ test("updates existing service with new task definition", async () => {
169
+ mockEcsResponses["DescribeClustersCommand"] = {
170
+ clusters: [
171
+ {
172
+ status: "ACTIVE",
173
+ clusterArn: "arn:aws:ecs:us-east-1:123:cluster/puls",
174
+ },
175
+ ],
176
+ };
177
+ mockEcsResponses["DescribeServicesCommand"] = {
178
+ services: [
179
+ {
180
+ status: "ACTIVE",
181
+ serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
182
+ },
183
+ ],
184
+ };
185
+ // ensureCluster (existing)
186
+ mockEcsResponses["CreateClusterCommand"] = {};
187
+ // ensureExecutionRole (existing)
188
+ mockIamResponses["GetRoleCommand"] = {
189
+ Role: { Arn: "arn:aws:iam::123:role/puls-fargate-my-svc-exec-role" },
190
+ };
191
+ // ensureLogGroup (existing)
192
+ mockCwResponses["DescribeLogGroupsCommand"] = {
193
+ logGroups: [{ logGroupName: "/puls/my-svc" }],
194
+ };
195
+ // networking (existing SGs provided)
196
+ const builder = new FargateBuilder("my-svc");
197
+ builder
198
+ .image("my-org/api:v2")
199
+ .subnets(["subnet-1"])
200
+ .securityGroups(["sg-1"])
201
+ .replicas(3);
202
+ mockEcsResponses["RegisterTaskDefinitionCommand"] = {
203
+ taskDefinition: {
204
+ taskDefinitionArn: "arn:aws:ecs:us-east-1:123:task-def/my-svc:2",
205
+ revision: 2,
206
+ },
207
+ };
208
+ mockEcsResponses["UpdateServiceCommand"] = {};
209
+ await builder.deploy();
210
+ const updateSvc = ecsCalls.find((c) => c.commandName === "UpdateServiceCommand");
211
+ assert.ok(updateSvc);
212
+ assert.strictEqual(updateSvc.input.desiredCount, 3);
213
+ assert.strictEqual(updateSvc.input.forceNewDeployment, true);
214
+ });
215
+ test("drains and deletes service on destroy", async () => {
216
+ mockEcsResponses["DescribeClustersCommand"] = {
217
+ clusters: [{ status: "ACTIVE" }],
218
+ };
219
+ mockEcsResponses["DescribeServicesCommand"] = {
220
+ services: [
221
+ {
222
+ status: "ACTIVE",
223
+ serviceArn: "arn:aws:ecs:us-east-1:123:service/puls/my-svc",
224
+ },
225
+ ],
226
+ };
227
+ const builder = new FargateBuilder("my-svc");
228
+ await builder.discoveryPromise;
229
+ const result = await builder.destroy();
230
+ assert.deepStrictEqual(result, { destroyed: "my-svc" });
231
+ const drain = ecsCalls.find((c) => c.commandName === "UpdateServiceCommand" && c.input.desiredCount === 0);
232
+ assert.ok(drain);
233
+ const del = ecsCalls.find((c) => c.commandName === "DeleteServiceCommand");
234
+ assert.ok(del);
235
+ assert.strictEqual(del.input.force, true);
236
+ });
237
+ test("skips destroy when service does not exist", async () => {
238
+ mockEcsResponses["DescribeClustersCommand"] = { clusters: [] };
239
+ const builder = new FargateBuilder("my-svc");
240
+ const result = await builder.destroy();
241
+ assert.deepStrictEqual(result, { destroyed: "my-svc" });
242
+ assert.ok(!ecsCalls.some((c) => c.commandName === "DeleteServiceCommand"));
243
+ });
244
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,219 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from "node:test";
2
+ import assert from "node:assert";
3
+ import { RDSClient } from "@aws-sdk/client-rds";
4
+ import { EC2Client } from "@aws-sdk/client-ec2";
5
+ import { RDSBuilder } from "./rds.js";
6
+ import { Config } from "../../core/config.js";
7
+ describe("RDSBuilder Unit Tests", () => {
8
+ let originalRdsSend;
9
+ let originalEc2Send;
10
+ let rdsCalls = [];
11
+ let ec2Calls = [];
12
+ let mockRdsResponses = {};
13
+ let mockEc2Responses = {};
14
+ function stubSend(calls, responses) {
15
+ return async function (command) {
16
+ const commandName = command.constructor.name;
17
+ calls.push({ commandName, input: command.input });
18
+ const handler = responses[commandName];
19
+ if (handler) {
20
+ if (typeof handler === "function")
21
+ return handler(command.input);
22
+ if (handler instanceof Error)
23
+ throw handler;
24
+ return handler;
25
+ }
26
+ return {};
27
+ };
28
+ }
29
+ beforeEach(() => {
30
+ Config.set({ dryRun: false, providers: { aws: { region: "us-east-1" } } });
31
+ rdsCalls = [];
32
+ ec2Calls = [];
33
+ mockRdsResponses = {};
34
+ mockEc2Responses = {};
35
+ originalRdsSend = RDSClient.prototype.send;
36
+ originalEc2Send = EC2Client.prototype.send;
37
+ RDSClient.prototype.send = stubSend(rdsCalls, mockRdsResponses);
38
+ EC2Client.prototype.send = stubSend(ec2Calls, mockEc2Responses);
39
+ });
40
+ afterEach(() => {
41
+ RDSClient.prototype.send = originalRdsSend;
42
+ EC2Client.prototype.send = originalEc2Send;
43
+ mock.restoreAll();
44
+ });
45
+ test("returns null when DB instance is not found during discovery", async () => {
46
+ const err = new Error("not found");
47
+ err.name = "DBInstanceNotFound";
48
+ mockRdsResponses["DescribeDBInstancesCommand"] = err;
49
+ const builder = new RDSBuilder("my-db");
50
+ const result = await builder.discoveryPromise;
51
+ assert.strictEqual(result, null);
52
+ assert.strictEqual(rdsCalls[0].commandName, "DescribeDBInstancesCommand");
53
+ });
54
+ test("discovers existing instance and populates resolved fields", async () => {
55
+ mockRdsResponses["DescribeDBInstancesCommand"] = {
56
+ DBInstances: [
57
+ {
58
+ DBInstanceIdentifier: "my-db",
59
+ DBInstanceStatus: "available",
60
+ DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
61
+ Endpoint: {
62
+ Address: "my-db.xyz.us-east-1.rds.amazonaws.com",
63
+ Port: 5432,
64
+ },
65
+ },
66
+ ],
67
+ };
68
+ const builder = new RDSBuilder("my-db");
69
+ const result = await builder.discoveryPromise;
70
+ assert.ok(result);
71
+ assert.strictEqual(builder.resolvedArn, "arn:aws:rds:us-east-1:123:db:my-db");
72
+ assert.strictEqual(builder.resolvedEndpoint, "my-db.xyz.us-east-1.rds.amazonaws.com");
73
+ assert.strictEqual(builder.resolvedPort, 5432);
74
+ });
75
+ test("performs dry-run without any write API calls", async () => {
76
+ Config.set({ dryRun: true, providers: { aws: { region: "us-east-1" } } });
77
+ const err = new Error("not found");
78
+ err.name = "DBInstanceNotFound";
79
+ mockRdsResponses["DescribeDBInstancesCommand"] = err;
80
+ const builder = new RDSBuilder("my-db");
81
+ builder
82
+ .engine({ engine: "postgres", version: "16" })
83
+ .size("db.t3.micro")
84
+ .storage(20);
85
+ const result = await builder.deploy();
86
+ assert.ok(result);
87
+ assert.ok(result.endpoint.includes("DRYRUN"));
88
+ assert.strictEqual(result.port, 5432);
89
+ const writeCalls = rdsCalls.filter((c) => !c.commandName.startsWith("Describe"));
90
+ assert.strictEqual(writeCalls.length, 0);
91
+ });
92
+ test("creates new DB instance with subnet group and security group", async () => {
93
+ const err = new Error("not found");
94
+ err.name = "DBInstanceNotFound";
95
+ mockRdsResponses["DescribeDBInstancesCommand"] = err;
96
+ // ensureSubnetGroup
97
+ const noGroup = new Error("not found");
98
+ noGroup.name = "DBSubnetGroupNotFoundFault";
99
+ mockRdsResponses["DescribeDBSubnetGroupsCommand"] = noGroup;
100
+ mockRdsResponses["CreateDBSubnetGroupCommand"] = {};
101
+ // ensureSecurityGroup
102
+ mockEc2Responses["DescribeVpcsCommand"] = {
103
+ Vpcs: [{ VpcId: "vpc-1", CidrBlock: "10.0.0.0/16" }],
104
+ };
105
+ mockEc2Responses["DescribeSubnetsCommand"] = {
106
+ Subnets: [{ SubnetId: "subnet-1" }],
107
+ };
108
+ mockEc2Responses["DescribeSecurityGroupsCommand"] = { SecurityGroups: [] };
109
+ mockEc2Responses["CreateSecurityGroupCommand"] = { GroupId: "sg-1" };
110
+ // CreateDBInstance- return available immediately so the waitFor resolves
111
+ mockRdsResponses["CreateDBInstanceCommand"] = {};
112
+ let describeCount = 0;
113
+ mockRdsResponses["DescribeDBInstancesCommand"] = (input) => {
114
+ describeCount++;
115
+ if (describeCount === 1) {
116
+ // First call: discovery in constructor- instance not found
117
+ const notFound = new Error("not found");
118
+ notFound.name = "DBInstanceNotFound";
119
+ throw notFound;
120
+ }
121
+ // Subsequent calls: waitFor polling- instance now available
122
+ return {
123
+ DBInstances: [
124
+ {
125
+ DBInstanceIdentifier: "my-db",
126
+ DBInstanceStatus: "available",
127
+ DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
128
+ Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
129
+ },
130
+ ],
131
+ };
132
+ };
133
+ // Fast-forward poll timer
134
+ mock.method(global, "setTimeout", (fn) => fn());
135
+ const builder = new RDSBuilder("my-db");
136
+ builder
137
+ .engine({ engine: "postgres", version: "16" })
138
+ .size("db.t3.small")
139
+ .storage(40)
140
+ .credentials("admin", "secret");
141
+ const result = await builder.deploy();
142
+ assert.ok(result);
143
+ const createCall = rdsCalls.find((c) => c.commandName === "CreateDBInstanceCommand");
144
+ assert.ok(createCall);
145
+ assert.strictEqual(createCall.input.DBInstanceIdentifier, "my-db");
146
+ assert.strictEqual(createCall.input.Engine, "postgres");
147
+ assert.strictEqual(createCall.input.EngineVersion, "16");
148
+ assert.strictEqual(createCall.input.DBInstanceClass, "db.t3.small");
149
+ assert.strictEqual(createCall.input.AllocatedStorage, 40);
150
+ });
151
+ test("modifies existing instance on update", async () => {
152
+ mockRdsResponses["DescribeDBInstancesCommand"] = {
153
+ DBInstances: [
154
+ {
155
+ DBInstanceIdentifier: "my-db",
156
+ DBInstanceStatus: "available",
157
+ DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
158
+ Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
159
+ },
160
+ ],
161
+ };
162
+ // ensureSubnetGroup (existing)
163
+ mockRdsResponses["DescribeDBSubnetGroupsCommand"] = {
164
+ DBSubnetGroups: [{ DBSubnetGroupName: "puls-my-db-subnet-group" }],
165
+ };
166
+ const builder = new RDSBuilder("my-db");
167
+ builder
168
+ .engine({ engine: "postgres", version: "16" })
169
+ .size("db.t3.medium")
170
+ .subnets(["subnet-1"])
171
+ .securityGroups(["sg-1"])
172
+ .credentials("admin", "secret");
173
+ await builder.deploy();
174
+ const modifyCall = rdsCalls.find((c) => c.commandName === "ModifyDBInstanceCommand");
175
+ assert.ok(modifyCall);
176
+ assert.strictEqual(modifyCall.input.DBInstanceClass, "db.t3.medium");
177
+ assert.strictEqual(modifyCall.input.ApplyImmediately, true);
178
+ });
179
+ test("deletes instance on destroy", async () => {
180
+ mockRdsResponses["DescribeDBInstancesCommand"] = {
181
+ DBInstances: [
182
+ {
183
+ DBInstanceIdentifier: "my-db",
184
+ DBInstanceStatus: "available",
185
+ DBInstanceArn: "arn:aws:rds:us-east-1:123:db:my-db",
186
+ Endpoint: { Address: "my-db.xyz.rds.amazonaws.com", Port: 5432 },
187
+ },
188
+ ],
189
+ };
190
+ const builder = new RDSBuilder("my-db");
191
+ await builder.discoveryPromise;
192
+ const result = await builder.destroy();
193
+ assert.deepStrictEqual(result, { destroyed: "my-db" });
194
+ const deleteCall = rdsCalls.find((c) => c.commandName === "DeleteDBInstanceCommand");
195
+ assert.ok(deleteCall);
196
+ assert.strictEqual(deleteCall.input.DBInstanceIdentifier, "my-db");
197
+ assert.strictEqual(deleteCall.input.SkipFinalSnapshot, true);
198
+ });
199
+ test("getDiff returns field changes against live state", () => {
200
+ const err = new Error("not found");
201
+ err.name = "DBInstanceNotFound";
202
+ mockRdsResponses["DescribeDBInstancesCommand"] = err;
203
+ const builder = new RDSBuilder("my-db");
204
+ builder
205
+ .engine({ engine: "postgres", version: "16" })
206
+ .size("db.t3.small")
207
+ .storage(40);
208
+ const diffs = builder.getDiff({
209
+ Engine: "postgres",
210
+ EngineVersion: "15", // differs
211
+ DBInstanceClass: "db.t3.micro", // differs
212
+ AllocatedStorage: 40,
213
+ PubliclyAccessible: false,
214
+ });
215
+ assert.strictEqual(diffs.length, 2);
216
+ assert.ok(diffs.some((d) => d.field === "engineVersion"));
217
+ assert.ok(diffs.some((d) => d.field === "instanceClass"));
218
+ });
219
+ });
@@ -0,0 +1 @@
1
+ export {};