test/e2e/api/issues.e2e.spec.tsmodified
import {
    app,
    authoriseProjectIamAccess,
    Authorities,
    chai,
    cleanMockSetup,
    closeMockServer,
    createAuthToken,
    deepEqualInAnyOrder,
    expect,
    http,
    Server,
    setupE2EMockEnvVariables,
    setupIAM,
    uuidv4
} from "../common/e2e-imports";
import {
    mockUserDetailApi,
    setupIamContactEmail,
    setupIamContactSync,
    setupIamServiceToken,
    setupNotificationService
} from "../util/util";
import {getMockServerRequestDetails} from "../util/mountebank-helper";
import {Issue} from "../../../src/models/ingress";
import {
    cleanUpIssueMappingHistory,
    cleanupIssueType,
    createActivityCategory,
    deleteIssuesForE2ECleanup,
    markIssuesAsDeleted,
    createCategoryType,
    createIssueActivityCategoryMapping,
    createIssueFileReferenceMapping,
    createPhotoRefInDB,
    getIssueById,
    getIssueTypesSimplified,
    setupIssue,
    setupIssueType
} from "../util/db-helper";
import {IssueHistoryResponse} from "../../../src/models/egress";
import {randomInt} from "node:crypto";
import {NotificationPurpose} from "../../../src/types/notification.types";
import {ENV_CONFIG} from '../../../config/environment.config';

chai.use(deepEqualInAnyOrder);

const issueExpectedKeys = [
    "assigneeEmail",
    "assigneeName",
    "assigneeType",
    "description",
    "dueDate",
    "issueId",
    "issueLocationId",
    "issueNumber",
    "issueRaisedOn",
    "issueSeverityCategoryId",
    "issueSeverityCategoryName",
    "issueTypeId",
    "location",
    "locationDetails",
    "modelElementId",
    "modelId",
    "modelRoomId",
    "projectId",
    "reporterEmail",
    "reporterName",
    "title",
    "typeName",
    "xMeters",
    "yMeters",
    "zMeters",
    "hardHatPosition",
    "issueStatusId",
    "issueStatus",
    "issueStatusCode",
    "customAttributes",
    "issueStageId",
    "issueStage",
    "issueOutcomeId",
    "issueOutcome",
    "company",
    "observedDiscrepancy",
    "closureReason",
    "cost",
    "lastModifiedBy",
    "lastModifiedOn",
    "resolutionDate"
];

