test/unit/services/projectfiles.service.spec.tsmodifiedimport 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;
});
});
});