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:
2026-04-21 15:58:04 +03:00
parent 6a91ce30e3
commit 86f9ac5603
20 changed files with 1237 additions and 96 deletions
+3 -2
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import * as path from "path";
import { Like, Repository } from "typeorm";
import {
PaginatedResult,
@@ -755,7 +756,7 @@ export class ScenarioService {
}
const file = link.file;
const fullPath = this.fileStorageService.getAbsolutePath(file.filePath);
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
res.setHeader("Content-Type", file.mimeType);
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
@@ -818,7 +819,7 @@ export class ScenarioService {
}
const file = link.file;
const fullPath = this.fileStorageService.getAbsolutePath(file.filePath);
const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath));
res.setHeader("Content-Type", file.mimeType);
res.setHeader("Content-Disposition", `inline; filename="${file.originalName}"`);
+419
View File
@@ -0,0 +1,419 @@
import { INestApplication } from "@nestjs/common";
import { getRepositoryToken } from "@nestjs/typeorm";
import * as http from "http";
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 { ScenarioFileEntity } from "../src/file/scenario-file.entity";
import { ScenarioRunFileEntity } from "../src/file/scenario-run-file.entity";
import { ScenarioEntity } from "../src/scenario/scenario.entity";
import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service";
describe("Exec Context File Methods Integration Tests", () => {
let app: INestApplication;
let fileStorageService: FileStorageService;
let scheduler: ScenarioSchedulerService;
let fileRepo: Repository<FileEntity>;
let scenarioFileRepo: Repository<ScenarioFileEntity>;
let scenarioRunFileRepo: Repository<ScenarioRunFileEntity>;
let scenarioRepo: Repository<ScenarioEntity>;
let stepRepo: Repository<ScenarioStepEntity>;
let runRepo: Repository<ScenarioRunEntity>;
let runStepRepo: Repository<ScenarioRunStepEntity>;
let filesDir: string;
let testServer: http.Server;
let testServerUrl: string;
beforeAll(async () => {
app = await buildTestApp();
fileStorageService = app.get(FileStorageService);
scheduler = app.get(ScenarioSchedulerService);
fileRepo = app.get<Repository<FileEntity>>(getRepositoryToken(FileEntity));
scenarioFileRepo = app.get<Repository<ScenarioFileEntity>>(
getRepositoryToken(ScenarioFileEntity),
);
scenarioRunFileRepo = app.get<Repository<ScenarioRunFileEntity>>(
getRepositoryToken(ScenarioRunFileEntity),
);
scenarioRepo = app.get<Repository<ScenarioEntity>>(
getRepositoryToken(ScenarioEntity),
);
stepRepo = app.get<Repository<ScenarioStepEntity>>(
getRepositoryToken(ScenarioStepEntity),
);
runRepo = app.get<Repository<ScenarioRunEntity>>(
getRepositoryToken(ScenarioRunEntity),
);
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
getRepositoryToken(ScenarioRunStepEntity),
);
filesDir = fileStorageService.getAbsolutePath("");
// Start a test HTTP server for downloadFile tests
testServer = await createTestServer();
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
});
afterAll(async () => {
// Close the test server
if (testServer) {
testServer.close();
}
// 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 ────────────────────────────────────────────────────────────────
function createTestServer(): Promise<http.Server> {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
if (req.url === "/test-file.bin") {
res.writeHead(200, { "Content-Type": "application/octet-stream" });
res.end(Buffer.from("test downloaded file content"));
} else if (req.url === "/test.pdf") {
res.writeHead(200, { "Content-Type": "application/pdf" });
res.end(Buffer.from("fake pdf content"));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(0, "localhost", () => {
resolve(server);
});
});
}
async function createScenario(name = "test-scenario") {
const scenario = await scenarioRepo.save(
scenarioRepo.create({ name }),
);
return scenario;
}
async function createStep(
scenarioId: string,
execCode: string,
order = 0,
): Promise<ScenarioStepEntity> {
const step = await stepRepo.save(
stepRepo.create({
scenarioId,
order,
execCode,
}),
);
return step;
}
async function createRun(scenarioId: string) {
const run = await runRepo.save(
runRepo.create({ scenarioId, status: "pending" }),
);
return run;
}
async function createRunStep(
runId: string,
scenarioStepId: string,
order = 0,
) {
const runStep = await runStepRepo.save(
runStepRepo.create({
runId,
scenarioStepId,
order,
status: "pending",
}),
);
return runStep;
}
async function waitForRunCompletion(
runId: string,
maxAttempts = 50,
): Promise<ScenarioRunEntity> {
for (let i = 0; i < maxAttempts; i++) {
// Run scheduler pickup once
await scheduler.pickUpPendingRuns();
// Wait a bit for execution
await new Promise((resolve) => setTimeout(resolve, 100));
const run = await runRepo.findOneBy({ id: runId });
if (run && (run.status === "pass" || run.status === "fail")) {
return run;
}
}
throw new Error(`Run ${runId} did not complete within timeout`);
}
// ── context.getScenarioFiles() ─────────────────────────────────────────────
describe("context.getScenarioFiles()", () => {
it("returns scenario files during a run", async () => {
const scenario = await createScenario("get-scenario-files-test");
// Upload a file to the scenario
const fileBuffer = Buffer.from("scenario file content");
const uploadedFile = await fileStorageService.saveFile(
fileBuffer,
"test-file.txt",
"text/plain",
);
// Create scenario file mapping
await scenarioFileRepo.save(
scenarioFileRepo.create({
scenarioId: scenario.id,
fileId: uploadedFile.id,
}),
);
// Create a step that calls getScenarioFiles
const step = await createStep(
scenario.id,
"const files = await context.getScenarioFiles(); return files;",
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
const completedRun = await waitForRunCompletion(run.id);
// Verify run completed successfully
expect(completedRun.status).toBe("pass");
// Get the step run to check output
const runStep = await runStepRepo.findOne({
where: { runId: run.id },
relations: ["scenarioStep"],
});
expect(runStep).toBeDefined();
expect(runStep!.output).toBeDefined();
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
expect(output).toBeDefined();
expect(output.items).toBeDefined();
expect(Array.isArray(output.items)).toBe(true);
expect(output.total).toBeGreaterThanOrEqual(1);
// Find the uploaded file in the output
const foundFile = output.items.find((f: any) => f.id === uploadedFile.id);
expect(foundFile).toBeDefined();
expect(foundFile.name).toBe("test-file.txt");
expect(foundFile.mimeType).toBe("text/plain");
expect(foundFile.size).toBe(fileBuffer.length);
expect(foundFile.sha256).toBeDefined();
expect(foundFile.path).toBeDefined();
});
it("returns empty list when no files are attached to scenario", async () => {
const scenario = await createScenario("no-files-test");
// Create a step that calls getScenarioFiles
const step = await createStep(
scenario.id,
"const files = await context.getScenarioFiles(); return files;",
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
const completedRun = await waitForRunCompletion(run.id);
expect(completedRun.status).toBe("pass");
const runStep = await runStepRepo.findOne({
where: { runId: run.id },
});
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
expect(output).toBeDefined();
expect(output.items).toEqual([]);
expect(output.total).toBe(0);
});
});
// ── context.downloadFile() ────────────────────────────────────────────────
describe("context.downloadFile()", () => {
it("downloads a file and creates a run artifact", async () => {
const scenario = await createScenario("download-file-test");
const downloadUrl = `${testServerUrl}/test.pdf`;
// Create a step that downloads a file
const step = await createStep(
scenario.id,
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
const completedRun = await waitForRunCompletion(run.id);
expect(completedRun.status).toBe("pass");
// Get the step run to check output
const runStep = await runStepRepo.findOne({
where: { runId: run.id },
relations: ["scenarioStep"],
});
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
expect(output).toBeDefined();
expect(output.id).toBeDefined();
expect(output.name).toBeDefined();
expect(output.mimeType).toBe("application/pdf");
expect(output.size).toBeGreaterThan(0);
expect(output.sha256).toBeDefined();
expect(output.path).toBeDefined();
// Verify the file was created as a run artifact
const runFileMapping = await scenarioRunFileRepo.findOne({
where: { runId: run.id, fileId: output.id },
});
expect(runFileMapping).toBeDefined();
// Verify the file exists on disk
const file = await fileRepo.findOneBy({ id: output.id });
expect(file).toBeDefined();
const fullPath = fileStorageService.getAbsolutePath(file!.filePath);
await fs.access(fullPath);
// Verify file content matches what was downloaded
const diskContent = await fs.readFile(fullPath);
expect(diskContent).toEqual(Buffer.from("fake pdf content"));
// Verify the file has an expiresAt date set
expect(file!.expiresAt).toBeDefined();
});
it("downloads file with custom filename", async () => {
const scenario = await createScenario("download-custom-filename-test");
const downloadUrl = `${testServerUrl}/test-file.bin`;
// Create a step that downloads a file with a custom filename
const step = await createStep(
scenario.id,
`const result = await context.downloadFile('${downloadUrl}', { filename: 'custom.dat' }); return result;`,
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
const completedRun = await waitForRunCompletion(run.id);
expect(completedRun.status).toBe("pass");
const runStep = await runStepRepo.findOne({
where: { runId: run.id },
});
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
expect(output.name).toBe("custom.dat");
});
it("creates ScenarioRunFileEntity mapping after download", async () => {
const scenario = await createScenario("download-mapping-test");
const downloadUrl = `${testServerUrl}/test.pdf`;
// Create a step
const step = await createStep(
scenario.id,
`const result = await context.downloadFile('${downloadUrl}'); return result;`,
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
await waitForRunCompletion(run.id);
// Verify that a ScenarioRunFileEntity was created
const runFileCount = await scenarioRunFileRepo.count({
where: { runId: run.id },
});
expect(runFileCount).toBeGreaterThanOrEqual(1);
// Verify the mapping links to the correct run
const mappings = await scenarioRunFileRepo.find({
where: { runId: run.id },
});
expect(mappings.length).toBeGreaterThanOrEqual(1);
for (const mapping of mappings) {
expect(mapping.runId).toBe(run.id);
expect(mapping.fileId).toBeDefined();
}
});
it("file returned by downloadFile has correct metadata", async () => {
const scenario = await createScenario("download-metadata-test");
const downloadUrl = `${testServerUrl}/test.pdf`;
// Create a step
const step = await createStep(
scenario.id,
`const result = await context.downloadFile('${downloadUrl}');
return {
hasId: result.id !== undefined,
hasName: result.name !== undefined,
hasMimeType: result.mimeType !== undefined,
hasSize: result.size > 0,
hasSha256: result.sha256 !== undefined,
hasPath: result.path !== undefined
};`,
);
// Create a run and run step
const run = await createRun(scenario.id);
await createRunStep(run.id, step.id);
// Wait for execution
const completedRun = await waitForRunCompletion(run.id);
expect(completedRun.status).toBe("pass");
const runStep = await runStepRepo.findOne({
where: { runId: run.id },
});
const output = runStep!.output ? JSON.parse(runStep!.output) : null;
expect(output).toBeDefined();
expect(output.hasId).toBe(true);
expect(output.hasName).toBe(true);
expect(output.hasMimeType).toBe(true);
expect(output.hasSize).toBe(true);
expect(output.hasSha256).toBe(true);
expect(output.hasPath).toBe(true);
});
});
});
+421
View File
@@ -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);
});
});
});