t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Railway/Bindings.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

420 lines
13 KiB
TypeScript

import { RailwayAuth } from "@/Railway/AuthProvider.ts";
import { fromAuthProvider } from "@/Railway/Credentials.ts";
import { fromCredentials } from "@distilled.cloud/aws/Credentials";
import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
import type { RegionName } from "@distilled.cloud/aws/Region";
import * as S3 from "@distilled.cloud/aws/s3";
import { Query } from "@distilled.cloud/core/query";
import { GraphQLLive, Railway as RailwayApi } from "@distilled.cloud/railway";
import * as Alchemy from "@/index.ts";
import * as Railway from "@/Railway";
import * as Test from "@/Test/Alchemy";
import { describe, expect } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as HttpClient from "effect/http/HttpClient";
import type * as HttpClientResponse from "effect/http/HttpClientResponse";
import BucketApi, {
Data as BucketData,
OBJECT_BODY,
OBJECT_KEY,
} from "./fixtures/bucket-api.ts";
import RedisApi, { Cache, REDIS_VALUE } from "./fixtures/redis-api.ts";
import { Site } from "./fixtures/bindings-shared.ts";
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
providers: Railway.providers(),
});
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provide(
Layer.merge(
GraphQLLive,
fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
),
),
);
class NotReady extends Data.TaggedError("NotReady")<{
status: number;
body?: unknown;
}> {
override get message() {
return this.body === undefined
? `status ${this.status}`
: `status ${this.status}: ${JSON.stringify(this.body)}`;
}
}
const asVariableMap = (value: unknown): Record<string, string> => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const out: Record<string, string> = {};
for (const [key, item] of Object.entries(value)) {
if (typeof item === "string") {
out[key] = item;
}
}
return out;
};
const readBucketCredentials = Query.fn(
(bucketId: string, environmentId: string, projectId: string) =>
RailwayApi.bucketS3Credentials({ bucketId, environmentId, projectId }).pipe(
Query.map((creds) => ({
bucketName: creds.bucketName,
endpoint: creds.endpoint,
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
region: creds.region,
})),
),
);
const readVariables = Query.fn(
(projectId: string, environmentId: string, serviceId: string) =>
RailwayApi.variables({
projectId,
environmentId,
serviceId,
unrendered: true,
}),
);
const readServiceVariables = (
projectId: string,
environmentId: string,
serviceId: string,
) =>
readVariables(projectId, environmentId, serviceId).pipe(
Effect.map(asVariableMap),
Effect.catchTag("RailwayNotFound", () =>
Effect.succeed({} as Record<string, string>),
),
);
const firstCredentials = (
bucketId: string,
environmentId: string,
projectId: string,
) =>
readBucketCredentials(bucketId, environmentId, projectId).pipe(
Effect.flatMap((items) => {
const first = items[0];
return first !== undefined
? Effect.succeed(first)
: Effect.fail(new Error("missing bucket credentials"));
}),
Effect.retry({
schedule: Schedule.spaced("2 seconds"),
times: 8,
}),
);
const withBucketS3 = <A, E, R>(
creds: {
accessKeyId: string;
secretAccessKey: string;
endpoint: string;
region: string;
},
operation: Effect.Effect<A, E, R>,
) =>
operation.pipe(
Effect.provide(
Layer.mergeAll(
fromCredentials(
{
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
},
creds.region as RegionName,
),
AwsEndpoint.of(creds.endpoint),
),
),
);
const Stack = Alchemy.Stack(
"RailwayBindingsFixture",
{
providers: Railway.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const project = yield* Site;
const cache = yield* Cache;
const proxy = yield* Railway.TcpProxy("CacheProxy", {
redis: cache,
environment: cache,
applicationPort: Railway.REDIS_PORT,
});
const redisApi = yield* RedisApi;
const bucket = yield* BucketData;
const bucketApi = yield* BucketApi;
return {
redisProjectId: project.projectId,
redisEnvironmentId: cache.environmentId,
cacheServiceId: cache.serviceId,
proxyDomain: proxy.domain,
proxyPort: proxy.proxyPort,
redisUrl: redisApi.url,
redisServiceId: redisApi.serviceId,
bucketProjectId: project.projectId,
bucketEnvironmentId: bucket.environmentId,
bucketId: bucket.bucketId,
bucketUrl: bucketApi.url,
bucketServiceId: bucketApi.serviceId,
mode: "effect" as const,
};
}),
);
// Setup provisions Redis and deploys two runtime builds before tests can run.
const stack = beforeAll(deploy(Stack), { timeout: 180_000 });
afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
timeout: 120_000,
});
const retryTransient = {
while: (e: { _tag?: string; status?: number }) =>
e._tag === "NotReady" &&
(e.status === 0 ||
e.status === 404 ||
e.status === 500 ||
e.status === 502 ||
e.status === 503),
schedule: Schedule.exponential("500 millis").pipe(
Schedule.upTo({ duration: "45 seconds" }),
),
times: 10,
} as const;
const readJson = (
res: HttpClientResponse.HttpClientResponse,
): Effect.Effect<unknown, NotReady> =>
res.json.pipe(
Effect.catch(() => Effect.fail(new NotReady({ status: res.status }))),
Effect.flatMap((body) =>
res.status === 200
? Effect.succeed(body)
: Effect.fail(new NotReady({ status: res.status, body })),
),
);
const getJson = (url: string, path: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(`${url}${path}`).pipe(
Effect.timeoutOrElse({
duration: "8 seconds",
orElse: () => Effect.fail(new NotReady({ status: 0 })),
}),
Effect.flatMap(readJson),
Effect.mapError((e) =>
e instanceof NotReady ? e : new NotReady({ status: 0, body: e }),
),
Effect.retry(retryTransient),
);
});
const getText = (url: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(url).pipe(
Effect.timeoutOrElse({
duration: "8 seconds",
orElse: () => Effect.fail(new NotReady({ status: 0 })),
}),
Effect.flatMap((res) =>
res.status === 200
? res.text.pipe(
Effect.mapError(() => new NotReady({ status: res.status })),
)
: Effect.fail(new NotReady({ status: res.status })),
),
Effect.mapError((e) =>
e instanceof NotReady ? e : new NotReady({ status: 0, body: e }),
),
Effect.retry(retryTransient),
);
});
describe(
"Railway Bindings",
{
tags: [
"provider:railway",
"provider:railway:bucket",
"provider:railway:project",
"provider:railway:projectenvironment",
"provider:railway:redis",
"provider:railway:service",
"provider:railway:tcpproxy",
"live",
],
},
() => {
test(
"fixture is reachable over up.railway.app",
Effect.gen(function* () {
const out = yield* stack;
expect(out.redisUrl).toEqual(expect.any(String));
expect(out.redisUrl).toContain("up.railway.app");
expect(out.bucketUrl).toEqual(expect.any(String));
expect(out.bucketUrl).toContain("up.railway.app");
if (out.mode === "effect") {
const redisHealth = (yield* getJson(out.redisUrl!, "/health")) as {
pong?: boolean;
};
expect(redisHealth.pong).toEqual(true);
const bucketHealth = (yield* getJson(out.bucketUrl!, "/health")) as {
ok?: boolean;
};
expect(bucketHealth.ok).toEqual(true);
} else {
const redisBody = yield* getText(out.redisUrl!);
expect(redisBody.length).toBeGreaterThan(0);
const bucketBody = yield* getText(out.bucketUrl!);
expect(bucketBody.length).toBeGreaterThan(0);
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
describe("ReadWriteRedis", { tags: ["provider:railway:variable"] }, () => {
test(
"sets and gets a key",
Effect.gen(function* () {
const out = yield* stack;
if (out.mode === "effect") {
const written = (yield* getJson(out.redisUrl!, "/set")) as {
ok?: boolean;
};
expect(written.ok).toEqual(true);
const read = (yield* getJson(out.redisUrl!, "/get")) as {
ok?: boolean;
value?: string;
};
expect(read.ok).toEqual(true);
expect(read.value).toEqual(REDIS_VALUE);
return;
}
// Public image fallback: docker push is impossible without a
// registry. ReadWriteRedis still packed REDIS_URL onto the
// Service; set/get runs over the public TCP proxy.
const cacheVars = yield* distilled(
readServiceVariables(
out.redisProjectId,
out.redisEnvironmentId,
out.cacheServiceId,
),
);
const password = cacheVars[Railway.REDIS_PASSWORD_ENV];
expect(password !== undefined && password.length > 0).toEqual(true);
const url = Railway.redisConnectionUrl({
host: out.proxyDomain,
port: out.proxyPort,
password: password!,
});
const pong = yield* Railway.runRedisCommand(url, "PING").pipe(
Effect.retry({
schedule: Schedule.spaced("4 seconds"),
times: 10,
}),
);
expect(String(pong).toUpperCase()).toContain("PONG");
yield* Railway.runRedisCommand(url, "SET", ["marker", REDIS_VALUE]);
const got = yield* Railway.runRedisCommand(url, "GET", ["marker"]);
expect(got).toEqual(REDIS_VALUE);
}).pipe(logLevel),
{ timeout: 120_000 },
);
});
describe(
"PutObject / GetObject",
{
tags: ["provider:aws", "provider:aws:s3", "provider:railway:variable"],
},
() => {
test(
"puts and gets an object",
Effect.gen(function* () {
const out = yield* stack;
if (out.mode === "effect") {
const put = (yield* getJson(out.bucketUrl!, "/put")) as {
ok?: boolean;
};
expect(put.ok).toEqual(true);
const got = (yield* getJson(out.bucketUrl!, "/get")) as {
ok?: boolean;
text?: string;
};
expect(got.ok).toEqual(true);
expect(got.text).toEqual(OBJECT_BODY);
return;
}
// Public image fallback: docker push is impossible without a
// registry. PutObject/GetObject still packed AWS_* onto the
// Service; put/get runs over the S3 API from the test process.
const vars = yield* distilled(
readServiceVariables(
out.bucketProjectId,
out.bucketEnvironmentId,
out.bucketServiceId,
),
);
expect((vars.AWS_ACCESS_KEY_ID ?? "").length).toBeGreaterThan(0);
expect((vars.BUCKET_NAME ?? "").length).toBeGreaterThan(0);
const creds = yield* distilled(
firstCredentials(
out.bucketId,
out.bucketEnvironmentId,
out.bucketProjectId,
),
);
yield* withBucketS3(
creds,
S3.putObject({
Bucket: creds.bucketName,
Key: OBJECT_KEY,
Body: OBJECT_BODY,
ContentType: "text/plain",
}),
);
const got = yield* withBucketS3(
creds,
S3.getObject({
Bucket: creds.bucketName,
Key: OBJECT_KEY,
}),
);
const text =
got.Body === undefined
? ""
: yield* Stream.mkString(Stream.decodeText(got.Body));
expect(text).toEqual(OBJECT_BODY);
}).pipe(logLevel),
{ timeout: 120_000 },
);
},
);
},
);