test/unit/api/v2/projects/projectfiles/projectfiles.controller.spec.tsmodified
import 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);
    });
  });
});