t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/S3/Bucket.data-plane.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

566 lines
17 KiB
TypeScript

import { Action } from "@/Action";
import * as AWS from "@/AWS";
import { AWSEnvironment } from "@/AWS/Environment.ts";
import { flociServices } from "@/AWS/Local/FlociServices.ts";
import { Bucket, PutObject, PutObjectHttp } from "@/AWS/S3";
import { remote } from "@/ProviderMode.ts";
import * as Test from "@/Test/Alchemy";
import { liveContext } from "../Local/fixtures/live.ts";
import * as S3 from "@distilled.cloud/aws/s3";
import { expect } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schedule from "effect/Schedule";
const { test } = Test.make({ providers: AWS.providers() });
// The Floci sweep must override even a configured live AWS environment. This
// sentinel makes the Action regression independent of local profiles.
const forceLocal = process.env.ALCHEMY_TEST_DEV === "1";
const actionProviders = forceLocal
? Layer.merge(
AWS.providers(),
Layer.succeed(
AWSEnvironment,
Effect.succeed({
accountId: "111111111111",
region: "us-east-1",
credentials: Effect.die("live credentials must not be used"),
}),
),
)
: AWS.providers();
const { test: actionTest } = Test.make({ providers: actionProviders });
const deployTestBucket = (stack: Test.ScratchStack) =>
Effect.gen(function* () {
// Leading destroy: reconcile away any partial deployment left by a
// previously crashed run (the auto-generated physical name is
// deterministic, so the re-deploy below re-adopts and owns it).
yield* stack.destroy();
return yield* stack.deploy(
Effect.gen(function* () {
return yield* Bucket("DataPlaneTestBucket", {
forceDestroy: true,
});
}),
);
});
// The PRODUCTION dev path: plain `dev: true` (no ALCHEMY_TEST_DEV sweep), so
// the ambient AWS environment stays whatever the profile resolves — live
// credentials under `--profile testing`. Binding clients invoked inside an
// Action must route per bound resource regardless: a local-mode bucket's
// calls land on the floci emulator, an `Alchemy.remote()` bucket's calls
// land on the real cloud. Skipped under the sweep, which pins the whole
// process to floci and would make the live-vs-local distinction vacuous.
const { test: devTest } = Test.make({ providers: AWS.providers(), dev: true });
const inSweep = process.env.ALCHEMY_TEST_DEV === "1";
devTest.provider.skipIf(inSweep)(
"dev Action routes to the local bucket's emulator data plane",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const output = yield* stack.deploy(
Effect.gen(function* () {
const bucket = yield* Bucket("DevLocalActionBucket", {
forceDestroy: true,
});
const PutLocal = Action(
"PutLocalObject",
Effect.gen(function* () {
const putObject = yield* PutObject(bucket);
return () =>
Effect.gen(function* () {
const result = yield* putObject({
Key: "local.txt",
Body: "routed to the emulator",
});
return { etag: result.ETag };
});
}).pipe(Effect.provide(PutObjectHttp)),
);
return { bucketName: bucket.bucketName, put: yield* PutLocal({}) };
}),
);
expect(output.put.etag).toBeDefined();
// The object landed on the emulator...
const local = yield* S3.headObject({
Bucket: output.bucketName,
Key: "local.txt",
}).pipe(Effect.provide(flociServices()));
expect(local.ETag).toBe(output.put.etag);
// ...and the bucket never existed on the real cloud. Ambient in a
// `dev` run is the emulator, so the live probe must opt into the
// registration-captured live chain (see Actions.local.test.ts).
const live = yield* S3.headBucket({ Bucket: output.bucketName }).pipe(
Effect.map(() => "found" as const),
Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
Effect.provide(liveContext),
);
expect(live).toBe("not-found");
yield* stack.destroy();
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
devTest.provider.skipIf(inSweep)(
"dev Action on a remote() bucket targets the real cloud",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const output = yield* stack.deploy(
Effect.gen(function* () {
const bucket = yield* Bucket("DevRemoteActionBucket", {
forceDestroy: true,
}).pipe(remote());
const PutRemote = Action(
"PutRemoteObject",
Effect.gen(function* () {
const putObject = yield* PutObject(bucket);
return () =>
Effect.gen(function* () {
const result = yield* putObject({
Key: "remote.txt",
Body: "landed on real AWS",
});
return { etag: result.ETag };
});
}).pipe(Effect.provide(PutObjectHttp)),
);
return { bucketName: bucket.bucketName, put: yield* PutRemote({}) };
}),
);
expect(output.put.etag).toBeDefined();
// Out-of-band via the live SDK: the write landed on the real bucket.
// Ambient in a `dev` run is the emulator, so this probe (and the
// post-destroy check) must opt into the registration-captured live
// chain.
yield* Effect.gen(function* () {
const live = yield* S3.headObject({
Bucket: output.bucketName,
Key: "remote.txt",
});
expect(live.ETag).toBe(output.put.etag);
yield* stack.destroy();
yield* assertBucketDeleted(output.bucketName);
}).pipe(Effect.provide(liveContext));
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
actionTest.provider(
"Action - uses the configured S3 data plane",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const output = yield* stack.deploy(
Effect.gen(function* () {
const bucket = yield* Bucket("ActionDataPlaneBucket", {
forceDestroy: true,
});
const PutWithAction = Action(
"PutWithAction",
Effect.gen(function* () {
const putObject = yield* PutObject(bucket);
return () =>
Effect.gen(function* () {
const environment = yield* AWSEnvironment.current;
if (forceLocal && environment.accountId !== "000000000000") {
return { accountId: environment.accountId };
}
const result = yield* putObject({
Key: "action.txt",
Body: "hello from Action",
});
return {
accountId: environment.accountId,
etag: result.ETag,
};
});
}).pipe(Effect.provide(PutObjectHttp)),
);
return yield* PutWithAction({});
}),
);
expect(output.etag).toBeDefined();
if (forceLocal) {
expect(output.accountId).toBe("000000000000");
}
yield* stack.destroy();
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"listObjectsV2 - list objects in bucket",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "file1.txt",
Body: "content 1",
});
yield* S3.putObject({
Bucket: bucketName,
Key: "file2.txt",
Body: "content 2",
});
yield* S3.putObject({
Bucket: bucketName,
Key: "folder/file3.txt",
Body: "content 3",
});
const result = yield* S3.listObjectsV2({
Bucket: bucketName,
});
expect(result.Contents).toBeDefined();
expect(result.Contents!.length).toBe(3);
expect(result.Contents!.map((c) => c.Key)).toContain("file1.txt");
expect(result.Contents!.map((c) => c.Key)).toContain("file2.txt");
expect(result.Contents!.map((c) => c.Key)).toContain("folder/file3.txt");
const prefixResult = yield* S3.listObjectsV2({
Bucket: bucketName,
Prefix: "folder/",
});
expect(prefixResult.Contents!.length).toBe(1);
expect(prefixResult.Contents![0].Key).toBe("folder/file3.txt");
const limitResult = yield* S3.listObjectsV2({
Bucket: bucketName,
MaxKeys: 1,
});
expect(limitResult.Contents!.length).toBe(1);
expect(limitResult.IsTruncated).toBe(true);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"headObject - get object metadata",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "test-file.txt",
Body: "Hello, World!",
ContentType: "text/plain",
});
const result = yield* S3.headObject({
Bucket: bucketName,
Key: "test-file.txt",
});
expect(result.ContentType).toBe("text/plain");
expect(result.ContentLength).toBe(13);
expect(result.ETag).toBeDefined();
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"headObject - returns error for non-existent object",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
const result = yield* S3.headObject({
Bucket: bucketName,
Key: "non-existent.txt",
}).pipe(
Effect.map(() => "found" as const),
Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
);
expect(result).toBe("not-found");
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"copyObject - copy object within bucket",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "source.txt",
Body: "Original content",
ContentType: "text/plain",
});
yield* S3.copyObject({
Bucket: bucketName,
Key: "destination.txt",
CopySource: `${bucketName}/source.txt`,
});
const destHead = yield* S3.headObject({
Bucket: bucketName,
Key: "destination.txt",
});
expect(destHead.ContentType).toBe("text/plain");
expect(destHead.ContentLength).toBe(16);
const sourceHead = yield* S3.headObject({
Bucket: bucketName,
Key: "source.txt",
});
expect(sourceHead.ContentLength).toBe(16);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"copyObject - copy with metadata replacement",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "source.txt",
Body: "Content",
ContentType: "text/plain",
});
yield* S3.copyObject({
Bucket: bucketName,
Key: "destination.txt",
CopySource: `${bucketName}/source.txt`,
ContentType: "application/octet-stream",
MetadataDirective: "REPLACE",
});
const destHead = yield* S3.headObject({
Bucket: bucketName,
Key: "destination.txt",
});
expect(destHead.ContentType).toBe("application/octet-stream");
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"multipart upload - complete workflow",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
const createResult = yield* S3.createMultipartUpload({
Bucket: bucketName,
Key: "multipart-file.txt",
ContentType: "text/plain",
});
expect(createResult.UploadId).toBeDefined();
const uploadId = createResult.UploadId!;
// AWS S3 requires parts to be at least 5MB except for the last (or only)
// part, so a single-part upload works with any size
const partContent = "Complete multipart upload content";
const partResult = yield* S3.uploadPart({
Bucket: bucketName,
Key: "multipart-file.txt",
UploadId: uploadId,
PartNumber: 1,
Body: partContent,
});
expect(partResult.ETag).toBeDefined();
yield* S3.completeMultipartUpload({
Bucket: bucketName,
Key: "multipart-file.txt",
UploadId: uploadId,
MultipartUpload: {
Parts: [{ ETag: partResult.ETag!, PartNumber: 1 }],
},
});
const headResult = yield* S3.headObject({
Bucket: bucketName,
Key: "multipart-file.txt",
});
// AWS S3 may use binary/octet-stream for multipart uploads even when
// ContentType is set on createMultipartUpload
expect(headResult.ContentLength).toBe(partContent.length);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"multipart upload - abort",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
const createResult = yield* S3.createMultipartUpload({
Bucket: bucketName,
Key: "aborted-file.txt",
ContentType: "text/plain",
});
const uploadId = createResult.UploadId!;
yield* S3.uploadPart({
Bucket: bucketName,
Key: "aborted-file.txt",
UploadId: uploadId,
PartNumber: 1,
Body: "Some content",
});
yield* S3.abortMultipartUpload({
Bucket: bucketName,
Key: "aborted-file.txt",
UploadId: uploadId,
});
const headResult = yield* S3.headObject({
Bucket: bucketName,
Key: "aborted-file.txt",
}).pipe(
Effect.map(() => "found" as const),
Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
);
expect(headResult).toBe("not-found");
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"putObject and getObject - basic operations",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "test-put.txt",
Body: "Test content for put operation",
ContentType: "text/plain",
});
const headResult = yield* S3.headObject({
Bucket: bucketName,
Key: "test-put.txt",
});
expect(headResult.ContentType).toBe("text/plain");
expect(headResult.ContentLength).toBe(30);
const getResult = yield* S3.getObject({
Bucket: bucketName,
Key: "test-put.txt",
});
expect(getResult.ContentType).toBe("text/plain");
expect(getResult.ContentLength).toBe(30);
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
test.provider(
"deleteObject - remove object",
(stack) =>
Effect.gen(function* () {
const bucket = yield* deployTestBucket(stack);
const bucketName = bucket.bucketName;
yield* S3.putObject({
Bucket: bucketName,
Key: "to-delete.txt",
Body: "Delete me",
});
yield* S3.headObject({
Bucket: bucketName,
Key: "to-delete.txt",
});
yield* S3.deleteObject({
Bucket: bucketName,
Key: "to-delete.txt",
});
const headResult = yield* S3.headObject({
Bucket: bucketName,
Key: "to-delete.txt",
}).pipe(
Effect.map(() => "found" as const),
Effect.catchTag("NotFound", () => Effect.succeed("not-found" as const)),
);
expect(headResult).toBe("not-found");
yield* stack.destroy();
yield* assertBucketDeleted(bucketName);
}),
{ tags: ["provider:aws", "provider:aws:s3", "live"] },
);
class BucketStillExists extends Data.TaggedError("BucketStillExists") {}
const assertBucketDeleted = Effect.fn(function* (bucketName: string) {
yield* S3.headBucket({ Bucket: bucketName }).pipe(
Effect.flatMap(() => Effect.fail(new BucketStillExists())),
Effect.retry({
while: (e) => e._tag === "BucketStillExists",
schedule: Schedule.spaced("2 seconds"),
times: 9,
}),
Effect.catchTag("NotFound", () => Effect.void),
);
});