diff --git a/.github/workflows/sync-e2e.yml b/.github/workflows/sync-e2e.yml index 6bfecd5..8f3f671 100644 --- a/.github/workflows/sync-e2e.yml +++ b/.github/workflows/sync-e2e.yml @@ -85,7 +85,7 @@ jobs: # Wait for MinIO to be ready for i in {1..30}; do - if curl -sf http://localhost:8987/minio/health/live; then + if curl -sf http://127.0.0.1:8987/minio/health/live; then echo "MinIO is ready" break fi @@ -111,7 +111,7 @@ jobs: working-directory: donut-sync env: SYNC_TOKEN: test-sync-token - S3_ENDPOINT: http://localhost:8987 + S3_ENDPOINT: http://127.0.0.1:8987 S3_ACCESS_KEY_ID: minioadmin S3_SECRET_ACCESS_KEY: minioadmin S3_BUCKET: donut-sync-test diff --git a/donut-sync/docker-compose.yml b/donut-sync/docker-compose.yml index d74389f..222b590 100644 --- a/donut-sync/docker-compose.yml +++ b/donut-sync/docker-compose.yml @@ -18,4 +18,3 @@ services: volumes: minio_data: - diff --git a/donut-sync/test/app.e2e-spec.ts b/donut-sync/test/app.e2e-spec.ts index 11e9287..1988fa0 100644 --- a/donut-sync/test/app.e2e-spec.ts +++ b/donut-sync/test/app.e2e-spec.ts @@ -2,18 +2,29 @@ import { INestApplication } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import request from "supertest"; import { App } from "supertest/types"; -import { AppModule } from "./../src/app.module.js"; +import { AppController } from "./../src/app.controller.js"; +import { AppService } from "./../src/app.service.js"; +import { SyncService } from "./../src/sync/sync.service.js"; describe("AppController (e2e)", () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], + controllers: [AppController], + providers: [ + AppService, + { + provide: SyncService, + useValue: { + checkS3Connectivity: async () => true, + }, + }, + ], }).compile(); app = moduleFixture.createNestApplication(); - await app.init(); + await app.listen(0); }); afterEach(async () => { diff --git a/donut-sync/test/jest-e2e.json b/donut-sync/test/jest-e2e.json index 994b34d..82bdc38 100644 --- a/donut-sync/test/jest-e2e.json +++ b/donut-sync/test/jest-e2e.json @@ -1,6 +1,7 @@ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", + "maxWorkers": 1, "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { diff --git a/donut-sync/test/sync.e2e-spec.ts b/donut-sync/test/sync.e2e-spec.ts index 30448ef..9f19247 100644 --- a/donut-sync/test/sync.e2e-spec.ts +++ b/donut-sync/test/sync.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { INestApplication } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; @@ -6,6 +8,11 @@ import { App } from "supertest/types"; import { AppController } from "./../src/app.controller.js"; import { AppService } from "./../src/app.service.js"; import { SyncModule } from "./../src/sync/sync.module.js"; +import { + configureTestEnv, + TEST_SYNC_TOKEN, + waitForTestS3, +} from "./test-env.js"; interface PresignResponse { url: string; @@ -29,26 +36,12 @@ interface StatResponse { lastModified?: string; } -interface SSEError { - code?: string; - timeout?: boolean; - response?: { status: number }; -} - -const TEST_TOKEN = "test-sync-token"; - describe("SyncController (e2e)", () => { let app: INestApplication; beforeAll(async () => { - process.env.SYNC_TOKEN = TEST_TOKEN; - process.env.S3_ENDPOINT = - process.env.S3_ENDPOINT || "http://localhost:8987"; - process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "minioadmin"; - process.env.S3_SECRET_ACCESS_KEY = - process.env.S3_SECRET_ACCESS_KEY || "minioadmin"; - process.env.S3_BUCKET = "donut-sync-test"; - process.env.S3_FORCE_PATH_STYLE = "true"; + configureTestEnv(); + await waitForTestS3(); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ @@ -62,7 +55,7 @@ describe("SyncController (e2e)", () => { }).compile(); app = moduleFixture.createNestApplication(); - await app.init(); + await app.listen(0); }); afterAll(async () => { @@ -88,7 +81,7 @@ describe("SyncController (e2e)", () => { it("should accept requests with valid token", () => { return request(app.getHttpServer()) .post("/v1/objects/stat") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: "nonexistent-key" }) .expect(200) .expect({ exists: false }); @@ -99,7 +92,7 @@ describe("SyncController (e2e)", () => { it("should return exists: false for non-existent key", () => { return request(app.getHttpServer()) .post("/v1/objects/stat") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: "does-not-exist" }) .expect(200) .expect({ exists: false }); @@ -110,7 +103,7 @@ describe("SyncController (e2e)", () => { it("should return a presigned upload URL", async () => { const response = await request(app.getHttpServer()) .post("/v1/objects/presign-upload") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: "test/upload-key.txt", contentType: "text/plain" }) .expect(200); @@ -125,7 +118,7 @@ describe("SyncController (e2e)", () => { it("should return a presigned download URL", async () => { const response = await request(app.getHttpServer()) .post("/v1/objects/presign-download") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: "test/download-key.txt" }) .expect(200); @@ -140,7 +133,7 @@ describe("SyncController (e2e)", () => { it("should list objects with prefix", async () => { const response = await request(app.getHttpServer()) .post("/v1/objects/list") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ prefix: "profiles/" }) .expect(200); @@ -155,7 +148,7 @@ describe("SyncController (e2e)", () => { it("should delete object and create tombstone", async () => { const response = await request(app.getHttpServer()) .post("/v1/objects/delete") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: "test/to-delete.txt", tombstoneKey: "tombstones/test/to-delete.json", @@ -176,7 +169,7 @@ describe("SyncController (e2e)", () => { it("should complete full upload/download cycle with presigned URLs", async () => { const uploadResponse = await request(app.getHttpServer()) .post("/v1/objects/presign-upload") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: testKey, contentType: "text/plain" }) .expect(200); @@ -192,7 +185,7 @@ describe("SyncController (e2e)", () => { const statResponse = await request(app.getHttpServer()) .post("/v1/objects/stat") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: testKey }) .expect(200); @@ -202,7 +195,7 @@ describe("SyncController (e2e)", () => { const downloadResponse = await request(app.getHttpServer()) .post("/v1/objects/presign-download") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: testKey }) .expect(200); @@ -215,13 +208,13 @@ describe("SyncController (e2e)", () => { await request(app.getHttpServer()) .post("/v1/objects/delete") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: testKey }) .expect(200); const finalStatResponse = await request(app.getHttpServer()) .post("/v1/objects/stat") - .set("Authorization", `Bearer ${TEST_TOKEN}`) + .set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`) .send({ key: testKey }) .expect(200); @@ -238,20 +231,28 @@ describe("SyncController (e2e)", () => { }); it("should return SSE stream with valid token", async () => { - const response = await request(app.getHttpServer()) - .get("/v1/objects/subscribe") - .set("Authorization", `Bearer ${TEST_TOKEN}`) - .set("Accept", "text/event-stream") - .buffer(true) - .timeout(3000) - .catch((err: SSEError) => { - if (err.code === "ECONNABORTED" || err.timeout) { - return err.response ?? { status: 200 }; - } - throw err; - }); + const address = ( + app.getHttpServer() as Server + ).address() as AddressInfo | null; + if (!address || typeof address === "string") { + throw new Error("Expected app to be listening on a TCP port"); + } + + const response = await fetch( + `http://127.0.0.1:${address.port}/v1/objects/subscribe`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${TEST_SYNC_TOKEN}`, + }, + }, + ); expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "text/event-stream", + ); + await response.body?.cancel(); }); }); }); diff --git a/donut-sync/test/test-env.ts b/donut-sync/test/test-env.ts new file mode 100644 index 0000000..daffdcb --- /dev/null +++ b/donut-sync/test/test-env.ts @@ -0,0 +1,37 @@ +import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3"; + +export const TEST_SYNC_TOKEN = "test-sync-token"; +export const TEST_S3_ENDPOINT = "http://127.0.0.1:8987"; + +export function configureTestEnv() { + process.env.SYNC_TOKEN ||= TEST_SYNC_TOKEN; + process.env.S3_ENDPOINT ||= TEST_S3_ENDPOINT; + process.env.S3_ACCESS_KEY_ID ||= "minioadmin"; + process.env.S3_SECRET_ACCESS_KEY ||= "minioadmin"; + process.env.S3_BUCKET ||= "donut-sync-test"; + process.env.S3_FORCE_PATH_STYLE ||= "true"; +} + +export async function waitForTestS3(timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + const s3Client = new S3Client({ + endpoint: TEST_S3_ENDPOINT, + region: "us-east-1", + credentials: { + accessKeyId: "minioadmin", + secretAccessKey: "minioadmin", + }, + forcePathStyle: true, + }); + + while (Date.now() < deadline) { + try { + await s3Client.send(new ListBucketsCommand({})); + return; + } catch {} + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out waiting for S3 at ${TEST_S3_ENDPOINT}`); +} diff --git a/scripts/dev.sh b/scripts/dev.sh index 83b7140..bc44ec5 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -81,7 +81,7 @@ echo -e "${YELLOW}Waiting for MinIO to be healthy...${NC}" MAX_RETRIES=30 RETRY_COUNT=0 while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do - if curl -sf http://localhost:8987/minio/health/live > /dev/null 2>&1; then + if curl -sf http://127.0.0.1:8987/minio/health/live > /dev/null 2>&1; then echo -e "${GREEN}MinIO is ready!${NC}" break fi