test/unit/services/projectfiles.service.spec.tsmodified
import sinon from "sinon";
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import sinonChai from "sinon-chai";
import * as db from "../../../src/db/db";
import * as cacheUtils from "../../../src/api/cache/cache.utils";
import * as redisClient from "../../../src/clients/redis.client";
import * as service from "../../../src/services/projectfiles.service";
import { NotFoundError, DatabaseError } from "../../../src/types/errortypes";

const expect = chai.expect;
chai.use(chaiAsPromised).use(sinonChai);

describe("ProjectFiles Service unit tests", () => {
  const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
  const fileReferenceId = "8b141d85-3305-4bdd-b802-e83639c260e8";

  let queryStub: sinon.SinonStub;

  beforeEach(() => {
    queryStub = sinon.stub(db, "query");
  });

  afterEach(() => {
    sinon.restore();
  });

  describe("getProjectFile", () => {
    it("should return file details when found", async () => {
      const row = {
        FileReferenceId: fileReferenceId,
        FileName: "test.png",
        FileExtension: "png",
        XyzDisplayName: "Test File",
        FileSizeBytes: 1024,
        Status: "Ready",
        CreatedBy: "user@example.com",
        InsertedOn: "2025-01-01T00:00:00Z",
        LastModifiedBy: null,
        LastModifiedOn: null,
        CloudStoragePath: "container/RawData/files/ProjectId=abc/def",
      };
      queryStub.resolves({ rows: [row] });

      const result = await service.getProjectFile(projectId, fileReferenceId);

      expect(result).to.deep.equal(row);
      expect(queryStub).to.have.been.calledOnce;
      expect(queryStub.firstCall.args[0]).to.include("fn_GetProjectFile");
      expect(queryStub.firstCall.args[1]).to.deep.equal([
        projectId,
        fileReferenceId,
      ]);
    });

    it("should throw NotFoundError when file does not exist", async () => {
      queryStub.resolves({ rows: [] });

      await expect(
        service.getProjectFile(projectId, fileReferenceId),
      ).to.be.rejectedWith(
        NotFoundError,
        `File with id '${fileReferenceId}' not found in project '${projectId}'.`,
      );
    });

    it("should throw DatabaseError on unexpected DB failure", async () => {
      queryStub.rejects(new Error("Connection timeout"));

      await expect(
        service.getProjectFile(projectId, fileReferenceId),
      ).to.be.rejectedWith(DatabaseError);
    });
  });

  describe("getBiggestProjectFile", () => {
    it("should return the biggest file row when one exists", async () => {
      const row = {
        FileReferenceId: fileReferenceId,
        FileName: "huge.zip",
        FileExtension: "zip",
        XyzDisplayName: "Huge File",
        FileSizeBytes: 9999999,
        FileHash: "abc123hash",
        CreatedBy: "user@example.com",
        InsertedOn: "2025-01-01T00:00:00Z",
        LastModifiedBy: null,
        LastModifiedOn: null,
      };
      queryStub.resolves({ rows: [row] });

      const result = await service.getBiggestProjectFile(projectId);

      expect(result).to.deep.equal(row);
      expect(queryStub).to.have.been.calledOnce;
      expect(queryStub.firstCall.args[0]).to.include("fn_GetBiggestFile");
      expect(queryStub.firstCall.args[1]).to.deep.equal([projectId]);
    });

    it("should return null when the project has no files", async () => {
      queryStub.resolves({ rows: [] });

      const result = await service.getBiggestProjectFile(projectId);

      expect(result).to.be.null;
    });

    it("should throw NotFoundError when the project does not exist", async () => {
      queryStub.rejects(new Error("No project found for ProjectId"));

      await expect(
        service.getBiggestProjectFile(projectId),
      ).to.be.rejectedWith(
        NotFoundError,
        `Project with ProjectId: ${projectId} not found.`,
      );
    });

    it("should throw DatabaseError on unexpected DB failure", async () => {
      queryStub.rejects(new Error("Connection timeout"));

      await expect(
        service.getBiggestProjectFile(projectId),
      ).to.be.rejectedWith(DatabaseError);
    });
  });

  describe("createProjectFile", () => {
    it("should call usp_InsertProjectFile with correct parameters", async () => {
      queryStub.resolves({ rows: [] });

      await service.createProjectFile(
        projectId,
        fileReferenceId,
        "test.png",
        "png",
        "container/RawData/files/ProjectId=abc/def",
        "user@example.com",
        "Test File",
      );

      expect(queryStub).to.have.been.calledOnce;
      expect(queryStub.firstCall.args[0]).to.include("usp_InsertProjectFile");
      expect(queryStub.firstCall.args[1]).to.deep.equal([
        projectId,
        fileReferenceId,
        "test.png",
        "png",
        "container/RawData/files/ProjectId=abc/def",
        "user@example.com",
        "Test File",
      ]);
    });

    it("should throw NotFoundError when project does not exist", async () => {
      queryStub.rejects(new Error("No project found for ProjectId"));

      await expect(
        service.createProjectFile(
          projectId,
          fileReferenceId,
          "f.png",
          "png",
          "path",
          "user",
          "Name",
        ),
      ).to.be.rejectedWith(
        NotFoundError,
        `Project with ProjectId: ${projectId} not found.`,
      );
    });

    it("should throw DatabaseError on unexpected DB failure", async () => {
      queryStub.rejects(new Error("DB error"));

      await expect(
        service.createProjectFile(
          projectId,
          fileReferenceId,
          "f.png",
          "png",
          "path",
          "user",
          "Name",
        ),
      ).to.be.rejectedWith(DatabaseError);
    });
  });

  describe("updateProjectFileStatus", () => {
    it("should call usp_UpdateProjectFileStatus with correct parameters", async () => {
      queryStub.resolves({ rows: [] });

      await service.updateProjectFileSize(
        projectId,
        fileReferenceId,
        2048,
        "abc123hash",
      );

      expect(queryStub).to.have.been.calledOnce;
      expect(queryStub.firstCall.args[0]).to.include(
        "usp_UpdateProjectFileSizeBytes",
      );
      expect(queryStub.firstCall.args[1]).to.deep.equal([
        projectId,
        fileReferenceId,
        2048,
        "abc123hash",
      ]);
    });

    it("should throw NotFoundError when project does not exist", async () => {
      queryStub.rejects(new Error("No project found for ProjectId"));

      await expect(
        service.updateProjectFileSize(projectId, fileReferenceId, 0, null),
      ).to.be.rejectedWith(NotFoundError);
    });

    it("should throw DatabaseError on unexpected DB failure", async () => {
      queryStub.rejects(new Error("Network error"));

      await expect(
        service.updateProjectFileSize(projectId, fileReferenceId, 0, null),
      ).to.be.rejectedWith(DatabaseError);
    });
  });

  describe("accumulateFileHash", () => {
    let getCacheStub: sinon.SinonStub;
    let setCacheStub: sinon.SinonStub;

    beforeEach(() => {
      getCacheStub = sinon.stub(cacheUtils, "getCachedItem");
      setCacheStub = sinon.stub(cacheUtils, "setCacheItem").resolves();
    });

    it("should store chunkHash directly for the first chunk", async () => {
      await service.accumulateFileHash(fileReferenceId, "hash1", true);

      expect(setCacheStub).to.have.been.calledOnce;
      expect(setCacheStub.firstCall.args[1]).to.equal("hash1");
    });

    it("should combine existing hash with chunkHash for subsequent chunks", async () => {
      getCacheStub.resolves("existinghash");

      await service.accumulateFileHash(fileReferenceId, "hash2", false);

      expect(setCacheStub).to.have.been.calledOnce;
      const storedValue = setCacheStub.firstCall.args[1] as string;
      expect(storedValue).to.be.a("string").with.length(64); // SHA-256 hex
      expect(storedValue).to.not.equal("existinghash");
      expect(storedValue).to.not.equal("hash2");
    });

    it("should store HASH_SKIPPED sentinel when subsequent chunk finds no prior hash", async () => {
      getCacheStub.resolves(undefined);

      await service.accumulateFileHash(fileReferenceId, "hash2", false);

      expect(setCacheStub).to.have.been.calledOnce;
      expect(setCacheStub.firstCall.args[1]).to.equal("HASH_SKIPPED");
    });

    it("should keep HASH_SKIPPED sentinel when a later chunk finds it already set", async () => {
      getCacheStub.resolves("HASH_SKIPPED");

      await service.accumulateFileHash(fileReferenceId, "hash3", false);

      expect(setCacheStub).to.have.been.calledOnce;
      expect(setCacheStub.firstCall.args[1]).to.equal("HASH_SKIPPED");
    });
  });

  describe("popFileHash", () => {
    let getCacheStub: sinon.SinonStub;
    let isRedisStub: sinon.SinonStub;
    let getRedisStub: sinon.SinonStub;
    let delStub: sinon.SinonStub;

    beforeEach(() => {
      getCacheStub = sinon.stub(cacheUtils, "getCachedItem");
      isRedisStub = sinon.stub(redisClient, "isRedisAvailable");
      getRedisStub = sinon.stub(redisClient, "getRedisClient");
      delStub = sinon.stub().resolves();
    });

    it("should return hash and delete cache key when Redis is available", async () => {
      getCacheStub.resolves("abc123hash");
      isRedisStub.returns(true);
      getRedisStub.returns({ del: delStub });

      const result = await service.popFileHash(fileReferenceId);

      expect(result).to.equal("abc123hash");
      expect(delStub).to.have.been.calledOnce;
    });

    it("should return null when HASH_SKIPPED sentinel is stored", async () => {
      getCacheStub.resolves("HASH_SKIPPED");
      isRedisStub.returns(true);
      getRedisStub.returns({ del: delStub });

      const result = await service.popFileHash(fileReferenceId);

      expect(result).to.be.null;
    });

    it("should return null when no hash is stored", async () => {
      getCacheStub.resolves(null);
      isRedisStub.returns(false);

      const result = await service.popFileHash(fileReferenceId);

      expect(result).to.be.null;
    });
  });
});