1import * as AWS from "@/AWS";
2import { KeyValueStore, KvRoutesUpdate } from "@/AWS/CloudFront";
3import { extractValue, withKvsRegion } from "@/AWS/CloudFront/common.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as cloudfront from "@distilled.cloud/aws/cloudfront";
7import * as kvs from "@distilled.cloud/aws/cloudfront-keyvaluestore";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14describe(
15 "AWS.CloudFront.KvRoutesUpdate",
16 { tags: ["provider:aws", "provider:aws:cloudfront", "live"] },
17 () => {
18 test.provider(
19 "destroy succeeds after the store is deleted out of band",
20 (stack) =>
21 Effect.gen(function* () {
22 yield* stack.destroy();
23 const store = yield* stack.deploy(
24 Effect.gen(function* () {
25 const store = yield* KeyValueStore("Store", {});
26 yield* KvRoutesUpdate("Route", {
27 store: store.keyValueStoreArn,
28 namespace: "app",
29 key: "routes",
30 entry: "site,app,*,/",
31 });
32 return store;
33 }),
34 );
35 const current = yield* cloudfront.describeKeyValueStore({
36 Name: store.keyValueStoreName,
37 });
38 yield* cloudfront.deleteKeyValueStore({
39 Name: store.keyValueStoreName,
40 IfMatch: current.ETag!,
41 });
42 yield* assertKeyValueStoreDeleted(store.keyValueStoreName);
43 yield* stack.destroy();
44 yield* stack.destroy();
45 }),
46 { timeout: 120_000 },
47 );
48
49 // KvRoutesUpdate is an update operation that manages a single route entry
50 // inside a JSON array stored at a KV store key. It is keyed entirely by
51 // {store, namespace, key, entry} and has no enumeration API, so list() is
52 // non-listable and returns [] cleanly.
53 test.provider("list returns [] (non-listable)", () =>
54 Effect.gen(function* () {
55 const provider = yield* Provider.findProvider(KvRoutesUpdate);
56 const all = yield* provider.list();
57 expect(all).toEqual([]);
58 }),
59 );
60
61 test.provider(
62 "create, update, and delete a routes document entry",
63 (stack) =>
64 Effect.gen(function* () {
65 yield* stack.destroy();
66
67 const initialEntry = "site,mysite,*,/";
68
69 const deployed = yield* stack.deploy(
70 Effect.gen(function* () {
71 const store = yield* KeyValueStore("RoutesStore", {
72 comment: "kv-routes lifecycle",
73 });
74 const route = yield* KvRoutesUpdate("HomeRoute", {
75 store: store.keyValueStoreArn,
76 namespace: "app",
77 key: "routes",
78 entry: initialEntry,
79 });
80 return { store, route };
81 }),
82 );
83
84 expect(deployed.route.store).toBe(deployed.store.keyValueStoreArn);
85 expect(deployed.route.namespace).toBe("app");
86 expect(deployed.route.key).toBe("routes");
87 expect(deployed.route.entry).toBe(initialEntry);
88
89 const described = yield* withKvsRegion(
90 kvs.describeKeyValueStore({
91 KvsARN: deployed.store.keyValueStoreArn,
92 }),
93 );
94 expect(described.KvsARN).toBe(deployed.store.keyValueStoreArn);
95
96 yield* assertRoutes(deployed.store.keyValueStoreArn, "app:routes", [
97 initialEntry,
98 ]);
99
100 const updatedEntry = "site,mysite,*,/app";
101
102 const updated = yield* stack.deploy(
103 Effect.gen(function* () {
104 const store = yield* KeyValueStore("RoutesStore", {
105 comment: "kv-routes lifecycle",
106 });
107 const route = yield* KvRoutesUpdate("HomeRoute", {
108 store: store.keyValueStoreArn,
109 namespace: "app",
110 key: "routes",
111 entry: updatedEntry,
112 });
113 return { store, route };
114 }),
115 );
116
117 expect(updated.store.keyValueStoreArn).toBe(
118 deployed.store.keyValueStoreArn,
119 );
120 expect(updated.route.entry).toBe(updatedEntry);
121
122 yield* assertRoutes(updated.store.keyValueStoreArn, "app:routes", [
123 updatedEntry,
124 ]);
125
126 yield* stack.destroy();
127 yield* assertKeyValueStoreDeleted(deployed.store.keyValueStoreName);
128 }),
129 { timeout: 120_000 },
130 );
131 },
132);
133
134const getRoutesDocument = (store: string, fullKey: string) =>
135 withKvsRegion(kvs.getKey({ KvsARN: store, Key: fullKey })).pipe(
136 Effect.map((resp) => JSON.parse(extractValue(resp.Value)) as string[]),
137 );
138
139const routesMatch = (got: string[], expected: string[]) =>
140 got.length === expected.length &&
141 expected.every((entry) => got.includes(entry));
142
143const assertRoutes = (store: string, fullKey: string, expected: string[]) =>
144 Effect.gen(function* () {
145 const routes = yield* getRoutesDocument(store, fullKey).pipe(
146 Effect.repeat({
147 schedule: Schedule.spaced("200 millis"),
148 until: (got) => routesMatch(got, expected),
149 times: 15,
150 }),
151 );
152 expect(routes).toEqual(expected);
153 });
154
155const assertKeyValueStoreDeleted = (name: string) =>
156 cloudfront.describeKeyValueStore({ Name: name }).pipe(
157 Effect.flatMap(() => Effect.fail(new Error("KeyValueStoreStillExists"))),
158 Effect.catchTag("EntityNotFound", () => Effect.void),
159 Effect.retry({
160 while: (error) =>
161 error instanceof Error && error.message === "KeyValueStoreStillExists",
162 schedule: Schedule.max([
163 Schedule.fixed("2 seconds"),
164 Schedule.recurs(20),
165 ]),
166 }),
167 );