feat(files): add file management UI for scenarios and runs
- add scenario upload and download UI with typed file API helpers - show run artifacts on run detail pages after polling completes - handle multipart uploads and absolute file paths for downloads
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
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<FileEntity>;
|
||||
let scenarioRepo: Repository<ScenarioEntity>;
|
||||
let runRepo: Repository<ScenarioRunEntity>;
|
||||
let filesDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp();
|
||||
fileStorageService = app.get(FileStorageService);
|
||||
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
|
||||
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||
getRepositoryToken(ScenarioEntity),
|
||||
);
|
||||
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user