1import * as AWS from "@/AWS";
2import { PhoneNumber } from "@/AWS/PinpointSMSVoiceV2";
3import * as Test from "@/Test/Alchemy";
4import * as smsvoice from "@distilled.cloud/aws/pinpoint-sms-voice-v2";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Ungated typed-error probe: proves the distilled error union carries the
12// not-found tag this provider's read/observe/delete paths depend on.
13test.provider(
14 "describePhoneNumbers on a nonexistent ID fails with ResourceNotFoundException",
15 () =>
16 Effect.gen(function* () {
17 const error = yield* Effect.flip(
18 smsvoice.describePhoneNumbers({
19 PhoneNumberIds: ["phone-ffffffffffffffffffffffffffffffff"],
20 }),
21 );
22 expect(error._tag).toBe("ResourceNotFoundException");
23 }),
24 {
25 tags: ["provider:aws", "provider:aws:pinpointsmsvoicev2", "live"],
26 timeout: 60_000,
27 },
28);
29
30const getById = (phoneNumberId: string) =>
31 smsvoice.describePhoneNumbers({ PhoneNumberIds: [phoneNumberId] }).pipe(
32 Effect.map((r) => r.PhoneNumbers?.[0]),
33 Effect.catchTag("ResourceNotFoundException", () =>
34 Effect.succeed(undefined),
35 ),
36 );
37
38const assertPhoneNumberGone = (phoneNumberId: string) =>
39 Effect.gen(function* () {
40 const found = yield* getById(phoneNumberId);
41 // A released number lingers briefly in DISASSOCIATING before vanishing.
42 if (found !== undefined && found.Status !== "DISASSOCIATING") {
43 return yield* Effect.fail(
44 new Error(
45 `phone number '${phoneNumberId}' still exists (status: ${found.Status})`,
46 ),
47 );
48 }
49 }).pipe(
50 Effect.retry({
51 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(8)]),
52 }),
53 );
54
55// Requesting even a SIMULATOR number incurs a (small) monthly leasing fee
56// and consumes account quota, so the lifecycle is gated behind
57// AWS_TEST_PINPOINT_SMS=1 and always releases what it leased.
58test.provider.skipIf(!process.env.AWS_TEST_PINPOINT_SMS)(
59 "request a SIMULATOR number, update, and release it",
60 (stack) =>
61 Effect.gen(function* () {
62 yield* stack.destroy();
63
64 const props = {
65 isoCountryCode: "US",
66 messageType: "TRANSACTIONAL" as const,
67 numberCapabilities: ["SMS"],
68 numberType: "SIMULATOR",
69 tags: { fixture: "smsvoice-phone-number" },
70 };
71
72 // Create.
73 const created = yield* stack.deploy(PhoneNumber("TestNumber", props));
74 expect(created.phoneNumberId).toContain("phone-");
75 expect(created.phoneNumberArn).toContain(":phone-number/");
76 expect(created.phoneNumber).toMatch(/^\+1/);
77 expect(created.numberType).toBe("SIMULATOR");
78 expect(created.status === "ACTIVE" || created.status === "PENDING").toBe(
79 true,
80 );
81
82 // Out-of-band verification via distilled.
83 const observed = yield* getById(created.phoneNumberId);
84 expect(observed?.PhoneNumberArn).toBe(created.phoneNumberArn);
85 const tags = yield* smsvoice.listTagsForResource({
86 ResourceArn: created.phoneNumberArn,
87 });
88 const tagRecord = Object.fromEntries(
89 (tags.Tags ?? []).map((t) => [t.Key, t.Value]),
90 );
91 expect(tagRecord.fixture).toBe("smsvoice-phone-number");
92 expect(tagRecord["alchemy::id"]).toBe("TestNumber");
93
94 // No-op redeploy keeps the same number.
95 const noop = yield* stack.deploy(PhoneNumber("TestNumber", props));
96 expect(noop.phoneNumberId).toBe(created.phoneNumberId);
97
98 // Update in place — enable deletion protection; the ID must not
99 // change.
100 const updated = yield* stack.deploy(
101 PhoneNumber("TestNumber", {
102 ...props,
103 deletionProtectionEnabled: true,
104 }),
105 );
106 expect(updated.phoneNumberId).toBe(created.phoneNumberId);
107 const reobserved = yield* getById(created.phoneNumberId);
108 expect(reobserved?.DeletionProtectionEnabled).toBe(true);
109
110 // Disable protection again so the release below succeeds.
111 yield* stack.deploy(PhoneNumber("TestNumber", props));
112 const unprotected = yield* getById(created.phoneNumberId);
113 expect(unprotected?.DeletionProtectionEnabled).toBe(false);
114
115 // Destroy and verify the release out-of-band.
116 yield* stack.destroy();
117 yield* assertPhoneNumberGone(created.phoneNumberId);
118 }),
119 {
120 tags: ["provider:aws", "provider:aws:pinpointsmsvoicev2", "live"],
121 timeout: 240_000,
122 },
123);