test/unit/api/v2/projects/projectfiles/projectfiles.controller.spec.tsmodifiedimport sinon from "sinon";
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import sinonChai from "sinon-chai";
import fs from "node:fs";
import * as service from "../../../../../../src/services/projectfiles.service";
import * as blobService from "../../../../../../src/services/azure.blob.service";
import * as controller from "../../../../../../src/api/v2/projects/projectfiles/projectfiles.controller";
import * as azureUtil from "../../../../../../src/util/azure.util";
import * as usernameUtil from "../../../../../../src/util/username";
import {
mockRequest,
mockResponse,
RequestOutput,
ResponseOutput,
} from "mock-req-res";
import { NotFoundError } from "../../../../../../src/types/errortypes";
const expect = chai.expect;
chai.use(chaiAsPromised).use(sinonChai);
describe("ProjectFiles Controller unit tests", () => {
const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
const fileReferenceId = "8b141d85-3305-4bdd-b802-e83639c260e8";
const mockFilePath = "/tmp/upload-abc";
let req: RequestOutput;
let res: ResponseOutput;
before(() => {
fs.writeFileSync(mockFilePath, "test file content");
});
after(() => {
fs.rmSync(mockFilePath, { force: true });
});
beforeEach(() => {
res = mockResponse();
});
afterEach(() => {
sinon.restore();
});
describe("getFile", () => {
const dbRow = {
FileReferenceId: fileReferenceId,
FileName: "report.pdf",
FileExtension: "pdf",
XyzDisplayName: "Project Report",
FileSizeBytes: 204800,
Status: "Ready",
UploadedBy: "alice@example.com",
InsertedOn: "2025-03-01T10:00:00Z",
LastModifiedBy: null,
LastModifiedOn: null,
CloudStoragePath: "container/RawData/files/ProjectId=abc/def",
};
it("should return 200 with file details and a signed download URL", async () => {
req = mockRequest({ params: { projectId, fileReferenceId } });
sinon.stub(service, "getProjectFile").resolves(dbRow);
sinon
.stub(azureUtil, "generateTokenisedBlobDownloadUrl")
.resolves("https://example.com/signed");
await controller.getFile(req, res);
expect(res.status).to.have.been.calledWith(200);
expect(res.json).to.have.been.calledWith(
sinon.match({
fileReferenceId,
fileName: "report.pdf",
fileExtension: "pdf",
xyzDisplayName: "Project Report",
fileSizeBytes: 204800,
downloadUrl: "https://example.com/signed",
}),
);
});
it("should return 404 when file is not found", async () => {
req = mockRequest({ params: { projectId, fileReferenceId } });
sinon
.stub(service, "getProjectFile")
.rejects(
new NotFoundError(
`File with id '${fileReferenceId}' not found in project '${projectId}'.`,
),
);
await controller.getFile(req, res);
expect(res.status).to.have.been.calledWith(404);
});
it("should return 500 on unexpected error", async () => {
req = mockRequest({ params: { projectId, fileReferenceId } });
sinon.stub(service, "getProjectFile").rejects(new Error("DB failure"));
await controller.getFile(req, res);
expect(res.status).to.have.been.calledWith(500);
});
});
describe("getBiggestFile", () => {
const dbRow = {
FileReferenceId: fileReferenceId,
FileName: "huge.zip",
FileExtension: "zip",
XyzDisplayName: "Huge File",
FileSizeBytes: 9999999,
FileHash: "abc123hash",
CreatedBy: "alice@example.com",
InsertedOn: "2025-03-01T10:00:00Z",
LastModifiedBy: null,
LastModifiedOn: null,
};
it("should return 200 with the biggest file's metadata", async () => {
req = mockRequest({ params: { projectId } });
sinon.stub(service, "getBiggestProjectFile").resolves(dbRow);
await controller.getBiggestFile(req, res);
expect(res.status).to.have.been.calledWith(200);
expect(res.json).to.have.been.calledWith(
sinon.match({
fileReferenceId,
fileName: "huge.zip",
fileExtension: "zip",
xyzDisplayName: "Huge File",
fileSizeBytes: 9999999,
fileHash: "abc123hash",
}),
);
});
it("should return 200 with a null body when the project has no files", async () => {
req = mockRequest({ params: { projectId } });
sinon.stub(service, "getBiggestProjectFile").resolves(null);
await controller.getBiggestFile(req, res);
expect(res.status).to.have.been.calledWith(200);
expect(res.json).to.have.been.calledWith(null);
});
it("should return 404 when the project is not found", async () => {
req = mockRequest({ params: { projectId } });
sinon
.stub(service, "getBiggestProjectFile")
.rejects(
new NotFoundError(`Project with ProjectId: ${projectId} not found.`),
);
await controller.getBiggestFile(req, res);
expect(res.status).to.have.been.calledWith(404);
});
it("should return 500 on unexpected error", async () => {
req = mockRequest({ params: { projectId } });
sinon
.stub(service, "getBiggestProjectFile")
.rejects(new Error("DB failure"));
await controller.getBiggestFile(req, res);
expect(res.status).to.have.been.calledWith(500);
});
});
describe("uploadFile", () => {
const mockFile = {
path: "/tmp/upload-abc",
size: 1024,
mimetype: "image/png",
} as Express.Multer.File;
it("should return 201 with fileReferenceId on single-chunk upload", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "photo.png",
xyzDisplayName: "Site Photo",
chunkIndex: "0",
totalChunks: "1",
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
const accumulateStub = sinon.stub(service, "accumulateFileHash").resolves();
sinon.stub(service, "popFileHash").resolves("abc123hash");
sinon.stub(service, "createProjectFile").resolves();
sinon.stub(service, "updateProjectFileSize").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/RawData/files/ProjectId=abc/uuid");
await controller.uploadFile(req, res);
expect(res.status).to.have.been.calledWith(201);
expect(res.json).to.have.been.calledWith(
sinon.match({
message: "Project file uploaded successfully",
}),
);
expect((res.json as sinon.SinonSpy).firstCall.args[0]).to.have.property(
"fileReferenceId",
);
expect(accumulateStub).to.have.been.calledWith(
sinon.match.string,
sinon.match.string,
true,
);
});
it("should return 200 with fileReferenceId on intermediate chunk", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "video.mp4",
xyzDisplayName: "Walkthrough",
chunkIndex: "1",
totalChunks: "3",
fileReferenceId,
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
sinon.stub(service, "accumulateFileHash").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(res.status).to.have.been.calledWith(200);
expect(res.json).to.have.been.calledWith(
sinon.match({ message: "Project file chunk uploaded successfully" }),
);
});
it("should create DB record only on first chunk", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "doc.pdf",
xyzDisplayName: "Doc",
chunkIndex: "0",
totalChunks: "2",
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
sinon.stub(service, "accumulateFileHash").resolves();
const createStub = sinon.stub(service, "createProjectFile").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(createStub).to.have.been.calledOnce;
});
it("should not create DB record on non-first chunks", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "doc.pdf",
xyzDisplayName: "Doc",
chunkIndex: "1",
totalChunks: "2",
fileReferenceId,
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
sinon.stub(service, "accumulateFileHash").resolves();
sinon.stub(service, "popFileHash").resolves("abc123hash");
sinon.stub(service, "updateProjectFileSize").resolves();
const createStub = sinon.stub(service, "createProjectFile").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(createStub).not.to.have.been.called;
});
it("should pass isFirstChunk=false for subsequent chunks and complete successfully when prior hash is missing", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "doc.pdf",
xyzDisplayName: "Doc",
chunkIndex: "1",
totalChunks: "3",
fileReferenceId,
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
const accumulateStub = sinon.stub(service, "accumulateFileHash").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(accumulateStub).to.have.been.calledWith(
fileReferenceId,
sinon.match.string,
false,
);
expect(res.status).to.have.been.calledWith(200);
});
it("should store null fileHash when prior chunk hash was missing (final chunk)", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "doc.pdf",
xyzDisplayName: "Doc",
chunkIndex: "1",
totalChunks: "2",
fileReferenceId,
},
file: mockFile,
});
sinon.stub(blobService, "uploadChunk").resolves();
sinon.stub(service, "accumulateFileHash").resolves();
// popFileHash returns null — simulates HASH_SKIPPED sentinel scenario
sinon.stub(service, "popFileHash").resolves(null);
const updateStub = sinon.stub(service, "updateProjectFileSize").resolves();
sinon.stub(service, "createProjectFile").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(res.status).to.have.been.calledWith(201);
expect(updateStub).to.have.been.calledWith(
projectId,
fileReferenceId,
sinon.match.number,
null,
);
});
it("should return 400 when no file is provided", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "photo.png",
xyzDisplayName: "Photo",
chunkIndex: "0",
totalChunks: "1",
},
});
await controller.uploadFile(req, res);
expect(res.status).to.have.been.calledWith(400);
});
it("should return 500 on blob upload failure", async () => {
req = mockRequest({
params: { projectId },
body: {
fileName: "photo.png",
xyzDisplayName: "Photo",
chunkIndex: "0",
totalChunks: "1",
},
file: mockFile,
});
sinon
.stub(blobService, "uploadChunk")
.rejects(new Error("Azure storage unavailable"));
sinon.stub(service, "accumulateFileHash").resolves();
sinon
.stub(usernameUtil, "getLoggedInUsername")
.returns("bob@example.com");
sinon
.stub(azureUtil, "getFullyQualifiedBlobStoragePath")
.returns("container/path");
await controller.uploadFile(req, res);
expect(res.status).to.have.been.calledWith(500);
});
});
});