/* eslint-disable  @typescript-eslint/no-explicit-any */
describe("Issues E2E tests", () => {
    let server: Server;
    let authToken: any;

    before(async () => {
        setupE2EMockEnvVariables();
        await cleanMockSetup();
        await setupIAM();
        await cleanUpIssueMappingHistory();
        server = http.createServer(app);
        await server.listen();
        authToken = await createAuthToken("email-from-token@xyz.com");
        await mockUserDetailApi(authToken.token);
        await setupNotificationService();
    });

    after(() => {
        closeMockServer(server);
    });

    describe("Create Issue", () => {
        const projectId = "543b4964-09df-4f86-9e98-7bec5a9fcdac";
        const baseUrl = `/api/v2/projects/${projectId}/issues`;

        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_CREATE]);
        });

        it("Should create a new issue and return 201", async () => {
            const issue: Partial<Issue> = {
                title: "New Issue",
                location: "Location",
                description: "Detailed description of the issue",
                xMeters: 12.34,
                yMeters: 56.78,
                zMeters: 90.12,
                issueTypeId: '445b3f4f-0302-4507-af81-d9e271f9b9ec',
                issueSeverityCategoryId: 'fb7e5584-5494-44ac-a56d-ec97fd8fe519',
                issueStatusId: '29e37b3e-ab46-4f23-885d-be56adfe4614',
                modelId: ['d54f12fe-d5f9-4026-b92a-da25fd03655e'],
                modelElementId: '50018591-5e1f-45e5-b80c-2944565a977d',
                assigneeEmail: 'e2e-test-assignee@xyzreality.com',
                dueDate: '2024-11-12',
                cost: '9000'
            };

            const res = await chai.request(app)
                .post(baseUrl)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);
            console.log("------------------------------------------------ res: ", res.body);
            expect(res).to.have.status(201);
            expect(res.body).to.have.keys(...issueExpectedKeys);
            expect(res.body.issueId).to.be.a("string").and.not.be.empty;
            expect(res.body.issueNumber).to.be.a("number");
            expect(res.body.issueTypeId).to.be.a("string").and.not.be.empty;
            expect(res.body.issueStatusId).to.be.a("string").and.not.be.empty;
            expect(res.body.reporterEmail).to.equal("email-from-token@xyz.com");
            expect(res.body.assigneeEmail).to.equal("e2e-test-assignee@xyzreality.com");
            expect(res.body.cost).to.equal("9000");
        });

        it("Should create a new issue and return 201 when optional fields are not provided", async () => {
            const issue: Partial<Issue> = {
                title: "New Issue 2",
                location: "Bosnia",
                description: "Detailed description of the issue 2",
                xMeters: 12.34,
                yMeters: 56.78,
                zMeters: 90.12,
                issueTypeId: '445b3f4f-0302-4507-af81-d9e271f9b9ec',
                issueSeverityCategoryId: 'fb7e5584-5494-44ac-a56d-ec97fd8fe519',
                issueStatusId: '29e37b3e-ab46-4f23-885d-be56adfe4614',
                modelId: ['d54f12fe-d5f9-4026-b92a-da25fd03655e'],
                dueDate: '2024-11-12',
                assigneeEmail: 'e2e-test-assignee@xyzreality.com'
            };

            const res = await chai.request(app)
                .post(baseUrl)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(res).to.have.status(201);
            expect(res.body).to.have.keys(...issueExpectedKeys);
            expect(res.body.issueId).to.be.a("string").and.not.be.empty;
            const issueId = res.body.issueId;
            expect(res.body.issueNumber).to.be.a("number");
            expect(res.body.typeName).to.be.a("string").and.not.be.empty;
            expect(res.body.issueStatusId).to.be.a("string").and.not.be.empty;
            expect(res.body.reporterEmail).to.equal("email-from-token@xyz.com");
            expect(res.body.assigneeEmail).to.equal("e2e-test-assignee@xyzreality.com");
            await assertNotificationSent("e2e-test-assignee@xyzreality.com", projectId, issueId, NotificationPurpose.ISSUE_ASSIGNED);
            await assertNotificationSent("email-from-token@xyz.com", projectId, issueId, NotificationPurpose.ISSUE_ASSIGNED_REPORTER);
        });

        it("Should use reporterEmail from request body when provided", async () => {
            const explicitReporterEmail = "explicit-reporter@xyzreality.com";
            const issue: Partial<Issue> = {
                title: "New Issue with explicit reporter",
                location: "Location",
                description: "Issue with reporterEmail in body",
                issueTypeId: '445b3f4f-0302-4507-af81-d9e271f9b9ec',
                issueSeverityCategoryId: 'fb7e5584-5494-44ac-a56d-ec97fd8fe519',
                issueStatusId: '29e37b3e-ab46-4f23-885d-be56adfe4614',
                modelId: ['d54f12fe-d5f9-4026-b92a-da25fd03655e'],
                dueDate: '2024-11-12',
                assigneeEmail: 'e2e-test-assignee@xyzreality.com',
                reporterEmail: explicitReporterEmail
            };

            const res = await chai.request(app)
                .post(baseUrl)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(res).to.have.status(201);
            expect(res.body.reporterEmail).to.equal(explicitReporterEmail);
            expect(res.body.reporterEmail).to.not.equal("email-from-token@xyz.com");
        });

        it("Should return 400 if payload is invalid", async () => {
            const issue: Partial<Issue> = {
                // title: "New Issue",
                location: "Location",
                description: "Detailed description of the issue",
                xMeters: 12.34,
                yMeters: 56.78,
                zMeters: 90.12,
                issueTypeId: '97443e67-5789-4f96-b9b8-cb65c0de7e37',
                issueSeverityCategoryId: '6a41a400-b7a4-4fd4-8681-03d26d9bfd66',
                issueStatusId: '334cfd9a-0ae4-4feb-895a-c858738bbfca',
                modelId: ['188065cf-96a1-46e3-a5ac-40618fbe4b96'],
                modelElementId: 'c1d7289c-e30e-48b3-9583-175ce942b8d6',
                dueDate: '2024-11-12',
                reporterEmail: 'e2e-test',
                assigneeEmail: 'e2e-test-assignee',
                reporterName: 'John Doe'
            };

            const res = await chai.request(app)
                .post(baseUrl)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(res).to.have.status(400);
            expect(res.body).to.deep.equal({
                code: "InvalidRequest",
                message: "Title is required and must be a non-blank string."
            });
        });

        it("Should return 400 when IssueStatusId violates foreign key constraint", async () => {
            const randomUuid = uuidv4();
            const issue: Partial<Issue> = {
                title: "New Issue",
                location: "Location",
                description: "Detailed description of the issue",
                xMeters: 12.34,
                yMeters: 56.78,
                zMeters: 90.12,
                issueTypeId: '97443e67-5789-4f96-b9b8-cb65c0de7e37',
                issueSeverityCategoryId: '6a41a400-b7a4-4fd4-8681-03d26d9bfd66',
                issueStatusId: randomUuid,
                modelId: ['188065cf-96a1-46e3-a5ac-40618fbe4b96'],
                modelElementId: 'c1d7289c-e30e-48b3-9583-175ce942b8d6',
                dueDate: '2024-11-12',
                reporterEmail: 'e2e-test',
                assigneeEmail: 'e2e-test-assignee',
                reporterName: 'John Doe'
            };
            const res = await chai.request(app)
                .post(baseUrl)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);
            expect(res).to.have.status(400);
            expect(res.body).to.deep.equal({
                code: "BadRequestError",
                message: `Integrity constraint 'Issue_IssueStatus_fkey' violated: Key (ProjectShardId, IssueStatusId)=(3, ${randomUuid}) is not present in table "IssueStatus".`
            });
        });

        it("Should return 400 when projectId is invalid", async () => {
            const invalidProjectId = "invalid-project-id";
            const issue: Partial<Issue> = {
                title: "New Issue"
            };
            const res = await chai.request(app)
                .post(`/api/v2/projects/${invalidProjectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(res.status).to.equal(400);
            expect(res.body).to.deep.equal({
                code: "InvalidRequest",
                message: "projectId is required in the URL parameters and must be a valid UUID."
            });
        });

        it("Should return 404 if project is not found", async () => {
            const nonExistentProjectId = "f18eca46-6b23-49eb-b09e-6fb670cca461";
            const issue: Partial<Issue> = {
                title: "New Issue",
                location: "Location",
                description: "Detailed description of the issue",
                issueNumber: 123,
                xMeters: 12.34,
                yMeters: 56.78,
                zMeters: 90.12,
                issueTypeId: '97443e67-5789-4f96-b9b8-cb65c0de7e37',
                issueSeverityCategoryId: '6a41a400-b7a4-4fd4-8681-03d26d9bfd66',
                issueLocationId: 'f3ae155c-0f5f-41dc-865d-09b6ded7a87f',
                issueStatusId: '334cfd9a-0ae4-4feb-895a-c858738bbfca',
                modelId: ['188065cf-96a1-46e3-a5ac-40618fbe4b96'],
                modelElementId: 'c1d7289c-e30e-48b3-9583-175ce942b8d6',
                dueDate: '2024-11-12',
                reporterEmail: 'e2e-test',
                assigneeEmail: 'e2e-test-assignee',
                reporterName: 'John Doe'
            };

            const res = await chai.request(app)
                .post(`/api/v2/projects/${nonExistentProjectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Project with ProjectId: '${nonExistentProjectId}' not found.`
            });
        });
    });

    describe("Get Issue", () => {
        const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";

        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);
        });

        it("Should retrieve a simple issue and return 200 with simple=true", async () => {
            const issueId = "123e4567-e89b-12d3-a456-426614174001";
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${issueId}?simple=true`)
                .set("Authorization", `Bearer ${authToken.token}`);
            console.log(res.body)
            expect(res).to.have.status(200);
            expect(res.body).to.have.property("issueId", issueId);
            expect(res.body).to.have.property("projectId", projectId);
            expect(res.body.modelId).to.not.be.null;
            expect(res.body).to.have.keys(...issueExpectedKeys);

            // Verify that fileReferences, comments, and activityCategories are NOT included when simple=true
            expect(res.body).to.not.have.property("fileReferences");
            expect(res.body).to.not.have.property("comments");
            expect(res.body).to.not.have.property("activityCategories");
        });

        it("Should retrieve detailed issue and return 200 when simple=false", async () => {
            const issueId = "123e4567-e89b-12d3-a456-426614174001";
            // Create two files and map to issue
            const fileReferenceId1 = uuidv4();
            await createPhotoRefInDB("local-azurite-container/RawData/photo", projectId, fileReferenceId1);
            await createIssueFileReferenceMapping(projectId, issueId, fileReferenceId1);

            // Create activity categories and map to issue
            const categoryType1Id = await createCategoryType(projectId, "Apples", false);
            const categoryType2Id = await createCategoryType(projectId, "Oranges", false);
            const activityCategory1Id = await createActivityCategory(projectId, null, "Jazz", categoryType1Id);
            const activityCategory2Id = await createActivityCategory(projectId, null, "Easy Peeler", categoryType2Id);
            await createIssueActivityCategoryMapping(projectId, issueId, activityCategory1Id, categoryType1Id);
            await createIssueActivityCategoryMapping(projectId, issueId, activityCategory2Id, categoryType2Id);

            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${issueId}?simple=false`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(200);
            console.log(res.body)

            // Check that the response has the main issue properties
            expect(res.body).to.have.property("issueId", issueId);
            expect(res.body).to.have.property("projectId", projectId);
            expect(res.body).to.have.property("title");
            expect(res.body).to.have.property("description");
            expect(res.body).to.have.property("issueTypeId");
            expect(res.body).to.have.property("typeName");
            expect(res.body).to.have.property("reporterEmail");
            expect(res.body).to.have.property("assigneeEmail");

            // Check that fileReferences is an array
            expect(res.body).to.have.property("fileReferences");
            expect(res.body.fileReferences).to.be.an("array");
            expect(res.body.fileReferences.length).to.be.greaterThan(0);

            // Comments are no longer embedded — fetched via the paged endpoint instead.
            expect(res.body).to.not.have.property("comments");

            // Check that activityCategories is an array
            expect(res.body).to.have.property("activityCategories");
            expect(res.body.activityCategories).to.be.an("array");
            expect(res.body.activityCategories.length).to.equal(2);

            // Verify structure of activityCategories
            const expectedActivityCategories = [
                {
                    activityCategoryId: activityCategory1Id,
                    categoryTypeId: categoryType1Id,
                    categoryName: "Jazz",
                    typeName: "Apples"
                },
                {
                    activityCategoryId: activityCategory2Id,
                    categoryTypeId: categoryType2Id,
                    categoryName: "Easy Peeler",
                    typeName: "Oranges"
                }
            ];
            expect(res.body.activityCategories).to.deep.equalInAnyOrder(expectedActivityCategories);

            // Verify structure of fileReferences
            expect(res.body.fileReferences[0]).to.have.keys(
                "fileReferenceId", "fileName", "fileExtension", "xyzDisplayName", "description", "fullDownloadUrl",
                "insertedOn", "createdBy", "lastModifiedBy", "lastModifiedOn", "smallImageDownloadUrl",
                "fileSizeBytes", "fileHash", "type"
            );
        });

        it("Should retrieve detailed issue with empty activityCategories when no mappings exist", async () => {
            const issueId = "123e4567-e89b-12d3-a456-426614174002"; // Different issue ID that won't have activity categories
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${issueId}?simple=false`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(200);
            expect(res.body).to.have.property("activityCategories");
            expect(res.body.activityCategories).to.be.an("array");
            expect(res.body.activityCategories).to.have.lengthOf(0);
        });

        it("Should return 400 when simple is set to bad value", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/123e4567-e89b-12d3-a456-426614174001?simple=death`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(400);
            expect(response.body).to.deep.equal({
                code: "InvalidRequest",
                message: "simple parameter must be a boolean value (true or false)."
            });
        });

        it("Should return 404 when issueId is not found with detailed request", async () => {
            const nonExistentIssueId = uuidv4();
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${nonExistentIssueId}?simple=false`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Issue with IssueId: ${nonExistentIssueId} and ProjectId: ${projectId} not found.`
            });
        });

        it("Should return 404 if project is not found", async () => {
            const nonExistentProjectId = uuidv4();
            const nonExistentIssueId = uuidv4();
            const res = await chai.request(app)
                .get(`/api/v2/projects/${nonExistentProjectId}/issues/${nonExistentIssueId}`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Project with ProjectId: '${nonExistentProjectId}' not found.`
            });
        });

        it("Should return 404 if issue is not found", async () => {
            const nonExistentIssueId = uuidv4();
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${nonExistentIssueId}`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Issue with IssueId: ${nonExistentIssueId} and ProjectId: ${projectId} not found.`
            });
        });
    });

    describe("Get Issue list", () => {
        const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab"

        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await setupIamContactEmail("Reporter@xyzreality.com");
            await setupIamContactSync();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW, Authorities.LINK_PROJECT]);
        });

        it("Should return 200 and basic list of issues when valid request is made with simple=true", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues?simple=true`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            console.log(response.body.records[0])
            expect(response.body.records).to.have.lengthOf(4);
            // Should have all keys from Issue interface
            expect(response.body.records[0]).to.have.keys(...issueExpectedKeys);
            expect(response.body.records[0]).to.not.have.property("fileReferences");
            expect(response.body.records[0]).to.not.have.property("comments");
            expect(response.body.records[0]).to.not.have.property("activityCategories");
        });

        it("Should return 200 and detailed list of issues when valid request is made", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`);
            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            console.log(response.body.records)
            expect(response.body.records).to.have.lengthOf(4);
            // Should have same keys as Issue detailed interface
            // List of all fields in IssueDetailed interface, in the same order
            const expectedDetailedKeys = [
                "activityCategories",
                "fileReferences"
            ];

            expect(response.body.records[0]).to.have.keys(...issueExpectedKeys, ...expectedDetailedKeys);
            expect(response.body.records[0]).to.not.have.property("comments");
            expect(response.body.records[0].fileReferences).to.be.an("array");
            expect(response.body.records[0].activityCategories).to.be.an("array");
            expect(response.body.records[0].fileReferences).to.have.lengthOf(1);

            // Check reporterName only for issues with Reporter@xyzreality.com
            const issueWithReporter = response.body.records.find((issue: any) => issue.reporterEmail === "Reporter@xyzreality.com");
            if (issueWithReporter) {
                expect(issueWithReporter.reporterName).to.be.eq("Test-first-name Test-last-name");
            }

            const fileReference = response.body.records[0].fileReferences.find((ref: any) => ref.fileName === 'e2e-fileName.jpg');
            expect(fileReference).to.not.be.undefined;
            expect(fileReference).to.have.keys(
                "fullDownloadUrl", "createdBy", "description", "fileExtension", "fileHash", "fileName",
                "fileReferenceId", "fileSizeBytes", "smallImageDownloadUrl", "insertedOn", "lastModifiedBy",
                "lastModifiedOn", "xyzDisplayName", "type"
            );

            // Check varying fields with type validation
            expect(fileReference.fullDownloadUrl).to.be.a("string").and.not.be.empty;
            expect(fileReference.fileReferenceId).to.be.a("string").and.not.be.empty;
            expect(fileReference.insertedOn).to.be.a("string").and.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}\+\d{2}:\d{2}$/);

            // Check fixed fields with exact values
            expect(fileReference.createdBy).to.equal("e2e-test");
            expect(fileReference.description).to.be.null;
            expect(fileReference.fileExtension).to.equal("jpg");
            expect(fileReference.fileHash).to.be.null;
            expect(fileReference.fileName).to.equal("e2e-fileName.jpg");
            expect(fileReference.fileSizeBytes).to.be.a("number");
            expect(fileReference.lastModifiedBy).to.equal("e2e-test");
            expect(fileReference.lastModifiedOn).not.to.be.null;
            expect(fileReference.xyzDisplayName).to.be.null;
            expect(response.body.records[0]).to.not.have.property("comments");
        });

        it("Should return 200 and the list of issues with correct size when valid request with lastFetchedIndexId is made", async () => {
            const lastFetchedIndexId = 1;
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({
                    lastFetchedIndexId: lastFetchedIndexId,
                    size: 5
                });

            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            expect(response.body.records.length).to.be.lessThanOrEqual(5);
            if (response.body.records.length > 0) {
                expect(response.body.records[0]).to.not.have.property("isDeleted");
            }
        });

        it("Should return 200 and list of issues when lastSyncDateTime is provided", async () => {
            const createdIssue = await setupTestIssueForAction("LastSync");
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({lastSyncDateTime: "2020-01-01T00:00:00Z", simple: "true"});

            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            const issue = response.body.records.find((r: { issueId: string }) => r.issueId === createdIssue.issueId);
            expect(issue, `Expected to find issue with issueId ${createdIssue.issueId} in response`).to.not.be.undefined;
            const issueExpectedKeysWithSync = [...issueExpectedKeys, "isDeleted"];
            expect(issue).to.have.keys(...issueExpectedKeysWithSync);
            expect(issue).to.have.property("issueId", createdIssue.issueId);
            expect(issue).to.have.property("projectId", projectId);
            expect(issue).to.have.property("title", createdIssue.title);
            expect(issue).to.have.property("isDeleted");
            expect(issue.isDeleted).to.be.a("boolean");
        });

        /**
         * Regression target (TDD): getIssueList filters IsDeleted in Node after fn_GetIssueList returns a page.
         * If the DB page is mostly soft-deleted rows, the API returns fewer than `size` records even though
         * more non-deleted issues exist — clients that treat recordCount < size as end-of-list will stop early.
         * Fix will move deleted filtering into the stored procedure so pages are full of active rows.
         */
        it("Should document post-filter IsDeleted pagination: first page can be short while more active issues remain", async function () {

            const pageSize = 50;
            const deletedBatchSize = 100;
            const activeBatchSize = 50;
            const createdIssueIds: string[] = [];

            try {
                for (let i = 0; i < deletedBatchSize; i++) {
                    const row = await setupTestIssueForAction(`PaginateDel-${i}`);
                    createdIssueIds.push(row.issueId);
                }
                await markIssuesAsDeleted(projectId, createdIssueIds);

                for (let i = 0; i < activeBatchSize; i++) {
                    const row = await setupTestIssueForAction(`PaginateAct-${i}`);
                    createdIssueIds.push(row.issueId);
                }

                const firstPage = await chai.request(app)
                    .get(`/api/v2/projects/${projectId}/issues`)
                    .set("Authorization", `Bearer ${authToken.token}`)
                    .query({simple: "true", size: pageSize, lastFetchedIndexId: 0});

                expect(firstPage.status).to.equal(200);

                const fullList = await chai.request(app)
                    .get(`/api/v2/projects/${projectId}/issues`)
                    .set("Authorization", `Bearer ${authToken.token}`)
                    .query({simple: "true", size: 50000, lastFetchedIndexId: 0});

                expect(fullList.status).to.equal(200);
                const totalActiveReturned = fullList.body.records.length;

                expect(
                    firstPage.body.recordCount,
                    "First page should return fewer rows than requested page size when the DB page is mostly deleted (current behaviour)"
                ).to.be.equal(pageSize);

                expect(
                    totalActiveReturned,
                    "Total non-deleted issues should exceed the first page count (more data exists beyond the short page)"
                ).to.be.greaterThan(firstPage.body.recordCount);
            } finally {
                await deleteIssuesForE2ECleanup(projectId, createdIssueIds);
            }
        });

        it("Should return 400 when lastSyncDateTime is invalid", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues?lastSyncDateTime=not-a-date`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(400);
        });

        // The four seeded issues for this project all use IssueType
        // 'OPEN_ISSUE_TYPE' (see docker/test-data-dump/e2e-test-data-dump.sql),
        // so the expectations below are deterministic against the seed.
        const seededTypeName = "OPEN_ISSUE_TYPE";

        it("Should return only issues matching the typeName filter", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({typeName: seededTypeName});

            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            expect(response.body.records).to.have.lengthOf(4);
            response.body.records.forEach((issue: any) => {
                expect(issue.typeName).to.equal(seededTypeName);
            });
        });

        it("Should match the typeName filter case-insensitively", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({typeName: seededTypeName.toLowerCase()});

            expect(response.status).to.equal(200);
            expect(response.body.records).to.have.lengthOf(4);
            response.body.records.forEach((issue: any) => {
                expect(issue.typeName).to.equal(seededTypeName);
            });
        });

        it("Should apply OR semantics when multiple comma-separated typeNames are provided", async () => {
            // 'NonExistentType' matches nothing, so OR with the seeded type
            // must still return the full list.
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({simple: "true", typeName: `${seededTypeName},NonExistentType`});

            expect(response.status).to.equal(200);
            expect(response.body.records).to.have.lengthOf(4);
            response.body.records.forEach((issue: any) => {
                expect(issue.typeName).to.equal(seededTypeName);
            });
        });

        it("Should return an empty list when no issue matches the typeName filter", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({typeName: "NonExistentType"});

            expect(response.status).to.equal(200);
            expect(response.body.records).to.be.an("array");
            expect(response.body.records).to.have.lengthOf(0);
        });

        it("Should return the full unchanged list when typeName is omitted", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(200);
            expect(response.body.records).to.have.lengthOf(4);
        });

        it("Should return 400 when typeName is an empty string", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues?typeName=`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(400);
            expect(response.body).to.deep.include({
                code: "InvalidRequest",
                message: "typeName must be a non-empty comma-separated string."
            });
        });

        it("Should return deleted issue with isDeleted true when fetched with lastSyncDateTime after issue was deleted", async () => {
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);

            const createdIssue = await setupTestIssueForAction("DeletedSync");

            const listBeforeDelete = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({simple: "true"});

            expect(listBeforeDelete.status).to.equal(200);
            const issueBeforeDelete = listBeforeDelete.body.records.find((r: {
                issueId: string
            }) => r.issueId === createdIssue.issueId);
            expect(issueBeforeDelete, `Expected to find issue ${createdIssue.issueId} in list before delete`).to.not.be.undefined;
            expect(issueBeforeDelete).to.not.have.property("isDeleted");
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_DELETE]);

            const deleteResponse = await chai.request(app)
                .delete(`/api/v2/projects/${projectId}/issues/${createdIssue.issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(deleteResponse.status).to.equal(204);

            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);

            const listWithSync = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .query({lastSyncDateTime: "2020-01-01T00:00:00Z", simple: "true"});

            expect(listWithSync.status).to.equal(200);
            expect(listWithSync.body.records).to.be.an("array");
            const deletedIssueInSync = listWithSync.body.records.find((r: {
                issueId: string
            }) => r.issueId === createdIssue.issueId);
            expect(deletedIssueInSync, `Expected deleted issue ${createdIssue.issueId} to appear in sync response with isDeleted true`).to.not.be.undefined;
            expect(deletedIssueInSync).to.have.property("isDeleted", true);
        });

        it("Should return 400 when simple is set to bad value", async () => {
            const response = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues?simple=death`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(400);
            expect(response.body).to.deep.equal({
                code: "InvalidRequest",
                message: "simple parameter must be a boolean value (true or false)."
            });
        });

        it("Should return 404 when projectId is not found", async () => {
            const response = await chai.request(app)
                .get("/api/v2/projects/543b4964-09df-4f86-9e98-7bec5a9fc123/issues")
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(response.status).to.equal(404);
            expect(response.body).to.deep.equal({
                code: "NotFoundError",
                message: "Project with ProjectId: '543b4964-09df-4f86-9e98-7bec5a9fc123' not found."
            });
        });
    });

    describe("Update Issue", () => {
        const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
        const issueId = "123e4567-e89b-12d3-a456-426614174002";
        const baseUrl = `/api/v2/projects/${projectId}/issues`;
        let res: any;

        before(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_EDIT]);
            const updatedIssue = {
                title: "Updated Issue Title",
                locationDetails: "Updated Location Description",
                description: "Updated detailed description of the issue",
                xMeters: 98.76,
                yMeters: 54.32,
                zMeters: 10.98,
                issueNumber: 123,
                observedDiscrepancy: 50
            };
            res = await chai.request(app)
                .patch(`/api/v2/projects/${projectId}/issues/${issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(updatedIssue);
        })

        it("Should update an existing issue and return 200", async () => {
            expect(res).to.have.status(200);
            expect(res.body).to.have.keys(...issueExpectedKeys);
            expect(res.body.issueId).to.be.a("string").and.not.be.empty;
            expect(res.body.issueNumber).to.be.a("number")
            expect(res.body.issueTypeId).to.be.a("string").and.not.be.empty;
            expect(res.body.issueStatusId).to.be.a("string").and.not.be.empty;
            expect(res.body.reporterEmail).to.equal("Reporter@xyzreality.com");
            expect(res.body.title).to.equal("Updated Issue Title");
            expect(res.body.observedDiscrepancy).to.equal(50);
        });

        it('Should have updated existing issue correctly', async () => {
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);
            const issue = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/${issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send();
            expect(issue).to.have.status(200);
            expect(issue.body.title).is.equal("Updated Issue Title");
        });

        it('Should have logged history correctly correctly', async () => {
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);
            const historyRes = await chai.request(app)
                .get(`${baseUrl}/${issueId}/history`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send();
            expect(historyRes).to.have.status(200);
            const history: IssueHistoryResponse[] = historyRes.body;
            expect(history).to.be.an("array");
            console.log(JSON.stringify(history, null, 2));
            expect(history).to.have.lengthOf(8);
            // Find CREATED and UPDATED history entries
            const createdHistory = history.find(h => h.historyType === "CREATED");
            expect(createdHistory).to.exist;

            const updatedHistories = history.filter(h => h.historyType === "UPDATED");
            expect(updatedHistories).to.be.an("array").and.not.be.empty;
            expect(updatedHistories).to.have.lengthOf(7);

            // Check expected updated fields
            const expectedUpdates = [
                {fieldName: "title", fieldType: "string", left: {value: "Issue 2"}, right: {value: "Updated Issue Title"}},
                {fieldName: "locationDetails", fieldType: "string", left: {value: null}, right: {value: "Updated Location Description"}},
                {
                    fieldName: "description",
                    fieldType: "string",
                    left: {value: "Description 2"},
                    right: {value: "Updated detailed description of the issue"}
                },
                {fieldName: "xMeters", fieldType: "number", left: {value: "4.5"}, right: {value: "98.76"}},
                {fieldName: "yMeters", fieldType: "number", left: {value: "5.7"}, right: {value: "54.32"}},
                {fieldName: "zMeters", fieldType: "number", left: {value: "6.8"}, right: {value: "10.98"}},
                {fieldName: "observedDiscrepancy", fieldType: "number", left: {value: null}, right: {value: "50"}}
            ];

            expectedUpdates.forEach(expected => {
                const entry = updatedHistories.find(h => h.fieldName === expected.fieldName);
                expect(entry).to.exist;
                if (entry) {
                    expect(entry.left).to.deep.equal(expected.left);
                    expect(entry.right).to.deep.equal(expected.right);
                }
            });
        });
        it("Should send notification to assignee when issue is updated", async () => {
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW]);
            await setupIamServiceToken();
            await setupNotificationService();
            const issue = await chai.request(app)
                .patch(`/api/v2/projects/${projectId}/issues/${issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send({
                    assigneeEmail: "new_assignee@xyzreality.com",
                    title: "Updated Issue Title"
                });
            expect(issue).to.have.status(200);

            await assertNotificationSent("new_assignee@xyzreality.com", projectId, issueId, NotificationPurpose.ISSUE_ASSIGNED);
            await assertNotificationSent("Reporter@xyzreality.com", projectId, issueId, NotificationPurpose.ISSUE_ASSIGNED_REPORTER);
        });

        it("Should return 400 if projectId is not valid", async () => {
            const invalidProjectId = "invalid-project-id";
            const validIssue = {
                title: "Updated Issue Title"
            };

            const testRes = await chai.request(app)
                .patch(`/api/v2/projects/${invalidProjectId}/issues/${issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(validIssue);

            expect(testRes).to.have.status(400);
            expect(testRes.body).to.deep.equal({
                code: "InvalidRequest",
                message: "projectId is required in the URL parameters and must be a valid UUID."
            });
        });

        it("Should return 400 if issueId is not valid", async () => {
            const invalidIssueId = "invalid-issue-id";
            const validIssue = {
                title: "Updated Issue Title"
            };

            const testRes = await chai.request(app)
                .patch(`/api/v2/projects/${projectId}/issues/${invalidIssueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(validIssue);

            expect(testRes).to.have.status(400);
            expect(testRes.body).to.deep.equal({
                code: "InvalidRequest",
                message: "issueId is required in the URL parameters and must be a valid UUID."
            });
        });

        it("Should return 400 if request body is empty", async () => {
            const testRes = await chai.request(app)
                .patch(`/api/v2/projects/${projectId}/issues/${issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send({});

            expect(testRes).to.have.status(400);
            expect(testRes.body).to.deep.equal({
                code: "InvalidRequest",
                message: "Request payload must not be an empty object. At least one field should be updated."
            });
        });

        it("Should return 404 if project is not found", async () => {
            const nonExistentProjectId = uuidv4();
            const nonExistentIssueId = uuidv4();
            const issue: Partial<Issue> = {
                title: "Updated Title"
            };

            const testRes = await chai.request(app)
                .patch(`/api/v2/projects/${nonExistentProjectId}/issues/${nonExistentIssueId}`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(issue);

            expect(testRes).to.have.status(404);
            expect(testRes.body).to.deep.equal({
                code: "NotFoundError",
                message: `Project with ProjectId: '${nonExistentProjectId}' not found.`
            });
        });
    });

    describe("Delete Issue", () => {
        let issueForDeletion: any;

        before(async () => {
            issueForDeletion = await setupTestIssueForAction("Test Type");
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(issueForDeletion.projectId, authToken.token, [Authorities.ISSUE_DELETE]);

        });

        it("Should delete an issue and return 204", async () => {
            const res = await chai.request(app)
                .delete(`/api/v2/projects/${issueForDeletion.projectId}/issues/${issueForDeletion.issueId}`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(204);
            const deletedIssue = await getIssueById(issueForDeletion.issueId)
            expect(deletedIssue[0].IsDeleted).to.be.true
            expect(deletedIssue[0].DeletedOn).not.to.be.undefined;

        });

        it("Should return 404 if issue is not found", async () => {
            const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
            const nonExistentIssueId = uuidv4();
            const res = await chai.request(app)
                .delete(`/api/v2/projects/${projectId}/issues/${nonExistentIssueId}`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Issue with IssueId: ${nonExistentIssueId} and ProjectId: ${projectId} not found.`
            });
        });
    });

    describe("Get issue types", () => {
        const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
        const projectId2 = "09901ccb-1946-45f9-a08b-7de83eb825aa";


        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW, Authorities.LINK_PROJECT]);
            await authoriseProjectIamAccess(projectId2, authToken.token, [Authorities.ISSUE_VIEW, Authorities.LINK_PROJECT]);
            await cleanupIssueType(projectId);
            await cleanupIssueType(projectId2);
        });

        it("Should get issues successfully and return 200", async () => {

            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/types`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send();

            expect(res).to.have.status(200);
            console.log(res.body)
            expect(res.body).to.deep.equalInAnyOrder([
                {
                    "issueTypeId": "97443e67-5789-4f96-b9b8-cb65c0de7e37",
                    "type": "OPEN_ISSUE_TYPE",
                    "issueCustomAttributeIds": [],
                    "displayName": "OPEN_ISSUE_DIS_NAME",
                    "validForIssueCreate": false,
                    "validForIssueUpdate": false,
                }
            ]);
        });

        it("Should return 200 and empty if no values found for project", async () => {
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId2}/issues/types`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send();

            expect(res).to.have.status(200);
            expect(res.body).to.be.empty;
        });

        it("Should return 400 if projectId invalid", async () => {
            const projectId = "invalid-project-id";

            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/types`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send();

            expect(res).to.have.status(400);
        });

    });

    describe("Bulk update Issue type names", () => {
        const projectId = "09901ccb-1946-45f9-a08b-7de83eb825aa";

        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_EDIT]);
        });

        let issueForUpdate1: any;
        let issueForUpdate2: any;
        let issueForUpdate3: any;

        before(async () => {
            issueForUpdate1 = await setupIssueType(projectId, "Test_Type_1");
            issueForUpdate2 = await setupIssueType(projectId, "Test_Type_2");
            issueForUpdate3 = await setupIssueType(projectId, "Test_Type_3");
        });

        it("Should update issues with typeNames and return 201", async () => {
            const bodyUpdate = [
                {
                    "existingTypeName": "Test_Type_1",
                    "newTypeName": "new_Test_Type_1"
                },
                {
                    "existingTypeName": "Test_Type_2",
                    "newTypeName": "new_Test_Type_2"
                },
                {
                    "existingTypeName": "Test_Type_3",
                    "newTypeName": "new_Test_Type_3"
                }
            ]

            const res = await chai.request(app)
                .put(`/api/v2/projects/${projectId}/issues/bulk-update-types`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send(bodyUpdate);

            expect(res).to.have.status(201);
            const validateChangeRes = await getIssueTypesSimplified(projectId);
            expect(validateChangeRes).to.deep.equalInAnyOrder([
                {
                    "typeName": "new_Test_Type_1",
                    "displayName": "displayName-Test_Type_1"
                },
                {
                    "typeName": "new_Test_Type_2",
                    "displayName": "displayName-Test_Type_2"
                },
                {
                    "typeName": "new_Test_Type_3",
                    "displayName": "displayName-Test_Type_3"
                }
            ])
        });

        it("Should return 400 if required fields are missing in body", async () => {
            const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
            const res = await chai.request(app)
                .put(`/api/v2/projects/${projectId}/issues/bulk-update-types`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(400);
            expect(res.body).to.deep.equal({
                code: "InvalidRequest",
                message: `ExistingTypeName is required and must be a non-blank string.`
            });
        });

        it("Should return 400 if issue update values are incorrect", async () => {
            const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
            const res = await chai.request(app)
                .put(`/api/v2/projects/${projectId}/issues/bulk-update-types`)
                .set("Authorization", `Bearer ${authToken.token}`)
                .send({name: 4});

            expect(res).to.have.status(400);
            expect(res.body).to.deep.equal({
                code: "InvalidRequest",
                message: `ExistingTypeName is required and must be a non-blank string.`
            });
        });
    });

    describe("Get Issue parameters", () => {
        const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";

        beforeEach(async () => {
            await cleanMockSetup();
            await setupIAM();
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW, Authorities.LINK_PROJECT]);
            await authoriseProjectIamAccess(projectId, authToken.token, [Authorities.ISSUE_VIEW, Authorities.ISSUE_EDIT, Authorities.ISSUE_DELETE, Authorities.ISSUE_CREATE]);
            await cleanupIssueType(projectId);
        });

        it("Should return 200 and issue parameters", async () => {
            const res = await chai.request(app)
                .get(`/api/v2/projects/${projectId}/issues/parameters`)
                .set("Authorization", `Bearer ${authToken.token}`);
            expect(res).to.have.status(200);

            const responseBody = res.body;
            console.log(JSON.stringify(responseBody, null, 2));

            expect(res.body).to.have.keys("issueTypes", "issueSeverityCategories", "issueStages", "issueOutcomes", "issueLocations", "issueStatuses", "issueCustomAttributes");
            // issueTypes
            expect(responseBody.issueTypes).to.be.an("array");
            expect(responseBody.issueTypes).to.have.lengthOf(1);
            expect(responseBody.issueTypes[0]).to.have.keys("issueTypeId", "category", "issueCustomAttributeIds", "type", "displayName", "validForIssueCreate", "validForIssueUpdate");
            expect(responseBody.issueTypes[0].issueTypeId).to.equal('97443e67-5789-4f96-b9b8-cb65c0de7e37');
            expect(responseBody.issueTypes[0].type).to.equal('OPEN_ISSUE_TYPE');
            expect(responseBody.issueTypes[0].displayName).to.equal('OPEN_ISSUE_DIS_NAME');
            expect(responseBody.issueTypes[0].validForIssueCreate).to.be.false;
            expect(responseBody.issueTypes[0].validForIssueUpdate).to.be.false;
            expect(responseBody.issueTypes[0].category).to.be.null;
            expect(responseBody.issueTypes[0].issueCustomAttributeIds).to.deep.equal([]);
            // issueSeverityCategories
            expect(responseBody.issueSeverityCategories).to.be.an("array");
            expect(responseBody.issueSeverityCategories).to.have.lengthOf(7);
            expect(responseBody.issueSeverityCategories[0]).to.have.keys("issueSeverityCategoryId", "categoryName");
            // issueStage
            expect(responseBody.issueStages).to.be.an("array");
            expect(responseBody.issueStages[0]).to.have.keys("issueStageId", "stage");
            // issueOutcome
            expect(responseBody.issueOutcomes).to.be.an("array");
            expect(responseBody.issueOutcomes[0]).to.have.keys("issueOutcomeId", "outcome");

            expect(responseBody.issueStatuses).to.be.an("array");
            expect(responseBody.issueStatuses).to.have.lengthOf(2);
            expect(responseBody.issueStatuses[0]).to.have.keys("issueStatusId", "displayName", "status", "validForIssueCreate", "validForIssueUpdate");
            expect(responseBody.issueStatuses[0].issueStatusId).to.equal('334cfd9a-0ae4-4feb-895a-c858738bbfca');
            expect(responseBody.issueStatuses[0].displayName).to.equal('Issue Open');
            expect(responseBody.issueStatuses[0].status).to.equal('Issue Open');
            expect(responseBody.issueStatuses[0].validForIssueCreate).to.be.true;
            expect(responseBody.issueStatuses[0].validForIssueUpdate).to.be.true;
            expect(responseBody.issueStatuses[1]).to.have.keys("issueStatusId", "displayName", "status", "validForIssueCreate", "validForIssueUpdate");
            expect(responseBody.issueStatuses[1].issueStatusId).to.equal('4511866d-d3af-491b-8edb-b95bd71860d3');
            expect(responseBody.issueStatuses[1].displayName).to.equal('Issue Close');
            expect(responseBody.issueStatuses[1].status).to.equal('Issue Close');
            expect(responseBody.issueStatuses[1].validForIssueCreate).to.be.true;

            expect(responseBody.issueStatuses[1].validForIssueUpdate).to.be.true;
            // issueLocations
            expect(responseBody.issueLocations).to.be.an("array");
            expect(responseBody.issueLocations).to.have.lengthOf(1);
            expect(responseBody.issueLocations[0]).to.have.keys("issueLocationId", "location");
            expect(responseBody.issueLocations[0].issueLocationId).to.equal('f3ae155c-0f5f-41dc-865d-09b6ded7a87f');
            expect(responseBody.issueLocations[0].location).to.equal('Issue Location');
            // issueCustomAttributes
            expect(responseBody.issueCustomAttributes).to.be.an("array");
            expect(responseBody.issueCustomAttributes).to.have.lengthOf(1);
            expect(responseBody.issueCustomAttributes[0]).to.have.keys("issueCustomAttributeId", "title", "dataType", "visualType", "isRequired", "fieldOptions");
            expect(responseBody.issueCustomAttributes[0].issueCustomAttributeId).to.equal('4aa18591-5e1f-45e5-b80c-2944565a977d');
            expect(responseBody.issueCustomAttributes[0].title).to.equal('Custom Attribute 1');
            expect(responseBody.issueCustomAttributes[0].dataType).to.equal('TEXT');
            expect(responseBody.issueCustomAttributes[0].visualType).to.equal('TEXT');
            expect(responseBody.issueCustomAttributes[0].isRequired).to.be.false;
            expect(responseBody.issueCustomAttributes[0].fieldOptions).to.deep.equal([{example: 'options'}]);
        });

        it("Should return 404 if project is not found", async () => {
            const nonExistentProjectId = uuidv4();
            const res = await chai.request(app)
                .get(`/api/v2/projects/${nonExistentProjectId}/issues/parameters`)
                .set("Authorization", `Bearer ${authToken.token}`);

            expect(res).to.have.status(404);
            expect(res.body).to.deep.equal({
                code: "NotFoundError",
                message: `Project with ProjectId: '${nonExistentProjectId}' not found.`
            });
        });
    });
});

/**
 * Polls the mock server for outgoing notification requests and asserts that
 * an ISSUE_ASSIGNED notification was sent to the given email with expected fields.
 */
async function assertNotificationSent(email: string, projectId: string, issueId: string, notificationPurpose: NotificationPurpose): Promise<void> {
    // The notification client call is not awaited in the service; poll briefly for the recorded request.
    let attempts = 0;
    let notifRequests: any[] = [];
    let lastImposterState: any = undefined;
    while (attempts < 10) {
        lastImposterState = await getMockServerRequestDetails();
        notifRequests = lastImposterState.requests.filter((r: any) => r.path === "/services/notification/api/notifications");
        if (notifRequests.length > 0) {
            break;
        }
        await new Promise(resolve => setTimeout(resolve, 50));
        attempts++;
    }
    expect(
        notifRequests.length,
        `No notif calls. Imposter state: ${JSON.stringify(lastImposterState, null, 2)}`
    ).to.be.greaterThan(0);

    // Find the notification sent to the new assignee and assert critical fields
    const parsed = notifRequests.map((r: any) => {
        let body: any = null;
        try {
            body = JSON.parse(r.body);
        } catch {
            body = null;
        }
        return {req: r, body};
    });
    const assigneeNotif = parsed.find(p => p.body && p.body.email === email);
    const emails = parsed.filter(p => p.body).map(p => p.body.email);
    expect(
        assigneeNotif,
        `No notification for ${email}. Seen emails: ${emails.join(", ")}`
    ).to.exist;
    if (assigneeNotif) {
        expect(assigneeNotif.body.purpose).to.equal(notificationPurpose);
        expect(assigneeNotif.body.projectId).to.equal(projectId);
        expect(assigneeNotif.body.url).to.equal(ENV_CONFIG.notification.baseUrl);
        expect(assigneeNotif.body.channelType).to.deep.equal(["EMAIL"]);
        expect(assigneeNotif.body.serviceName).to.equal("PAPI");
        expect(assigneeNotif.body.readStatus).to.equal("UNREAD");
    }
}

async function setupTestIssueForAction(typeName: string): Promise<Issue> {
    const issueId = uuidv4();
    const id = randomInt(1, 1000000);
    const projectId = "443b4964-09df-4f86-9e98-7bec5a9fcdab";
    const title = "Test Issue Title" + typeName;
    const issueTypeId = '97443e67-5789-4f96-b9b8-cb65c0de7e37';
    const description = "Issue description";
    const xMeters = 10.0;
    const yMeters = 20.0;
    const zMeters = 30.0;
    const createdBy = "E2E test";
    const issueSeverityCategoryId = 'fb7e5584-5494-44ac-a56d-ec97fd8fe519';
    const issueLocationId = 'f3ae155c-0f5f-41dc-865d-09b6ded7a87f';
    const issueStatusId = '4511866d-d3af-491b-8edb-b95bd71860d3';
    const modelId = 'c53f12fe-d5f9-4026-b92a-da25fd03655d';
    const modelElementId = '4aa18591-5e1f-45e5-b80c-2944565a977d';
    const reporterEmail = "Test";
    const assigneeEmail = 'Unassigned';

    return await setupIssue(
        issueId,
        issueTypeId,
        projectId,
        title,
        description,
        xMeters,
        yMeters,
        zMeters,
        createdBy,
        issueSeverityCategoryId,
        issueLocationId,
        issueStatusId,
        modelId,
        modelElementId,
        reporterEmail,
        assigneeEmail
    );
}