test/unit/services/issues.service.spec.tsmodifiedimport sinon from "sinon";
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import sinonChai from "sinon-chai";
import * as service from "../../../src/services/issues.service";
import * as db from "../../../src/db/db";
import * as iamClientHelper from "../../../src/clients/iam.client.helper";
import * as projectsService from "../../../src/services/projects.service";
import {LoggerFactory} from "../../../src/util/logger";
const expect = chai.expect;
const logger = LoggerFactory("IssuesService");
chai.use(chaiAsPromised).use(sinonChai);
describe("Issues Service unit tests", () => {
let queryStub: sinon.SinonStub;
let getFromCacheOrIamStub: sinon.SinonStub;
let getMappedProjectByProjectIdStub: sinon.SinonStub;
let loggerSpy: any;
beforeEach(() => {
loggerSpy = sinon.spy(logger);
queryStub = sinon.stub(db, "query");
getFromCacheOrIamStub = sinon.stub(iamClientHelper, "getFromCacheOrIam");
getMappedProjectByProjectIdStub = sinon.stub(projectsService, "getMappedProjectByProjectId");
getMappedProjectByProjectIdStub.resolves({
mongoTenantId: "test-tenant"
});
});
afterEach(() => {
sinon.restore();
});
describe("getNameFromCacheOrIam - system@xyzreality.com check", () => {
it("Should return 'System' when reporterEmail is 'system@xyzreality.com' (lowercase)", async () => {
const mockRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "system@xyzreality.com",
AssigneeEmail: "user@example.com",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
queryStub.resolves({
rows: [mockRow]
});
const mockContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-123",
email: "user@example.com",
firstName: "Test",
lastName: "User"
};
getFromCacheOrIamStub.resolves(mockContactInfo);
const result = await service.getIssue("443b4964-09df-4f86-9e98-7bec5a9fcdab", "123e4567-e89b-12d3-a456-426614174000", true);
expect(result.reporterName).to.equal("System");
expect(getFromCacheOrIamStub).to.not.have.been.calledWith(sinon.match.any, "system@xyzreality.com");
expect(getMappedProjectByProjectIdStub).to.have.been.calledOnce;
});
it("Should return 'System' when reporterEmail is 'SYSTEM@XYZREALITY.COM' (uppercase)", async () => {
const mockRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "SYSTEM@XYZREALITY.COM",
AssigneeEmail: "user@example.com",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
queryStub.resolves({
rows: [mockRow]
});
const mockContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-123",
email: "user@example.com",
firstName: "Test",
lastName: "User"
};
getFromCacheOrIamStub.resolves(mockContactInfo);
const result = await service.getIssue("443b4964-09df-4f86-9e98-7bec5a9fcdab", "123e4567-e89b-12d3-a456-426614174000", true);
expect(result.reporterName).to.equal("System");
expect(getFromCacheOrIamStub).to.not.have.been.calledWith(sinon.match.any, "SYSTEM@XYZREALITY.COM");
expect(getMappedProjectByProjectIdStub).to.have.been.calledOnce;
});
it("Should return 'System' when reporterEmail is 'System@XyzReality.com' (mixed case)", async () => {
const mockRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "System@XyzReality.com",
AssigneeEmail: "user@example.com",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
queryStub.resolves({
rows: [mockRow]
});
const mockContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-123",
email: "user@example.com",
firstName: "Test",
lastName: "User"
};
getFromCacheOrIamStub.resolves(mockContactInfo);
const result = await service.getIssue("443b4964-09df-4f86-9e98-7bec5a9fcdab", "123e4567-e89b-12d3-a456-426614174000", true);
expect(result.reporterName).to.equal("System");
expect(getFromCacheOrIamStub).to.not.have.been.calledWith(sinon.match.any, "System@XyzReality.com");
expect(getMappedProjectByProjectIdStub).to.have.been.calledOnce;
});
it("Should return 'System' when assigneeEmail is 'system@xyzreality.com'", async () => {
const mockRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "user@example.com",
AssigneeEmail: "system@xyzreality.com",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
queryStub.resolves({
rows: [mockRow]
});
const mockContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-123",
email: "user@example.com",
firstName: "Test",
lastName: "User"
};
getFromCacheOrIamStub.resolves(mockContactInfo);
const result = await service.getIssue("443b4964-09df-4f86-9e98-7bec5a9fcdab", "123e4567-e89b-12d3-a456-426614174000", true);
expect(result.assigneeName).to.equal("System");
expect(getFromCacheOrIamStub).to.not.have.been.calledWith(sinon.match.any, "system@xyzreality.com");
expect(getMappedProjectByProjectIdStub).to.have.been.calledOnce;
});
it("Should call getFromCacheOrIam for non-system emails", async () => {
const mockRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "user@example.com",
AssigneeEmail: "assignee@example.com",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
queryStub.resolves({
rows: [mockRow]
});
const mockReporterContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-123",
email: "user@example.com",
firstName: "John",
lastName: "Doe"
};
const mockAssigneeContactInfo = {
tenant: {id: "test-tenant"},
id: "contact-456",
email: "assignee@example.com",
firstName: "Jane",
lastName: "Smith"
};
getFromCacheOrIamStub
.onFirstCall().resolves(mockReporterContactInfo)
.onSecondCall().resolves(mockAssigneeContactInfo);
const result = await service.getIssue("443b4964-09df-4f86-9e98-7bec5a9fcdab", "123e4567-e89b-12d3-a456-426614174000", true);
expect(result.reporterName).to.equal("John Doe");
expect(result.assigneeName).to.equal("Jane Smith");
expect(getFromCacheOrIamStub).to.have.been.calledTwice;
expect(getFromCacheOrIamStub).to.have.been.calledWith("test-tenant", "user@example.com");
expect(getFromCacheOrIamStub).to.have.been.calledWith("test-tenant", "assignee@example.com");
expect(getMappedProjectByProjectIdStub).to.have.been.calledOnce;
});
});
describe("updateIssue", () => {
it("should throw NotFoundError when fn_UpdateIssue returns no rows", async () => {
const { NotFoundError } = await import("../../../src/types/errortypes");
queryStub.resolves({ rows: [] });
await expect(
service.updateIssue("non-existent-id", "443b4964-09df-4f86-9e98-7bec5a9fcdab", { platform: "XYZ" })
).to.be.rejectedWith(NotFoundError, /not found/);
});
});
const baseIssueRow = {
Id: 1,
IssueId: "123e4567-e89b-12d3-a456-426614174000",
ProjectId: "443b4964-09df-4f86-9e98-7bec5a9fcdab",
ModelId: null,
ModelElementId: null,
ModelRoomId: null,
Title: "Test Issue",
Description: "Test Description",
IssueNumber: 1,
IssueStatusId: "status-id",
IssueStatus: "Open",
IssueStatusCode: "OPEN",
IssueStageId: null,
IssueStage: null,
IssueOutcomeId: null,
IssueOutcome: null,
IssueRaisedOn: new Date(),
ReporterEmail: "user@example.com",
AssigneeEmail: "assignee@example.com",
AssigneeType: "INTERNAL",
IssueTypeId: "type-id",
TypeName: "Type",
IssueLocationId: null,
Location: null,
DueDate: null,
XMeters: null,
YMeters: null,
ZMeters: null,
LocationDetails: null,
IssueSeverityCategoryId: null,
HardHatPosition: null,
Company: null,
ObservedDiscrepancy: null,
ClosureReason: null,
Cost: null,
LastModifiedBy: null,
LastModifiedOn: null,
IssueSeverityCategoryName: null,
CustomAttributes: "[]"
};
// The typeName filter is pushed down to Postgres (a WHERE ... = ANY(...) clause is
// appended to the function-call query), so these tests assert the SQL text and bound
// params handed to the DB rather than any client-side filtering of the returned rows.
describe("getIssueList - typeName filter", () => {
const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
const paging = {lastFetchedIndexId: 0, size: 10};
const rows = [
{...baseIssueRow, Id: 1, IssueId: "issue-1", TypeName: "Quality"},
{...baseIssueRow, Id: 2, IssueId: "issue-2", TypeName: "Safety"}
];
it("Should not add a typeName clause when no typeNames are provided", async () => {
queryStub.resolves({rows});
const result = await service.getIssueList(projectId, paging, false);
expect(result).to.have.lengthOf(2);
const [sql, params] = queryStub.firstCall.args;
expect(sql).to.not.match(/TypeName/);
expect(params).to.have.lengthOf(5);
expect(params).to.deep.equal([projectId, paging.lastFetchedIndexId, paging.size, null, false]);
});
it("Should append a case-insensitive TypeName filter bound as a lowercased array", async () => {
queryStub.resolves({rows: [rows[0]]});
await service.getIssueList(projectId, paging, false, undefined, ["Quality"]);
const [sql, params] = queryStub.firstCall.args;
expect(sql).to.match(/WHERE LOWER\("TypeName"\) = ANY\(\$6::text\[\]\)/);
expect(params).to.have.lengthOf(6);
expect(params[5]).to.deep.equal(["quality"]);
});
it("Should bind every value (OR semantics) when multiple TypeNames are provided", async () => {
queryStub.resolves({rows});
await service.getIssueList(projectId, paging, false, undefined, ["Quality", "Safety"]);
const [, params] = queryStub.firstCall.args;
expect(params[5]).to.deep.equal(["quality", "safety"]);
});
it("Should trim and lowercase the bound TypeNames", async () => {
queryStub.resolves({rows: [rows[0]]});
await service.getIssueList(projectId, paging, false, undefined, [" qUaLiTy "]);
const [, params] = queryStub.firstCall.args;
expect(params[5]).to.deep.equal(["quality"]);
});
it("Should return whatever rows the database returns for the filter", async () => {
queryStub.resolves({rows: []});
const result = await service.getIssueList(projectId, paging, false, undefined, ["NonExistentType"]);
expect(result).to.have.lengthOf(0);
});
});
describe("getDetailedIssueList - typeName filter", () => {
const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
const paging = {lastFetchedIndexId: 0, size: 10};
const detailedRows = [
{...baseIssueRow, Id: 1, IssueId: "issue-1", TypeName: "Quality", FileReferences: [], ActivityCategories: []},
{...baseIssueRow, Id: 2, IssueId: "issue-2", TypeName: "Safety", FileReferences: [], ActivityCategories: []}
];
it("Should not add a typeName clause when no typeNames are provided", async () => {
queryStub.resolves({rows: detailedRows});
const result = await service.getDetailedIssueList(projectId, paging, false);
expect(result).to.have.lengthOf(2);
const [sql, params] = queryStub.firstCall.args;
expect(sql).to.not.match(/TypeName/);
expect(params).to.have.lengthOf(5);
});
it("Should append a case-insensitive TypeName filter bound as a lowercased array", async () => {
queryStub.resolves({rows: [detailedRows[1]]});
await service.getDetailedIssueList(projectId, paging, false, undefined, ["Safety"]);
const [sql, params] = queryStub.firstCall.args;
expect(sql).to.match(/WHERE LOWER\("TypeName"\) = ANY\(\$6::text\[\]\)/);
expect(params[5]).to.deep.equal(["safety"]);
});
});
});