1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import IoTSiteWiseTestFunctionLive, {
11 IoTSiteWiseTestFunction,
12} from "./fixtures/handler.ts";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "IoTSiteWiseBindings");
17
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(60),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// POST a fixture route; retry 5xx (cold-start IAM propagation surfaces as a
31// defect -> 500 until the fresh role policy is visible to IoT SiteWise).
32const post = (path: string) =>
33 HttpClient.execute(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
34 Effect.flatMap((response) =>
35 response.status >= 500
36 ? response.text.pipe(
37 Effect.flatMap((body) =>
38 Effect.fail(
39 new TransientUpstream({ status: response.status, body }),
40 ),
41 ),
42 )
43 : Effect.succeed(response),
44 ),
45 Effect.retry({
46 while: (e) => e._tag === "TransientUpstream",
47 schedule: Schedule.max([
48 Schedule.exponential("1 second"),
49 Schedule.recurs(6),
50 ]),
51 }),
52 );
53
54const postJson = (path: string) =>
55 post(path).pipe(Effect.flatMap((response) => response.json));
56
57describe(
58 "IoTSiteWise Bindings",
59 {
60 tags: [
61 "provider:aws",
62 "provider:aws:batch",
63 "provider:aws:iotsitewise",
64 "provider:aws:lambda",
65 "live",
66 ],
67 },
68 () => {
69 beforeAll(
70 Effect.gen(function* () {
71 yield* Effect.logInfo(
72 "IoTSiteWise test setup: destroying previous resources",
73 );
74 yield* sharedStack.destroy();
75
76 yield* Effect.logInfo("IoTSiteWise test setup: deploying fixture");
77 const { functionUrl } = yield* sharedStack.deploy(
78 Effect.gen(function* () {
79 return yield* IoTSiteWiseTestFunction;
80 }).pipe(Effect.provide(IoTSiteWiseTestFunctionLive)),
81 );
82
83 expect(functionUrl).toBeTruthy();
84 baseUrl = functionUrl!.replace(/\/+$/, "");
85 const readinessUrl = `${baseUrl}/ping`;
86
87 yield* Effect.logInfo(
88 `IoTSiteWise test setup: probing readiness at ${readinessUrl}`,
89 );
90 yield* HttpClient.get(readinessUrl).pipe(
91 Effect.flatMap((response) =>
92 response.status === 200
93 ? Effect.succeed(response)
94 : Effect.fail(
95 new Error(`Function not ready: ${response.status}`),
96 ),
97 ),
98 Effect.tapError((error) =>
99 Effect.logWarning(
100 `IoTSiteWise test setup: fixture not ready yet (${String(error)})`,
101 ),
102 ),
103 Effect.retry({ schedule: readinessPolicy }),
104 );
105 }),
106 { timeout: 300_000 },
107 );
108
109 afterAll(sharedStack.destroy(), { timeout: 180_000 });
110
111 describe("DescribeAsset", () => {
112 test.provider(
113 "describes the bound asset with its properties",
114 (_stack) =>
115 Effect.gen(function* () {
116 const response = (yield* postJson("/describe")) as {
117 assetId: string;
118 assetName: string;
119 propertyNames: string[];
120 };
121 expect(response.assetId).toBeTruthy();
122 expect(response.propertyNames).toContain("Temperature");
123 }),
124 { timeout: 120_000 },
125 );
126 });
127
128 describe("ListAssetProperties", () => {
129 test.provider(
130 "lists the bound asset's property summaries",
131 (_stack) =>
132 Effect.gen(function* () {
133 const response = (yield* postJson("/properties")) as {
134 count: number;
135 };
136 expect(response.count).toBeGreaterThanOrEqual(1);
137 }),
138 { timeout: 120_000 },
139 );
140 });
141
142 describe("BatchPutAssetPropertyValue + GetAssetPropertyValue + GetAssetPropertyValueHistory", () => {
143 test.provider(
144 "ingests a TQV and reads it back (current value + history)",
145 (_stack) =>
146 Effect.gen(function* () {
147 const put = (yield* postJson("/put")) as {
148 errorCount: number;
149 errorCodes: string[];
150 };
151 expect(put.errorCodes).toEqual([]);
152 expect(put.errorCount).toBe(0);
153
154 // Ingested values become readable within seconds — poll bounded.
155 const value = yield* postJson("/value").pipe(
156 Effect.map(
157 (body) =>
158 body as {
159 doubleValue: number | null;
160 timeInSeconds: number | null;
161 },
162 ),
163 Effect.repeat({
164 schedule: Schedule.spaced("3 seconds"),
165 until: (body): boolean => body.doubleValue !== null,
166 times: 20,
167 }),
168 );
169 expect(value.doubleValue).toBe(23.5);
170 expect(value.timeInSeconds).toBeGreaterThan(0);
171
172 const history = (yield* postJson("/history")) as { count: number };
173 expect(history.count).toBeGreaterThanOrEqual(1);
174 }),
175 { timeout: 180_000 },
176 );
177 });
178
179 describe("GetAssetPropertyAggregates", () => {
180 test.provider(
181 "reads aggregates over the last hour",
182 (_stack) =>
183 Effect.gen(function* () {
184 const response = (yield* postJson("/aggregates")) as {
185 ok: boolean;
186 count: number;
187 };
188 expect(response.ok).toBe(true);
189 expect(response.count).toBeGreaterThanOrEqual(0);
190 }),
191 { timeout: 120_000 },
192 );
193 });
194
195 describe("GetInterpolatedAssetPropertyValues", () => {
196 test.provider(
197 "computes interpolated values over the last hour",
198 (_stack) =>
199 Effect.gen(function* () {
200 const response = (yield* postJson("/interpolated")) as {
201 ok: boolean;
202 count: number;
203 };
204 expect(response.ok).toBe(true);
205 expect(response.count).toBeGreaterThanOrEqual(0);
206 }),
207 { timeout: 120_000 },
208 );
209 });
210
211 describe("ExecuteQuery", () => {
212 test.provider(
213 "runs a SiteWise SQL query against the asset index",
214 (_stack) =>
215 Effect.gen(function* () {
216 // The query index is eventually consistent for fresh assets — the
217 // call succeeding with a well-formed (possibly empty) row set is
218 // the contract under test.
219 const response = (yield* postJson("/query")) as {
220 ok: boolean;
221 rowCount: number;
222 };
223 expect(response.ok).toBe(true);
224 expect(response.rowCount).toBeGreaterThanOrEqual(0);
225 }),
226 { timeout: 120_000 },
227 );
228 });
229 },
230);