1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as sns from "@distilled.cloud/aws/sns";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Result from "effect/Result";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import {
12 PlatformApiFunction,
13 PlatformApiFunctionLive,
14 PlatformFixture,
15} from "./platform-handler.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19describe.sequential(
20 "SNS PlatformApplication",
21 { tags: ["provider:aws", "provider:aws:sns", "live"] },
22 () => {
23 // Ungated typed-error probe: SNS validates platform credentials at
24 // CreatePlatformApplication time, so a fake GCM API key must surface as a
25 // typed `InvalidParameterException` ("Platform credentials are invalid").
26 // This proves the distilled error mapping and documents exactly why the
27 // full lifecycle below is credential-gated, at near-zero cost (nothing is
28 // created).
29 test.provider(
30 "createPlatformApplication rejects fake credentials with a typed error",
31 (_stack) =>
32 Effect.gen(function* () {
33 const result = yield* Effect.result(
34 sns.createPlatformApplication({
35 Name: "alchemy-test-sns-platform-probe",
36 Platform: "GCM",
37 Attributes: { PlatformCredential: "invalid-api-key-probe" },
38 }),
39 );
40
41 expect(Result.isFailure(result)).toBe(true);
42 if (Result.isFailure(result)) {
43 expect(result.failure._tag).toBe("InvalidParameterException");
44 }
45 }),
46 { timeout: 60_000 },
47 );
48
49 // Full lifecycle + mobile-push bindings — requires real push-service
50 // credentials:
51 // AWS_TEST_SNS_PLATFORM=1
52 // AWS_TEST_SNS_PLATFORM_NAME (default GCM)
53 // AWS_TEST_SNS_PLATFORM_CREDENTIAL
54 // AWS_TEST_SNS_PLATFORM_TOKEN
55 test.provider.skipIf(!process.env.AWS_TEST_SNS_PLATFORM)(
56 "platform application lifecycle + endpoint bindings",
57 (stack) =>
58 Effect.gen(function* () {
59 if (!process.env.NO_DESTROY) {
60 yield* stack.destroy();
61 }
62
63 const deployed = yield* stack.deploy(
64 Effect.gen(function* () {
65 const { application } = yield* PlatformFixture;
66 const apiFunction = yield* PlatformApiFunction;
67 return { application, apiFunction };
68 }).pipe(Effect.provide(PlatformApiFunctionLive)),
69 );
70
71 expect(deployed.application.platformApplicationArn).toContain(
72 ":app/",
73 );
74 expect(deployed.application.enabled).toBe(true);
75
76 const baseUrl = deployed.apiFunction.functionUrl!.replace(/\/+$/, "");
77 const response = yield* HttpClient.execute(
78 HttpClientRequest.bodyJsonUnsafe(
79 HttpClientRequest.post(`${baseUrl}/endpoint-cycle`),
80 { token: process.env.AWS_TEST_SNS_PLATFORM_TOKEN },
81 ),
82 ).pipe(
83 Effect.retry({
84 schedule: Schedule.exponential("2 seconds"),
85 times: 8,
86 }),
87 Effect.flatMap((res) => res.json),
88 );
89
90 expect((response as any).ok).toBe(true);
91 expect((response as any).endpointArn).toContain(":endpoint/");
92 expect((response as any).endpointCount).toBeGreaterThanOrEqual(0);
93
94 if (!process.env.NO_DESTROY) {
95 yield* stack.destroy();
96 yield* assertPlatformApplicationGone(
97 deployed.application.platformApplicationArn,
98 );
99 }
100 }),
101 { tags: ["provider:aws:lambda"], timeout: 300_000 },
102 );
103 },
104);
105
106class PlatformApplicationStillExists extends Data.TaggedError(
107 "PlatformApplicationStillExists",
108) {}
109
110// Out-of-band proof the trailing destroy left nothing behind: the platform
111// application must be observably gone (typed NotFoundException) after destroy.
112const assertPlatformApplicationGone = Effect.fn(function* (arn: string) {
113 yield* sns
114 .getPlatformApplicationAttributes({ PlatformApplicationArn: arn })
115 .pipe(
116 Effect.flatMap(() => Effect.fail(new PlatformApplicationStillExists())),
117 Effect.retry({
118 while: (error) => error._tag === "PlatformApplicationStillExists",
119 schedule: Schedule.exponential(100),
120 times: 8,
121 }),
122 Effect.catchTag("NotFoundException", () => Effect.void),
123 Effect.catchTag("InvalidParameterException", () => Effect.void),
124 );
125});