1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import { AssetDeployment } from "@/AWS/Website/AssetDeployment.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as s3 from "@distilled.cloud/aws/s3";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Path from "effect/Path";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15test.provider(
16 "list returns [] for the non-listable AssetDeployment",
17 () =>
18 Effect.gen(function* () {
19 const provider = yield* Provider.findProvider(AssetDeployment);
20 const all = yield* provider.list();
21 expect(all).toEqual([]);
22 }),
23 { tags: ["provider:aws", "provider:aws:website", "live"] },
24);
25
26test.provider(
27 "create, update, and delete a purged asset deployment",
28 (stack) =>
29 Effect.gen(function* () {
30 const path = yield* Path.Path;
31 const v1 = path.join(
32 import.meta.dirname,
33 "fixtures",
34 "asset-deployment-v1",
35 );
36 const v2 = path.join(
37 import.meta.dirname,
38 "fixtures",
39 "asset-deployment-v2",
40 );
41
42 yield* stack.destroy();
43
44 const deployed = yield* stack.deploy(
45 Effect.gen(function* () {
46 const bucket = yield* Bucket("AssetBucket", {
47 forceDestroy: true,
48 });
49 const files = yield* AssetDeployment("WebsiteFiles", {
50 bucket,
51 sourcePath: v1,
52 prefix: "site",
53 purge: true,
54 });
55 return { bucket, files };
56 }),
57 );
58
59 expect(deployed.files.bucketName).toBe(deployed.bucket.bucketName);
60 expect(deployed.files.prefix).toBe("site");
61 expect(deployed.files.fileCount).toBe(3);
62 expect(deployed.files.files).toEqual([
63 "about.html",
64 "index.html",
65 "robots.txt",
66 ]);
67
68 yield* assertObjectKeys(deployed.bucket.bucketName, [
69 "site/about.html",
70 "site/index.html",
71 "site/robots.txt",
72 ]);
73
74 const updated = yield* stack.deploy(
75 Effect.gen(function* () {
76 const bucket = yield* Bucket("AssetBucket", {
77 forceDestroy: true,
78 });
79 const files = yield* AssetDeployment("WebsiteFiles", {
80 bucket,
81 sourcePath: v2,
82 prefix: "site",
83 purge: true,
84 });
85 return { bucket, files };
86 }),
87 );
88
89 expect(updated.bucket.bucketName).toBe(deployed.bucket.bucketName);
90 expect(updated.files.fileCount).toBe(3);
91 expect(updated.files.files).toEqual([
92 "extra.css",
93 "index.html",
94 "robots.txt",
95 ]);
96 expect(updated.files.version).not.toBe(deployed.files.version);
97
98 yield* assertObjectKeys(updated.bucket.bucketName, [
99 "site/extra.css",
100 "site/index.html",
101 "site/robots.txt",
102 ]);
103
104 const bucketName = deployed.bucket.bucketName;
105 yield* stack.destroy();
106 yield* assertBucketDeleted(bucketName);
107 }),
108 {
109 tags: ["provider:aws", "provider:aws:s3", "provider:aws:website", "live"],
110 timeout: 120_000,
111 },
112);
113
114const listObjectKeys = (bucketName: string) =>
115 Effect.gen(function* () {
116 const listed = yield* s3.listObjectsV2({ Bucket: bucketName });
117 return (listed.Contents ?? [])
118 .flatMap((object) => (object.Key !== undefined ? [object.Key] : []))
119 .sort();
120 });
121
122const keysMatch = (got: string[], expected: string[]) =>
123 got.length === expected.length &&
124 expected.every((key, index) => got[index] === key);
125
126const assertObjectKeys = (bucketName: string, expected: string[]) =>
127 Effect.gen(function* () {
128 const keys = yield* listObjectKeys(bucketName).pipe(
129 Effect.repeat({
130 schedule: Schedule.spaced("200 millis"),
131 until: (got) => keysMatch(got, expected),
132 times: 15,
133 }),
134 );
135 expect(keys).toEqual(expected);
136 });
137
138class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
139
140const assertBucketDeleted = (bucketName: string) =>
141 s3.headBucket({ Bucket: bucketName }).pipe(
142 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
143 Effect.retry({
144 while: (e) => e._tag === "BucketStillExists",
145 schedule: Schedule.max([Schedule.exponential(100), Schedule.recurs(10)]),
146 }),
147 Effect.catchTag("NotFound", () => Effect.void),
148 );