test/unit/services/issues.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 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"]);
        });
    });
});