import { INestApplication } from "@nestjs/common"; import { getRepositoryToken } from "@nestjs/typeorm"; import request from "supertest"; import * as fs from "fs/promises"; import { Repository } from "typeorm"; import { buildTestApp } from "./app.harness"; import { FileStorageService } from "../src/file/file-storage.service"; import { FileEntity } from "../src/file/file.entity"; import { ScenarioEntity } from "../src/scenario/scenario.entity"; import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity"; describe("File Controller Integration Tests", () => { let app: INestApplication; let fileStorageService: FileStorageService; let fileRepo: Repository; let scenarioRepo: Repository; let runRepo: Repository; let filesDir: string; beforeAll(async () => { app = await buildTestApp(); fileStorageService = app.get(FileStorageService); fileRepo = app.get>(getRepositoryToken(FileEntity)); scenarioRepo = app.get>( getRepositoryToken(ScenarioEntity), ); runRepo = app.get>( getRepositoryToken(ScenarioRunEntity), ); // Get the files directory from the service filesDir = fileStorageService.getAbsolutePath(""); }); afterAll(async () => { // Clean up files directory try { await fs.rm(filesDir, { recursive: true, force: true }); } catch (err) { // Ignore errors if directory doesn't exist } await app.close(); }); // ── helpers ──────────────────────────────────────────────────────────────── async function createScenario(name = "test-scenario") { const scenario = await scenarioRepo.save( scenarioRepo.create({ name }), ); return scenario; } async function createRun(scenarioId: string) { const run = await runRepo.save( runRepo.create({ scenarioId, status: "pending" }), ); return run; } // ── POST /scenarios/:id/files ────────────────────────────────────────────── describe("POST /scenarios/:id/files", () => { it("uploads a file and returns 201 with metadata", async () => { const scenario = await createScenario("upload-test"); const fileBuffer = Buffer.from("test file content"); const res = await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .attach("file", fileBuffer, "test.txt") .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.name).toBe("test.txt"); expect(res.body.mimeType).toBe("text/plain"); expect(res.body.size).toBe(fileBuffer.length); expect(res.body.createdAt).toBeDefined(); // Verify file exists on disk const savedFile = await fileRepo.findOneBy({ id: res.body.id }); expect(savedFile).toBeDefined(); const fullPath = fileStorageService.getAbsolutePath(savedFile!.filePath); await fs.access(fullPath); }); it("uploads a file with expiresAt and includes it in response", async () => { const scenario = await createScenario("upload-expires-test"); const fileBuffer = Buffer.from("expires file"); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const res = await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .attach("file", fileBuffer, "expires.txt") .field("expiresAt", expiresAt) .expect(201); expect(res.body.expiresAt).toBeDefined(); // Verify expiresAt is close to the one we sent const returnedExpires = new Date(res.body.expiresAt); const sentExpires = new Date(expiresAt); expect(Math.abs(returnedExpires.getTime() - sentExpires.getTime())).toBeLessThan(1000); }); it("returns 404 when scenario does not exist", async () => { const nonExistentId = "00000000-0000-0000-0000-000000000000"; const fileBuffer = Buffer.from("test"); await request(app.getHttpServer()) .post(`/scenarios/${nonExistentId}/files`) .attach("file", fileBuffer, "test.txt") .expect(404); }); it("returns 400 when no file is provided", async () => { const scenario = await createScenario("no-file-test"); // Send a request with no file const res = await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .send({}); // Should either be 400 or 422 depending on validation expect([400, 422]).toContain(res.status); }); }); // ── GET /scenarios/:id/files ─────────────────────────────────────────────── describe("GET /scenarios/:id/files", () => { it("lists files attached to a scenario", async () => { const scenario = await createScenario("list-test"); const fileBuffer = Buffer.from("list test file"); // Upload a file await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .attach("file", fileBuffer, "listed.txt") .expect(201); const res = await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/files`) .expect(200); expect(res.body.items).toBeDefined(); expect(Array.isArray(res.body.items)).toBe(true); expect(res.body.items.length).toBeGreaterThanOrEqual(1); expect(res.body.total).toBeGreaterThanOrEqual(1); const file = res.body.items.find((f: any) => f.name === "listed.txt"); expect(file).toBeDefined(); expect(file.id).toBeDefined(); expect(file.mimeType).toBe("text/plain"); expect(file.size).toBe(fileBuffer.length); }); it("respects limit and offset pagination", async () => { const scenario = await createScenario("pagination-test"); // Upload 3 files for (let i = 1; i <= 3; i++) { await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .attach("file", Buffer.from(`file ${i}`), `file${i}.txt`) .expect(201); } // List with limit=2, offset=0 const page1 = await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/files`) .query({ limit: 2, offset: 0 }) .expect(200); expect(page1.body.items).toHaveLength(2); expect(page1.body.total).toBe(3); // List with limit=2, offset=2 const page2 = await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/files`) .query({ limit: 2, offset: 2 }) .expect(200); expect(page2.body.items.length).toBeGreaterThanOrEqual(1); }); it("returns 404 when scenario does not exist", async () => { const nonExistentId = "00000000-0000-0000-0000-000000000000"; await request(app.getHttpServer()) .get(`/scenarios/${nonExistentId}/files`) .expect(404); }); }); // ── GET /scenarios/:id/files/:fileId/content ─────────────────────────────── describe("GET /scenarios/:id/files/:fileId/content", () => { it("downloads file content with correct bytes and Content-Type header", async () => { const scenario = await createScenario("download-test"); const fileBuffer = Buffer.from("download test content"); // Upload the file const uploadRes = await request(app.getHttpServer()) .post(`/scenarios/${scenario.id}/files`) .attach("file", fileBuffer, "download.txt") .expect(201); const fileId = uploadRes.body.id; // Download content const res = await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/files/${fileId}/content`) .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => callback(null, Buffer.concat(chunks))); }) .expect(200); // The response body should contain the file bytes expect((res.body as Buffer).toString()).toBe(fileBuffer.toString()); expect(res.headers["content-type"]).toContain("text/plain"); }); it("returns 404 when file does not exist", async () => { const scenario = await createScenario("not-found-test"); const nonExistentFileId = "00000000-0000-0000-0000-000000000000"; await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/files/${nonExistentFileId}/content`) .expect(404); }); it("returns 404 when file is not linked to scenario", async () => { const scenario1 = await createScenario("scenario-1"); const scenario2 = await createScenario("scenario-2"); // Upload file to scenario1 const uploadRes = await request(app.getHttpServer()) .post(`/scenarios/${scenario1.id}/files`) .attach("file", Buffer.from("content"), "file.txt") .expect(201); const fileId = uploadRes.body.id; // Try to access from scenario2 await request(app.getHttpServer()) .get(`/scenarios/${scenario2.id}/files/${fileId}/content`) .expect(404); }); }); // ── GET /scenarios/:id/runs/:runId/files ─────────────────────────────────── describe("GET /scenarios/:id/runs/:runId/files", () => { it("lists files created during a run", async () => { const scenario = await createScenario("run-files-list-test"); const run = await createRun(scenario.id); // Create a run artifact file const fileBuffer = Buffer.from("run artifact content"); const savedFile = await fileStorageService.createAndSaveRunArtifact( run.id, fileBuffer, "artifact.txt", "text/plain", ); const res = await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/runs/${run.id}/files`) .expect(200); expect(res.body.items).toBeDefined(); expect(Array.isArray(res.body.items)).toBe(true); expect(res.body.total).toBeGreaterThanOrEqual(1); const artifact = res.body.items.find((f: any) => f.id === savedFile.id); expect(artifact).toBeDefined(); expect(artifact.name).toBe("artifact.txt"); expect(artifact.size).toBe(fileBuffer.length); }); it("returns 404 when run does not exist", async () => { const scenario = await createScenario("run-not-found-test"); const nonExistentRunId = "00000000-0000-0000-0000-000000000000"; await request(app.getHttpServer()) .get(`/scenarios/${scenario.id}/runs/${nonExistentRunId}/files`) .expect(404); }); }); // ── GET /scenarios/:id/runs/:runId/files/:fileId/content ────────────────── describe("GET /scenarios/:id/runs/:runId/files/:fileId/content", () => { it("downloads run artifact content with correct bytes", async () => { const scenario = await createScenario("run-artifact-download-test"); const run = await createRun(scenario.id); // Create a run artifact const fileBuffer = Buffer.from("run artifact bytes"); const savedFile = await fileStorageService.createAndSaveRunArtifact( run.id, fileBuffer, "run-artifact.bin", "application/octet-stream", ); const res = await request(app.getHttpServer()) .get( `/scenarios/${scenario.id}/runs/${run.id}/files/${savedFile.id}/content`, ) .buffer(true) .parse((res, callback) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => callback(null, Buffer.concat(chunks))); }) .expect(200); expect((res.body as Buffer).toString()).toBe(fileBuffer.toString()); expect(res.headers["content-type"]).toContain("application/octet-stream"); }); it("returns 404 when artifact file does not exist", async () => { const scenario = await createScenario("artifact-not-found"); const run = await createRun(scenario.id); const nonExistentFileId = "00000000-0000-0000-0000-000000000000"; await request(app.getHttpServer()) .get( `/scenarios/${scenario.id}/runs/${run.id}/files/${nonExistentFileId}/content`, ) .expect(404); }); }); // ── File cleanup ─────────────────────────────────────────────────────────── describe("File cleanup", () => { it("removes expired files from disk and database", async () => { const scenario = await createScenario("cleanup-test"); // Upload a file with a past expiration date const fileBuffer = Buffer.from("to be cleaned up"); const savedFile = await fileStorageService.saveFile( fileBuffer, "cleanup.txt", "text/plain", new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago ); // Verify file exists const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath); await fs.access(fullPath); // Run cleanup await fileStorageService.cleanupExpiredFiles(); // Verify file is deleted from database const deleted = await fileRepo.findOneBy({ id: savedFile.id }); expect(deleted).toBeNull(); // Verify file is deleted from disk try { await fs.access(fullPath); fail("File should have been deleted"); } catch (err) { // Expected: file not found expect((err as any).code).toBe("ENOENT"); } }); it("does not delete files with future expiration dates", async () => { const scenario = await createScenario("no-cleanup-test"); // Upload a file with a future expiration date const fileBuffer = Buffer.from("should not be cleaned"); const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); const savedFile = await fileStorageService.saveFile( fileBuffer, "keep.txt", "text/plain", futureDate, ); // Run cleanup await fileStorageService.cleanupExpiredFiles(); // Verify file still exists in database const kept = await fileRepo.findOneBy({ id: savedFile.id }); expect(kept).toBeDefined(); // Verify file still exists on disk const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath); await fs.access(fullPath); }); it("does not delete files without expiration dates", async () => { const scenario = await createScenario("no-expire-test"); // Upload a file without expiration const fileBuffer = Buffer.from("permanent file"); const savedFile = await fileStorageService.saveFile( fileBuffer, "permanent.txt", "text/plain", ); // Run cleanup await fileStorageService.cleanupExpiredFiles(); // Verify file still exists const kept = await fileRepo.findOneBy({ id: savedFile.id }); expect(kept).toBeDefined(); const fullPath = fileStorageService.getAbsolutePath(savedFile.filePath); await fs.access(fullPath); }); }); });