test/e2e/api/issues.e2e.spec.tsmodifiedimport {
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
);
}