1/**
2 * LIVE counterpart to test/dev.test.ts: deploys the exact same orders app
3 * to real AWS (Test.make + deploy, no CLI) — VPC, shared ALB, Fargate
4 * services, DynamoDB table, one-shot tasks — and drives the same HTTP
5 * surface against the real load balancer.
6 *
7 * Requires AWS credentials and docker (the Api/SeedTask/Report images are
8 * built locally and pushed to ECR). The first deploy provisions an ALB and
9 * two Fargate services (~5-10 minutes).
10 */
11import * as Alchemy from "alchemy";
12import * as AWS from "alchemy/AWS";
13import * as Test from "alchemy/Test/Bun";
14import { expect } from "bun:test";
15import * as Effect from "effect/Effect";
16import * as Schedule from "effect/Schedule";
17import * as HttpClient from "effect/http/HttpClient";
18import Stack from "../alchemy.run.ts";
19
20const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
21 providers: AWS.providers(),
22 state: Alchemy.localState(),
23});
24
25// The first deploy builds + pushes container images and waits for two
26// Fargate services to stabilize behind the ALB.
27const stack = beforeAll(deploy(Stack), { timeout: 1_800_000 });
28afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
29 timeout: 1_800_000,
30});
31
32type Outputs = {
33 url: string;
34 apiUrl: string;
35 seedUrl: string;
36 tableName: string;
37};
38
39const outputs = Effect.map(stack, (out) => out as unknown as Outputs);
40
41/** GET with retries — fresh ALB targets take a moment to pass health checks. */
42const getJson = Effect.fn(function* (url: string) {
43 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
44 const res = yield* client.get(url).pipe(
45 Effect.retry({
46 schedule: Schedule.min([
47 Schedule.exponential("1 second"),
48 Schedule.spaced("5 seconds"),
49 ]),
50 times: 30,
51 }),
52 );
53 return yield* res.json;
54});
55
56test(
57 "the Api service serves /api/orders through the shared ALB",
58 Effect.gen(function* () {
59 const { apiUrl } = yield* outputs;
60 const body = (yield* getJson(apiUrl)) as {
61 count: number;
62 orders: unknown[];
63 };
64 expect(body.count).toBeNumber();
65 }),
66 { timeout: 300_000 },
67);
68
69test(
70 "the Web service serves the catch-all route",
71 Effect.gen(function* () {
72 const { url } = yield* outputs;
73 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
74 const res = yield* client.get(url).pipe(
75 Effect.retry({
76 schedule: Schedule.min([
77 Schedule.exponential("1 second"),
78 Schedule.spaced("5 seconds"),
79 ]),
80 times: 30,
81 }),
82 );
83 const text = yield* res.text;
84 expect(text).toContain("Server");
85 }),
86 { timeout: 300_000 },
87);
88
89test(
90 "POST /api/seed launches the SeedTask and seeds the table",
91 Effect.gen(function* () {
92 const { apiUrl, seedUrl } = yield* outputs;
93 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
94
95 const seedRes = yield* client.post(seedUrl).pipe(
96 Effect.retry({
97 schedule: Schedule.spaced("5 seconds"),
98 times: 12,
99 }),
100 );
101 const seed = (yield* seedRes.json) as {
102 taskArn?: string;
103 failures: unknown[];
104 };
105 expect(seed.failures).toEqual([]);
106 expect(seed.taskArn).toBeString();
107
108 // The one-shot Fargate task must pull its image and run to completion
109 // before the orders appear.
110 const count = yield* getJson(apiUrl).pipe(
111 Effect.map((body) => (body as { count: number }).count),
112 Effect.repeat({
113 schedule: Schedule.spaced("5 seconds"),
114 until: (count) => count >= 3,
115 times: 60,
116 }),
117 );
118 expect(count).toBeGreaterThanOrEqual(3);
119 }),
120 { timeout: 600_000 },
121);